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