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;
|
||||
}
|
||||
Reference in New Issue
Block a user