D1 redesign: quantityParser determinista (es-AR) + 46 tests

Primer paso del rediseño tool-calling agent. Setup:
- DeepSeek V4 confirmado vía OPENAI_BASE_URL en .env (no commiteado).
- Smoke test exitoso: tools+tool_choice nativos andan con deepseek-chat.

Nuevo: src/modules/3-turn-engine/agent/quantityParser.js
- Parser determinista que pre-procesa la query del usuario para extraer
  cantidad+unidad ANTES del LLM. Resultado va al agente como side-channel
  (working_memory.preparsed); el agente puede sobreescribirlo.
- Cubre AR-es: fracciones (1/4 kg, 3/4), frases compuestas (media docena,
  cuarto kilo, tres cuartos, medio kilo, par, docena), numéricos pegados
  (2kg, 0.5kg, 500g, 2,5 kilos), numéricos solos, palabras + unidad.
- Confidence escalonado: fraction 0.95, phrase/numeric+unit 0.9,
  word+unit 0.85, numeric solo 0.7.

Tests: 46/46 pasan, incluyen casos de WhatsApp real, casos negativos,
edge cases (división por cero, string vacío, decimales con coma).

Total suite: 238/238 (192 previos + 46 quantity).

Próximos pasos del plan: D1 workingMemory.js + runTurn skeleton, D2 tools
cart, D3 tools shipping/checkout, D4 customerProfile, D5 catalog fallback,
D6 system prompt + tuning, D7 persistencia, D8 shadow validation,
D9 cleanup legacy, D10 hardening. Plan completo en
~/.claude/plans/ok-creo-que-tiene-humming-sutton.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-02 12:31:59 -03:00
parent 6376739f48
commit 9c69cf8911
2 changed files with 341 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
/**
* Quantity parser determinista (es-AR).
*
* Pre-procesa el texto del usuario para extraer cantidad+unidad ANTES del LLM.
* El resultado se pasa al agente como side-channel (`working_memory.preparsed`)
* — el agente lo ve pero puede sobreescribirlo si el contexto lo amerita.
*
* Cubre los patrones AR-es más comunes:
* - Fracciones: "1/4 kg", "1/2 kilo"
* - Frases compuestas: "media docena", "cuarto kilo", "cuarto de kilo",
* "tres cuartos", "medio kilo", "par"
* - Numerales con unidad: "300 gramos", "0.5kg", "2 botellas"
* - Numerales solos: "300", "0.5" (unit=null para que el contexto decida)
*/
const NUMBER_WORDS = {
un: 1, uno: 1, una: 1,
dos: 2, tres: 3, cuatro: 4, cinco: 5,
seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,
dieciseis: 16, diecisiete: 17, dieciocho: 18, diecinueve: 19,
veinte: 20, veintidos: 22, veinticinco: 25, treinta: 30,
};
// Frases compuestas. Orden importa: las más largas primero.
const PHRASES = [
["tres cuartos de kilo", { qty: 0.75, unit: "kg" }],
["tres cuartos kilo", { qty: 0.75, unit: "kg" }],
["tres cuartos", { qty: 0.75, unit: "kg" }],
["cuarto de kilo", { qty: 0.25, unit: "kg" }],
["cuarto kilo", { qty: 0.25, unit: "kg" }],
["un cuarto", { qty: 0.25, unit: "kg" }],
["media docena", { qty: 6, unit: "unit" }],
["medio kilo", { qty: 0.5, unit: "kg" }],
["media kilo", { qty: 0.5, unit: "kg" }],
["dos kilos", { qty: 2, unit: "kg" }],
["tres kilos", { qty: 3, unit: "kg" }],
["cinco kilos", { qty: 5, unit: "kg" }],
["un kilo", { qty: 1, unit: "kg" }],
["docena", { qty: 12, unit: "unit" }],
["par", { qty: 2, unit: "unit" }],
["pareja", { qty: 2, unit: "unit" }],
];
// Fracción: respeta separación opcional con espacios. NO depende de \b.
const FRACTION = /(\d+)\s*\/\s*(\d+)/;
// Decimal con punto o coma. Capta "2.5", "0,3", "300", "0.5"
const NUMERIC = /(\d+(?:[.,]\d+)?)/;
// Unidades: pueden venir pegadas al número ("2kg") o separadas ("2 kg").
// Lookbehind opcional para dígito o non-letter; lookahead obligatorio para
// non-letter o fin de string. Probamos kilos antes que gramos para evitar
// que "kg" matchee "g" (ambos requieren post boundary, pero kg es 2 chars).
const UNIT_KG_RE = /(?:kgs?|kilos?|kilogramos?)(?![a-z])/i;
const UNIT_G_RE = /(?:^|[^a-z])(?:g|gr|grs|gramos?)(?![a-z])/i;
const UNIT_UNIT_RE = /(?:unidad(?:es)?|botellas?|frascos?|paquetes?|atados?|piezas?)(?![a-z])|(?:^|\s)u(?:\s|$)/i;
/**
* Lower + sin diacríticos. Conserva dígitos y separadores numéricos.
*/
function lowerNoDiacritics(text) {
return String(text || "")
.toLowerCase()
.normalize("NFD").replace(/[̀-ͯ]/g, "");
}
/**
* Detecta la unidad explícita en el texto. Devuelve null si no hay.
*/
export function detectUnit(text) {
const t = lowerNoDiacritics(text);
if (!t) return null;
if (UNIT_KG_RE.test(t)) return "kg";
if (UNIT_G_RE.test(t)) return "g";
if (UNIT_UNIT_RE.test(t)) return "unit";
return null;
}
function postProcess(qty, unit) {
if (!Number.isFinite(qty) || qty <= 0) return null;
return { qty, unit };
}
/**
* Extrae cantidad+unidad del texto. Devuelve `null` si no encuentra nada confiable.
* Confidence:
* - 0.95 fracción explícita
* - 0.9 frase compuesta o numérico+unit
* - 0.85 palabra + unit
* - 0.7 numérico solo
*/
export function parseQuantity(text) {
const raw = String(text || "").trim();
if (!raw) return null;
const t = lowerNoDiacritics(raw);
// 1) Fracción explícita
const fracMatch = FRACTION.exec(t);
if (fracMatch) {
const num = Number(fracMatch[1]);
const den = Number(fracMatch[2]);
if (den > 0 && Number.isFinite(num) && num > 0) {
const value = num / den;
const unit = detectUnit(t);
// Si no hay unit explícita pero menciona "kilo"/"kg" o similar, igual cae.
// detectUnit lo cubre; si t contiene "kilo" después de "1/2", lo agarra.
const result = postProcess(value, unit);
if (result) return { ...result, confidence: 0.95, source: "fraction" };
} else {
return null; // div por cero / fracción inválida
}
}
// 2) Frases compuestas (las más largas primero)
for (const [phrase, payload] of PHRASES) {
if (t.includes(phrase)) {
const explicitUnit = detectUnit(t);
// Si hay unit explícita en el texto, gana sobre la default de la frase
const finalUnit = explicitUnit || payload.unit;
return { qty: payload.qty, unit: finalUnit, confidence: 0.9, source: "phrase", phrase };
}
}
// 3) Numérico con unit
const numMatch = NUMERIC.exec(t);
if (numMatch) {
const value = parseFloat(numMatch[1].replace(",", "."));
const unit = detectUnit(t);
if (unit) {
const result = postProcess(value, unit);
if (result) return { ...result, confidence: 0.9, source: "numeric_with_unit" };
}
// Numérico solo (sin unit)
const result = postProcess(value, null);
if (result) return { ...result, confidence: 0.7, source: "numeric_alone" };
}
// 4) Palabra (ej: "dos botellas")
for (const [word, value] of Object.entries(NUMBER_WORDS)) {
const re = new RegExp(`(?:^|\\s)${word}(?:\\s|$)`);
if (re.test(t)) {
const unit = detectUnit(t);
if (unit) return { qty: value, unit, confidence: 0.85, source: "word_with_unit" };
}
}
return null;
}

