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;
}