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:
148
src/modules/3-turn-engine/agent/quantityParser.js
Normal file
148
src/modules/3-turn-engine/agent/quantityParser.js
Normal 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;
|
||||
}
|
||||
193
src/modules/3-turn-engine/agent/quantityParser.test.js
Normal file
193
src/modules/3-turn-engine/agent/quantityParser.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user