View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from "vitest";
import { parseQuantity, detectUnit } from "./quantityParser.js";
describe("parseQuantity — fracciones", () => {
it("'1/4 kg' → 0.25 kg", () => {
const r = parseQuantity("1/4 kg");
expect(r).toMatchObject({ qty: 0.25, unit: "kg", source: "fraction" });
});
it("'1/2 kilo' → 0.5 kg", () => {
const r = parseQuantity("1/2 kilo");
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'3/4 kilos de matambre' → 0.75 kg", () => {
const r = parseQuantity("3/4 kilos de matambre");
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
});
it("'1/2' sin unidad → 0.5 sin unit", () => {
const r = parseQuantity("1/2");
expect(r).toMatchObject({ qty: 0.5, unit: null });
});
it("'1/0' division por cero → null", () => {
expect(parseQuantity("1/0")).toBeNull();
});
});
describe("parseQuantity — frases compuestas", () => {
it("'media docena' → 6 unit", () => {
const r = parseQuantity("media docena");
expect(r).toMatchObject({ qty: 6, unit: "unit", source: "phrase" });
});
it("'media docena de chorizos' → 6 unit", () => {
const r = parseQuantity("media docena de chorizos");
expect(r).toMatchObject({ qty: 6, unit: "unit" });
});
it("'cuarto de kilo' → 0.25 kg", () => {
const r = parseQuantity("cuarto de kilo");
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
});
it("'cuarto kilo' → 0.25 kg", () => {
const r = parseQuantity("cuarto kilo");
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
});
it("'tres cuartos' → 0.75 kg", () => {
const r = parseQuantity("tres cuartos");
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
});
it("'tres cuartos de kilo' → 0.75 kg", () => {
const r = parseQuantity("tres cuartos de kilo");
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
});
it("'medio kilo' → 0.5 kg", () => {
const r = parseQuantity("medio kilo");
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'media kilo' → 0.5 kg (typo común)", () => {
const r = parseQuantity("media kilo");
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'docena' → 12 unit", () => {
expect(parseQuantity("una docena")).toMatchObject({ qty: 12, unit: "unit" });
});
it("'par' → 2 unit", () => {
expect(parseQuantity("un par de chorizos")).toMatchObject({ qty: 2, unit: "unit" });
});
});
describe("parseQuantity — numéricos con unidad", () => {
it("'300 gramos' → 300 g", () => {
expect(parseQuantity("300 gramos")).toMatchObject({ qty: 300, unit: "g" });
});
it("'500g' → 500 g", () => {
expect(parseQuantity("500g")).toMatchObject({ qty: 500, unit: "g" });
});
it("'2kg' → 2 kg", () => {
expect(parseQuantity("2kg")).toMatchObject({ qty: 2, unit: "kg" });
});
it("'2.5 kilos' → 2.5 kg", () => {
expect(parseQuantity("2.5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
});
it("'2,5 kilos' (coma decimal) → 2.5 kg", () => {
expect(parseQuantity("2,5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
});
it("'0.5kg' → 0.5 kg", () => {
expect(parseQuantity("0.5kg")).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'3 botellas' → 3 unit", () => {
expect(parseQuantity("3 botellas")).toMatchObject({ qty: 3, unit: "unit" });
});
it("'2 unidades' → 2 unit", () => {
expect(parseQuantity("2 unidades")).toMatchObject({ qty: 2, unit: "unit" });
});
it("'1 atado' → 1 unit", () => {
expect(parseQuantity("1 atado")).toMatchObject({ qty: 1, unit: "unit" });
});
});
describe("parseQuantity — numéricos solos", () => {
it("'300' → 300 (sin unit)", () => {
expect(parseQuantity("300")).toMatchObject({ qty: 300, unit: null });
});
it("'2.5' → 2.5 (sin unit)", () => {
expect(parseQuantity("2.5")).toMatchObject({ qty: 2.5, unit: null });
});
});
describe("parseQuantity — palabras + unidad", () => {
it("'dos botellas' → 2 unit", () => {
expect(parseQuantity("dos botellas")).toMatchObject({ qty: 2, unit: "unit" });
});
it("'tres kilos' → 3 kg (frase compuesta)", () => {
expect(parseQuantity("tres kilos")).toMatchObject({ qty: 3, unit: "kg" });
});
});
describe("parseQuantity — casos negativos", () => {
it("texto sin números retorna null", () => {
expect(parseQuantity("hola que tal")).toBeNull();
});
it("string vacío retorna null", () => {
expect(parseQuantity("")).toBeNull();
});
it("null/undefined retorna null", () => {
expect(parseQuantity(null)).toBeNull();
expect(parseQuantity(undefined)).toBeNull();
});
it("cantidad cero retorna null", () => {
expect(parseQuantity("0 kg")).toBeNull();
});
});
describe("detectUnit", () => {
it("detecta kg/kilo/kilos", () => {
expect(detectUnit("2 kg")).toBe("kg");
expect(detectUnit("dos kilos")).toBe("kg");
expect(detectUnit("medio kilogramo")).toBe("kg");
});
it("detecta g/gr/gramos", () => {
expect(detectUnit("300 g")).toBe("g");
expect(detectUnit("500 gr")).toBe("g");
expect(detectUnit("100 gramos")).toBe("g");
});
it("detecta unidades múltiples", () => {
expect(detectUnit("3 unidades")).toBe("unit");
expect(detectUnit("una botella")).toBe("unit");
expect(detectUnit("2 frascos")).toBe("unit");
expect(detectUnit("1 atado")).toBe("unit");
});
it("retorna null sin unidad explícita", () => {
expect(detectUnit("dame 3")).toBeNull();
});
});
describe("parseQuantity — confidence", () => {
it("fraction: confidence 0.95", () => {
expect(parseQuantity("1/4 kg").confidence).toBe(0.95);
});
it("phrase: confidence 0.9", () => {
expect(parseQuantity("media docena").confidence).toBe(0.9);
});
it("numeric_with_unit: confidence 0.9", () => {
expect(parseQuantity("300 gramos").confidence).toBe(0.9);
});
it("numeric_alone: confidence 0.7", () => {
expect(parseQuantity("300").confidence).toBe(0.7);
});
});
describe("parseQuantity — casos de WhatsApp real", () => {
it("'dame 1/4 de matambre' → 0.25 sin unit (el contexto resuelve)", () => {
// sin "kg"/"kilo" explícito, el parser deja unit=null. El agente
// infiere "kg" porque matambre vende por peso.
expect(parseQuantity("dame 1/4 de matambre")).toMatchObject({ qty: 0.25, unit: null });
});
it("'media docena de chorizos por favor' → 6 unit", () => {
expect(parseQuantity("media docena de chorizos por favor")).toMatchObject({ qty: 6, unit: "unit" });
});
it("'2.5kg de asado' → 2.5 kg", () => {
expect(parseQuantity("2.5kg de asado")).toMatchObject({ qty: 2.5, unit: "kg" });
});
it("'cuarto kilo de fuet' → 0.25 kg", () => {
expect(parseQuantity("cuarto kilo de fuet")).toMatchObject({ qty: 0.25, unit: "kg" });
});
it("'un kilo y medio' → ambiguo, por ahora cae a phrase 'un kilo' → 1 kg", () => {
// limitación conocida — el LLM puede sobreescribir
const r = parseQuantity("un kilo y medio");
expect(r.qty).toBe(1);
});
it("'mandame 3 chorizos' → 3 unit (vía word + unit detection)", () => {
const r = parseQuantity("mandame 3 chorizos");
// chorizos no es una unit reconocida, así que queda numeric_alone
expect(r.qty).toBe(3);
});
});