From 9c69cf8911d7b2234a02e64712f0504948d5b62b Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Sat, 2 May 2026 12:31:59 -0300 Subject: [PATCH] D1 redesign: quantityParser determinista (es-AR) + 46 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../3-turn-engine/agent/quantityParser.js | 148 ++++++++++++++ .../agent/quantityParser.test.js | 193 ++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 src/modules/3-turn-engine/agent/quantityParser.js create mode 100644 src/modules/3-turn-engine/agent/quantityParser.test.js diff --git a/src/modules/3-turn-engine/agent/quantityParser.js b/src/modules/3-turn-engine/agent/quantityParser.js new file mode 100644 index 0000000..a4c529e --- /dev/null +++ b/src/modules/3-turn-engine/agent/quantityParser.js @@ -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; +} diff --git a/src/modules/3-turn-engine/agent/quantityParser.test.js b/src/modules/3-turn-engine/agent/quantityParser.test.js new file mode 100644 index 0000000..fb67e46 --- /dev/null +++ b/src/modules/3-turn-engine/agent/quantityParser.test.js @@ -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); + }); +});