diff --git a/docs/env.md b/docs/env.md index c321af2..3189797 100644 --- a/docs/env.md +++ b/docs/env.md @@ -17,6 +17,12 @@ - **`OPENAI_API_KEY`** (o `OPENAI_APIKEY`): API key. - **`OPENAI_MODEL`**: modelo (default `gpt-4o-mini`). +### Turn Engine + +- **`TURN_ENGINE`**: motor de turnos. Valores: + - `v1` (default): pipeline actual (heurísticas + guardrails + LLM plan final). + - `v2`: “LLM-first NLU, deterministic core” (nuevo motor detrás de feature flag). + ### WooCommerce (solo fallback si falta config por tenant) - **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`). diff --git a/docs/llm_regionalismos.md b/docs/llm_regionalismos.md new file mode 100644 index 0000000..52951d9 --- /dev/null +++ b/docs/llm_regionalismos.md @@ -0,0 +1,17 @@ +# Guía de lenguaje regional (AR/UY) — Catálogo Carnicería + +## Objetivo +Evitar “no existe” por diferencias de vocabulario. Si hay ambigüedad, pedir una aclaración con opciones numeradas. + +## Ejemplos comunes +- **asado**: puede referirse a *tira de asado*, *tapa de asado*, *asado ventana*, etc. +- **vacío**: puede venir como *vacío*, *vacío pulpón*, *vacío de cerdo*, etc. +- **bondiola**: puede ser cerdo, curada/no curada, feteada, etc. +- **matambre**: puede venir como *a la pizza*, *matambre de cerdo/vacuno*, etc. + +## Reglas prácticas +- Si el usuario dice “**opciones** / **variedades** / **tenés X?**”: listar opciones (hasta 9) y ofrecer “Mostrame más…”. +- Si el usuario pregunta por **precio**: no inventar; usar `products_context` o pedir selección primero. +- Si el usuario responde solo “**por kilo**” o solo un número (ej. “2kg”), usar el último producto seleccionado (contexto). + + diff --git a/scripts/scan-unused.mjs b/scripts/scan-unused.mjs new file mode 100644 index 0000000..625df98 --- /dev/null +++ b/scripts/scan-unused.mjs @@ -0,0 +1,100 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +/** + * Scan simple de archivos JS alcanzables desde un entrypoint, + * siguiendo imports estáticos: `import ... from "./x.js"` y `await import("./x.js")`. + * + * OJO: no entiende requires dinámicos ni construcciones complejas. + * Útil para detectar archivos claramente no usados. + */ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const root = path.resolve(__dirname, ".."); + +const entry = path.join(root, "index.js"); +const srcDir = path.join(root, "src"); + +function listJsFiles(dir) { + const out = []; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop(); + const items = fs.readdirSync(cur, { withFileTypes: true }); + for (const it of items) { + const p = path.join(cur, it.name); + if (it.isDirectory()) stack.push(p); + else if (it.isFile() && p.endsWith(".js")) out.push(p); + } + } + return out; +} + +function readText(p) { + return fs.readFileSync(p, "utf8"); +} + +function resolveLocalImport(fromFile, spec) { + if (!spec.startsWith(".")) return null; + const base = path.resolve(path.dirname(fromFile), spec); + const candidates = []; + if (base.endsWith(".js")) candidates.push(base); + else { + candidates.push(`${base}.js`); + candidates.push(path.join(base, "index.js")); + } + for (const c of candidates) { + if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; + } + return null; +} + +function extractImports(code) { + const out = new Set(); + // static import ... from "x" + for (const m of code.matchAll(/import\s+[^;]*?\s+from\s+["']([^"']+)["']/g)) { + out.add(m[1]); + } + // side-effect import "x" + for (const m of code.matchAll(/import\s+["']([^"']+)["']/g)) { + out.add(m[1]); + } + // dynamic import("x") or await import("x") + for (const m of code.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g)) { + out.add(m[1]); + } + return [...out]; +} + +const allSrc = listJsFiles(srcDir); +const all = [entry, ...allSrc]; + +const reachable = new Set(); +const queue = [entry]; + +while (queue.length) { + const f = queue.shift(); + if (reachable.has(f)) continue; + reachable.add(f); + let code = ""; + try { + code = readText(f); + } catch { + continue; + } + const specs = extractImports(code); + for (const s of specs) { + const resolved = resolveLocalImport(f, s); + if (resolved && !reachable.has(resolved)) queue.push(resolved); + } +} + +const unused = allSrc + .filter((f) => !reachable.has(f)) + .map((f) => path.relative(root, f).replace(/\\/g, "/")) + .sort(); + +console.log(JSON.stringify({ entry: path.relative(root, entry), reachable_count: reachable.size, total_src: allSrc.length, unused }, null, 2)); + diff --git a/src/services/openai.js b/src/services/openai.js index cbc3d51..4cdc68d 100644 --- a/src/services/openai.js +++ b/src/services/openai.js @@ -171,3 +171,93 @@ export async function llmExtract({ input, model } = {}) { const extracted = ExtractSchema.parse(parsed); return { extracted, raw_text, model: chosenModel, usage }; } + +// --- NLU v2 (LLM-first) --- + +const NluIntentV2Schema = z.enum([ + "price_query", + "browse", + "add_to_cart", + "remove_from_cart", + "checkout", + "delivery_question", + "store_hours", + "greeting", + "other", +]); + +const NluSelectionSchema = z + .object({ + type: z.enum(["index", "text", "sku"]), + value: z.string().min(1), + }) + .nullable() + .default(null); + +const NluEntitiesSchema = z + .object({ + product_query: z.string().nullable().default(null), + quantity: z.number().nullable().default(null), + unit: z.enum(["kg", "g", "unidad", "docena"]).nullable().default(null), + selection: NluSelectionSchema, + attributes: z.array(z.string()).default([]), + preparation: z.array(z.string()).default([]), + budget: z.number().nullable().default(null), + }) + .strict(); + +const NluNeedsSchema = z + .object({ + catalog_lookup: z.boolean().default(false), + knowledge_lookup: z.boolean().default(false), + }) + .strict(); + +const NluClarificationSchema = z + .object({ + reason: z.enum([ + "ambiguous_product", + "missing_quantity", + "missing_variant", + "missing_delivery_zone", + "none", + ]), + question: z.string().nullable().default(null), + }) + .strict(); + +const NluV2Schema = z + .object({ + intent: NluIntentV2Schema, + confidence: z.number().min(0).max(1).default(0.5), + language: z.string().default("es-AR"), + entities: NluEntitiesSchema, + dialogue_act: z.enum(["answer", "ask_clarification", "confirm", "propose_options"]).default("answer"), + needs: NluNeedsSchema, + clarification: NluClarificationSchema, + }) + .strict(); + +export async function llmNlu({ input, model } = {}) { + const system = + "Sos un servicio NLU para un asistente de carnicería en Argentina (es-AR).\n" + + "Tu tarea es EXTRAER intención, entidades y acto conversacional del mensaje del usuario.\n" + + "Respondé SOLO JSON válido (sin markdown) y con keys EXACTAS según el contrato.\n" + + "\n" + + "Reglas críticas:\n" + + "- Si el contexto incluye last_shown_options y el usuario responde con un número o 'el segundo/la cuarta', eso es selection {type:'index'}.\n" + + "- Si el usuario pone '2kg' o '500g' o '3 unidades' eso es quantity+unit.\n" + + "- Si el usuario pone solo un número y hay opciones mostradas, interpretalo como selection (no como cantidad).\n" + + "- Si el contexto indica pending_item (ya hay producto elegido) y NO hay opciones mostradas, y el usuario pone solo un número, interpretalo como quantity (con unit null o la que indique el usuario).\n" + + "- No inventes productos ni SKUs. product_query es lo que el usuario pidió (ej 'asado', 'tapa de asado wagyu').\n" + + "- needs.catalog_lookup debe ser true para intents: price_query, browse, add_to_cart (salvo que sea pura selección numérica sobre opciones ya mostradas).\n"; + + const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({ + system, + user: JSON.stringify(input ?? {}), + model, + }); + + const nlu = NluV2Schema.parse(parsed); + return { nlu, raw_text, model: chosenModel, usage }; +} \ No newline at end of file diff --git a/src/services/pipeline.js b/src/services/pipeline.js index b7abf58..c918e0a 100644 --- a/src/services/pipeline.js +++ b/src/services/pipeline.js @@ -15,6 +15,7 @@ import { createWooCustomer, getWooCustomerById } from "./woo.js"; import { llmExtract, llmPlan } from "./openai.js"; import { searchProducts } from "./wooProducts.js"; import { debug as dbg } from "./debug.js"; +import { runTurnV2 } from "./turnEngineV2.js"; function nowIso() { @@ -62,6 +63,16 @@ Tool context: products_context If products_context is provided (array of products with name/price), you MUST use it as the source of truth for prices and product options. If the user asks for a price and products_context is empty or has no price, you MUST ask a single clarifying question or say you don't have that price yet. Never invent prices. +Never claim "we don't sell X" unless you actually checked products_context (or asked a clarifying question). If unsure, say you can check and ask for the product name as it appears in the catalog. + +Units / weight (IMPORTANT) + +Some products are sold by weight (kg/g) and others by unit. If products_context includes categories (e.g., bebidas/vinos) infer unit accordingly. +When asking "how much?", ask for kilos for meat cuts, and units for beverages. + +Regional language + +Users may use regional names (AR/UY): "vacío", "tira de asado", "bondiola", "matambre", "nalga", etc. If ambiguous, ask a short clarification with numbered options. basket_resolved (MANDATORY) @@ -86,6 +97,8 @@ Before returning, double-check: did I include all required keys? Did I move the function isPriceQuestion(text) { const t = String(text || "").toLowerCase(); + // Si el usuario está pidiendo "opciones" / "variedades", priorizamos browse y NO price. + if (isBrowseQuestion(t)) return false; return ( t.includes("precio") || t.includes("cuánto") || @@ -98,6 +111,38 @@ function isPriceQuestion(text) { ); } +function extractBrowseQuery(text, prev_context = null) { + const raw = String(text || "").toLowerCase(); + const prevLabel = prev_context?.slots?.product_label || null; + + // patrones: "opciones de X", "que opciones de X", "tenes X", "tenes de X" + const m1 = /\bopciones\s+de\s+(.+)$/.exec(raw); + const m2 = /\bten[eé]s\s+de\s+(.+)$/.exec(raw); + const m3 = /\bten[eé]s\s+(.+)$/.exec(raw); + // producto antes del verbo: "asado vendes?", "asado premium hay?" + const m4 = /^(.+?)\s+\b(ten[eé]s|vendes|hay)\b/.exec(raw); + const pick = (m) => + m?.[1] + ? m[1] + .replace(/[¿?¡!.,;:()"]/g, " ") + .replace(/\s+/g, " ") + .trim() + : null; + + let q = pick(m1) || pick(m2) || pick(m3) || pick(m4) || null; + if (q) { + q = q + .replace(/\b(ten[eé]s|vendes|hay|opciones|variedades|tipos)\b/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + if (q && q.length >= 3) return q; + + // si el user dice "dame opciones" y veníamos de un producto, reutilizarlo + if (/\b(dame|pasame|mandame)\s+opciones\b/.test(raw) && prevLabel) return prevLabel; + return prevLabel || null; +} + function extractProductQuery(text) { // Heurística simple: remover palabras comunes de pregunta de precio y unidades. // Esto se puede reemplazar por un extractor LLM más adelante. @@ -109,10 +154,28 @@ function extractProductQuery(text) { const stop = new Set([ "precio", + "precios", "cuanto", "cuánto", "sale", "vale", + "quiero", + "saber", + "decime", + "decir", + "dime", + "dame", + "pasame", + "mandame", + "tenes", + "tenés", + "vendes", + "hay", + "opciones", + "variedades", + "variedad", + "tipos", + "tipo", "el", "la", "los", @@ -176,6 +239,59 @@ function formatARS(n) { return x.toLocaleString("es-AR", { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } +function inferDefaultUnit({ name, categories }) { + const n = String(name || "").toLowerCase(); + const cats = Array.isArray(categories) ? categories : []; + const hay = (re) => + cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); + + // Heurística: bebidas (vino/cerveza/etc) casi siempre es por unidad. + if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { + return "unit"; + } + return "kg"; +} + +function unitDisplay(unit) { + if (unit === "unit") return "unidad"; + if (unit === "g") return "g"; + return "kilo"; +} + +function askQtyText(unit) { + if (unit === "unit") return "¿Cuántas unidades querés?"; + if (unit === "g") return "¿Cuántos gramos querés?"; + return "¿Cuántos kilos querés?"; +} + +function pricePerText(unit) { + return unit === "unit" ? "por unidad" : "el kilo"; +} + +function parseQuantityUnit(text, defaultUnit = "kg") { + const t = String(text || "").toLowerCase(); + // 2kg / 0.5 kg / 500g / 3 unidades + const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|u|unidad|unidades)\b/.exec(t); + if (m) { + const qty = Number(String(m[1]).replace(",", ".")); + const u = m[2]; + const unit = + u === "g" || u === "gramo" || u === "gramos" + ? "g" + : u === "u" || u === "unidad" || u === "unidades" + ? "unit" + : "kg"; + return Number.isFinite(qty) ? { quantity: qty, unit } : null; + } + // solo número => asumir unidad default + const m2 = /\b(\d+(?:[.,]\d+)?)\b/.exec(t); + if (m2) { + const qty = Number(String(m2[1]).replace(",", ".")); + return Number.isFinite(qty) ? { quantity: qty, unit: defaultUnit } : null; + } + return null; +} + function makePerf() { const started_at = Date.now(); const perf = { t0: started_at, marks: {} }; @@ -214,6 +330,50 @@ function isAffirmation(text) { ); } +function isBrowseQuestion(text) { + const t = String(text || "").toLowerCase(); + // "qué opciones de vacío tenés", "qué tenés de vacío", "mostrame opciones de ..." + if (/\bopciones\b/.test(t)) return true; + if (/\b(que|qué)\s+ten[eé]s\b/.test(t)) return true; + if (/\b(variedades|variedad|tipos|tipo)\b/.test(t)) return true; + if (/\bmostr(a|ame|ame)\b/.test(t) && /\b(opciones|productos)\b/.test(t)) return true; + // consultas simples de catálogo: "asado tenés?", "asado vendés?", "hay asado premium?" + if (/\b(ten[eé]s|vendes|hay)\b/.test(t)) { + const cleaned = t + .replace(/[¿?¡!.,;:()"]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const stop = new Set([ + "tenes", + "tenés", + "vendes", + "hay", + "de", + "del", + "la", + "el", + "los", + "las", + "un", + "una", + "por", + "favor", + "hola", + "buenas", + "buenos", + "dia", + "día", + "tardes", + "noches", + "precio", + "precios", + ]); + const toks = cleaned.split(" ").filter(Boolean).filter((w) => !stop.has(w)); + if (toks.length >= 1) return true; + } + return false; +} + function isGreeting(text) { const t = String(text || "").trim().toLowerCase(); if (!t) return false; @@ -229,7 +389,7 @@ function classifyIntent(text, prev_context = null) { const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; const hasPending = Boolean(prev.pending_clarification?.candidates?.length); const hasBasket = Array.isArray(prev.order_basket?.items) && prev.order_basket.items.length > 0; - const awaitingPrice = Boolean(prev.awaiting_price?.labels?.length); + const awaitingPrice = Boolean(prev.awaiting_price?.labels?.length) || Boolean(prev.pending_item); if (isAffirmation(text) && (hasPending || hasBasket || awaitingPrice)) { return { kind: awaitingPrice ? "price" : "order", intent_hint: awaitingPrice ? "ask_price" : "create_order", needs_extract: false, needs_products: awaitingPrice, is_continuation: true }; @@ -237,6 +397,10 @@ function classifyIntent(text, prev_context = null) { if (isOrderish(text)) { return { kind: "order", intent_hint: "create_order", needs_extract: true, needs_products: true }; } + // Browse ANTES que price: "opciones de asado ... buscando precios" es browse (listado) + luego precio por item. + if (isBrowseQuestion(text)) { + return { kind: "browse", intent_hint: "browse_products", needs_extract: false, needs_products: true }; + } if (isPriceQuestion(text)) { return { kind: "price", intent_hint: "ask_price", needs_extract: true, needs_products: true }; } @@ -255,6 +419,12 @@ function tokenize(s) { function candidateText(c) { const parts = [c?.name || ""]; + if (Array.isArray(c?.categories)) { + for (const cat of c.categories) { + if (cat?.name) parts.push(cat.name); + if (cat?.slug) parts.push(cat.slug); + } + } if (Array.isArray(c?.attributes)) { for (const a of c.attributes) { if (a?.name) parts.push(a.name); @@ -301,6 +471,53 @@ function computeDifferentiators(candidates) { }); } +function isShowMoreRequest(text) { + const t = String(text || "").toLowerCase(); + return ( + /\bmostr(a|ame)\s+m[aá]s\b/.test(t) || + /\bmas\s+opciones\b/.test(t) || + /\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t) || + /\bsiguiente(s)?\b/.test(t) + ); +} + +function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) { + const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name); + const off = Math.max(0, parseInt(candidateOffset, 10) || 0); + const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9)); + + const slice = cands.slice(off, off + size); + const diffs = computeDifferentiators(cands); + + const options = slice.map((c, i) => { + const d = diffs.find((z) => z.id === c.woo_product_id); + const hint = d?.unique_tokens?.length ? ` (${d.unique_tokens.join(", ")})` : ""; + return { idx: baseIdx + i, type: "product", woo_product_id: c.woo_product_id, name: c.name, hint }; + }); + + const hasMore = off + size < cands.length; + if (hasMore) { + options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" }); + } + + const list = options + .map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}${o.hint}`)) + .join("\n"); + + const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`; + const pending = { + candidates: cands, + options, + candidate_offset: off, + page_size: size, + base_idx: baseIdx, + has_more: hasMore, + next_candidate_offset: off + size, + next_base_idx: baseIdx + size + (hasMore ? 1 : 0), + }; + return { question, pending, options, hasMore }; +} + function resolveAmbiguity({ userText, candidates }) { const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name); if (cands.length <= 1) { @@ -320,31 +537,31 @@ function resolveAmbiguity({ userText, candidates }) { return { kind: "resolved", chosen: best.c, pending: null, debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) } }; } - const diffs = computeDifferentiators(scored.map((x) => x.c)); - const options = scored.slice(0, 3).map((x, i) => { - const d = diffs.find((z) => z.id === x.c.woo_product_id); - const hint = d?.unique_tokens?.length ? ` (${d.unique_tokens.join(", ")})` : ""; - return { idx: i + 1, woo_product_id: x.c.woo_product_id, name: x.c.name, hint }; - }); - - const list = options.map((o) => `${o.idx}) ${o.name}${o.hint}`).join(" / "); - const question = `¿Cuál de estos querés? ${list}`; + const full = scored.map((x) => x.c).slice(0, 60); + const { question, pending } = buildPagedOptions({ candidates: full, candidateOffset: 0, baseIdx: 1, pageSize: 9 }); return { kind: "ask", question, - pending: { candidates: scored.map((x) => x.c).slice(0, 5), options }, + pending, debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) }, }; } function parseOptionSelection(userText) { const t = String(userText || "").toLowerCase(); - const m = /\b([1-3])\b/.exec(t); + const m = /\b(\d{1,2})\b/.exec(t); if (m) return parseInt(m[1], 10); if (/\bprimera\b|\bprimero\b/.test(t)) return 1; if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2; if (/\btercera\b|\btercero\b/.test(t)) return 3; + if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4; + if (/\bquinta\b|\bquinto\b/.test(t)) return 5; + if (/\bsexta\b|\bsexto\b/.test(t)) return 6; + if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7; + if (/\boctava\b|\boctavo\b/.test(t)) return 8; + if (/\bnovena\b|\bnoveno\b/.test(t)) return 9; + if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10; return null; } @@ -427,7 +644,8 @@ async function extractProducts({ for (const it of extracted.items) { try { - const { items } = await searchProducts({ tenantId, q: it.label, limit: 5 }); + // Para ambigüedades queremos más candidatos (no 3), así el paginado 9+1 tiene con qué. + const { items } = await searchProducts({ tenantId, q: it.label, limit: 25 }); if (!items?.length) { unresolved.push({ ...it, reason: "not_found" }); continue; @@ -454,8 +672,13 @@ async function extractProducts({ ...it, reason: "ambiguous", candidates: scored - .slice(0, 3) - .map((x) => ({ woo_product_id: x.p.woo_product_id, name: x.p.name, price: x.p.price })), + .slice(0, 25) + .map((x) => ({ + woo_product_id: x.p.woo_product_id, + name: x.p.name, + price: x.p.price, + attributes: x.p.attributes || [], + })), }); continue; } @@ -480,19 +703,27 @@ async function extractProducts({ // products_context para precios / browse (soporta múltiples labels) let products_context = null; - if (classification.kind === "price" || isPriceQuestion(text)) { + if (classification.needs_products || classification.kind === "price" || isPriceQuestion(text) || classification.kind === "browse") { const labels = (extracted?.items || []).map((x) => x.label).filter(Boolean); - const queries = labels.length ? labels.slice(0, 3) : [extractProductQuery(text)]; + const prevLabel = llmInput?.context?.slots?.product_label || null; + const q0 = classification.kind === "browse" + ? (extractBrowseQuery(text, llmInput?.context) || extractProductQuery(text)) + : extractProductQuery(text); + const baseQueries = labels.length ? labels.slice(0, 3) : [prevLabel, q0].filter(Boolean); + const queries = baseQueries.length ? baseQueries.slice(0, 3) : [q0].filter(Boolean); mark("before_product_lookup"); try { const results = []; for (const q of queries) { - const { items, source } = await searchProducts({ tenantId, q, limit: 5 }); + const lim = classification.kind === "browse" ? 25 : 12; + const forceWoo = classification.kind === "browse" || classification.kind === "price"; + const { items, source } = await searchProducts({ tenantId, q, limit: lim, forceWoo }); results.push({ label: q, source, items }); } - // flatten para UI/LLM (top 1 por label primero) - const flat = results.flatMap((r) => (r.items || []).slice(0, 2).map((p) => ({ ...p, _query: r.label }))); + // flatten para UI/LLM + const perLabel = classification.kind === "browse" ? 25 : 12; + const flat = results.flatMap((r) => (r.items || []).slice(0, perLabel).map((p) => ({ ...p, _query: r.label }))); products_context = { queries, results, items: flat }; llmInput.products_context = products_context; logStage(stageDebug, "extractProducts.products_context", { queries, results: results.map((r) => ({ q: r.label, count: r.items?.length || 0 })) }); @@ -526,33 +757,73 @@ function applyBusinessLogic({ classification.kind === "price" || extracted?.intent === "ask_price" || Boolean(prev.awaiting_price?.labels?.length) || + Boolean(prev.pending_item) || isPriceQuestion(text); // 0) Si hay una ambigüedad pendiente, intentamos colapsarla con la respuesta del usuario - // (evita loops de "¿a cuál te referís?" cuando el usuario ya respondió "la no curada") + // Nota: esta rama debe tener prioridad sobre pending_item para evitar casos tipo: + // el bot mostró opciones y el usuario responde "4" => selección (NO cantidad en kg). if (prev.pending_clarification?.candidates?.length) { // Selección explícita por número (1/2/3) const sel = parseOptionSelection(text); if (sel && Array.isArray(prev.pending_clarification.options)) { const opt = prev.pending_clarification.options.find((o) => o.idx === sel); if (opt) { - const chosen = prev.pending_clarification.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null; + if (opt.type === "more") { + const nextOffset = prev.pending_clarification.next_candidate_offset ?? ((prev.pending_clarification.candidate_offset || 0) + (prev.pending_clarification.page_size || 9)); + const nextBaseIdx = prev.pending_clarification.next_base_idx ?? ((prev.pending_clarification.base_idx || 1) + (prev.pending_clarification.page_size || 9) + 1); + const { question, pending } = buildPagedOptions({ + candidates: prev.pending_clarification.candidates, + candidateOffset: nextOffset, + baseIdx: nextBaseIdx, + pageSize: prev.pending_clarification.page_size || 9, + }); + const decision = { + mode: "ask_clarification", + reply: question, + next_state: "BROWSING", + intent: extracted?.intent || classification.intent_hint || "browse_products", + missing_fields: ["product_selection"], + order_action: "none", + basket_resolved: resolvedBasket || { items: [] }, + meta: { paged: true, via: "more_option" }, + context_patch: { pending_clarification: pending, pending_item: null }, + }; + logStage(stageDebug, "applyBusinessLogic", decision); + return decision; + } + + const chosen = + prev.pending_clarification.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null; if (chosen) { const price = chosen.price ?? null; + const unit = inferDefaultUnit({ name: chosen.name, categories: chosen.categories }); const decision = { mode: "resolved_from_pending", resolved_product: chosen, reply: price != null - ? `Perfecto. ${chosen.name} está $${formatARS(price)} el kilo. ¿Cuántos kilos querés?` - : `Perfecto. ¿Cuántos kilos querés llevar de ${chosen.name}?`, + ? `Perfecto. ${chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}` + : `Perfecto. ${askQtyText(unit)} de ${chosen.name}?`, next_state: "BROWSING", intent: "ask_price", missing_fields: [], order_action: "none", basket_resolved: { items: [] }, meta: { collapsed: true, via: "option_number" }, - context_patch: { pending_clarification: null, awaiting_price: null }, + context_patch: { + pending_clarification: null, + awaiting_price: null, + pending_item: { + product_id: Number(chosen.woo_product_id), + variation_id: null, + name: chosen.name, + price: chosen.price ?? null, + categories: chosen.categories || [], + attributes: chosen.attributes || [], + default_unit: unit, + }, + }, }; logStage(stageDebug, "applyBusinessLogic", decision); return decision; @@ -560,23 +831,61 @@ function applyBusinessLogic({ } } + // “mostrame más” sin número + if (isShowMoreRequest(text)) { + const nextOffset = prev.pending_clarification.next_candidate_offset ?? ((prev.pending_clarification.candidate_offset || 0) + (prev.pending_clarification.page_size || 9)); + const nextBaseIdx = prev.pending_clarification.next_base_idx ?? ((prev.pending_clarification.base_idx || 1) + (prev.pending_clarification.page_size || 9) + 1); + const { question, pending } = buildPagedOptions({ + candidates: prev.pending_clarification.candidates, + candidateOffset: nextOffset, + baseIdx: nextBaseIdx, + pageSize: prev.pending_clarification.page_size || 9, + }); + const decision = { + mode: "ask_clarification", + reply: question, + next_state: "BROWSING", + intent: extracted?.intent || classification.intent_hint || "browse_products", + missing_fields: ["product_selection"], + order_action: "none", + basket_resolved: resolvedBasket || { items: [] }, + meta: { paged: true, via: "more_text" }, + context_patch: { pending_clarification: pending, pending_item: null }, + }; + logStage(stageDebug, "applyBusinessLogic", decision); + return decision; + } + const r = resolveAmbiguity({ userText: text, candidates: prev.pending_clarification.candidates }); if (r.kind === "resolved" && r.chosen) { // si era un price inquiry, resolvemos precio directo en base a ese candidato const price = r.chosen.price ?? null; + const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories }); const decision = { mode: "resolved_from_pending", resolved_product: r.chosen, reply: price != null - ? `Perfecto. ${r.chosen.name} está $${formatARS(price)} el kilo. ¿Cuántos kilos querés?` - : `Perfecto. ¿Cuántos kilos querés llevar de ${r.chosen.name}?`, + ? `Perfecto. ${r.chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}` + : `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`, next_state: "BROWSING", intent: "ask_price", missing_fields: [], order_action: "none", basket_resolved: { items: [] }, meta: { collapsed: true, debug: r.debug || null }, - context_patch: { pending_clarification: null, awaiting_price: null }, + context_patch: { + pending_clarification: null, + awaiting_price: null, + pending_item: { + product_id: Number(r.chosen.woo_product_id), + variation_id: null, + name: r.chosen.name, + price: r.chosen.price ?? null, + categories: r.chosen.categories || [], + attributes: r.chosen.attributes || [], + default_unit: unit, + }, + }, }; logStage(stageDebug, "applyBusinessLogic", decision); return decision; @@ -592,13 +901,85 @@ function applyBusinessLogic({ order_action: "none", basket_resolved: resolvedBasket || { items: [] }, meta: { skipped: "pending_still_ambiguous", debug: r.debug || null }, - context_patch: { pending_clarification: r.pending, awaiting_price: isPriceFlow ? { labels: [prev?.slots?.product_label].filter(Boolean) } : null }, + context_patch: { pending_clarification: r.pending, awaiting_price: isPriceFlow ? { labels: [prev?.slots?.product_label].filter(Boolean) } : null, pending_item: null }, }; logStage(stageDebug, "applyBusinessLogic", decision); return decision; } } + // 0a) Si venimos de una selección (pending_item) y el usuario responde cantidad/unidad, + // completamos basket_resolved y confirmamos sin volver a preguntar por "qué producto". + if (prev.pending_item) { + const defUnit = inferDefaultUnit({ name: prev.pending_item.name, categories: prev.pending_item.categories }); + const parsed = parseQuantityUnit(text, defUnit); + if (parsed?.quantity) { + const it = { + product_id: Number(prev.pending_item.product_id), + variation_id: prev.pending_item.variation_id ?? null, + quantity: parsed.quantity, + unit: parsed.unit, + label: prev.pending_item.name, + }; + const qtyStr = + parsed.unit === "unit" + ? `${parsed.quantity}u` + : parsed.unit === "g" + ? `${parsed.quantity}g` + : `${parsed.quantity}kg`; + const decision = { + mode: "resolved_from_pending", + reply: `Perfecto, anoto ${qtyStr} de ${prev.pending_item.name}. ¿Algo más?`, + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: [], + order_action: "none", + basket_resolved: { items: [it] }, + meta: { server: "pending_item_qty" }, + context_patch: { + pending_item: null, + pending_clarification: null, + awaiting_price: null, + order_basket: mergeBasket(prev.order_basket, { items: [it] }), + }, + }; + logStage(stageDebug, "applyBusinessLogic", decision); + return decision; + } + } + + // 0b) Browse: "qué opciones de X tenés" => listar opciones server-side y dejar pending_clarification + if (classification.kind === "browse" && products_context?.items?.length) { + const uniq = new Map(); + for (const p of products_context.items) { + if (!p?.woo_product_id) continue; + if (!uniq.has(p.woo_product_id)) uniq.set(p.woo_product_id, p); + } + const candidates = [...uniq.values()].slice(0, 60).map((p) => ({ + woo_product_id: p.woo_product_id, + name: p.name, + price: p.price ?? null, + attributes: p.attributes || [], + })); + const label = (products_context.queries || []).join(" / ") || "ese producto"; + const { question, pending } = buildPagedOptions({ candidates, candidateOffset: 0, baseIdx: 1, pageSize: 9 }); + + const decision = { + mode: "ask_clarification", + reply: `Tengo estas opciones de "${label}":\n${question.split("\n").slice(1).join("\n")}`, + next_state: "BROWSING", + intent: "browse_products", + missing_fields: ["product_confirmation"], + order_action: "none", + basket_resolved: { items: [] }, + meta: { server: "browse_options" }, + // Importante: al entrar en modo selección por lista, anulamos pending_item viejo para que un "4" no se tome como cantidad. + context_patch: { pending_clarification: pending, pending_item: null }, + }; + logStage(stageDebug, "applyBusinessLogic", decision); + return decision; + } + // 1) Ambiguo / sin resolver => 1 pregunta (pero guardando candidatos para colapsar luego) if (unresolved?.length) { const first = unresolved[0]; @@ -609,19 +990,31 @@ function applyBusinessLogic({ : null; if (r?.kind === "resolved" && r.chosen) { + const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories }); const decision = { mode: "resolved_from_pending", resolved_product: r.chosen, reply: r.chosen.price != null - ? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} el kilo. ¿Cuántos kilos querés?` - : `Perfecto. ¿Cuántos kilos querés llevar de ${r.chosen.name}?`, + ? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} ${pricePerText(unit)}. ${askQtyText(unit)}` + : `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`, next_state: "BROWSING", intent: "ask_price", missing_fields: [], order_action: "none", basket_resolved: { items: [] }, meta: { collapsed: true, debug: r.debug || null }, - context_patch: { pending_clarification: null }, + context_patch: { + pending_clarification: null, + pending_item: { + product_id: Number(r.chosen.woo_product_id), + variation_id: null, + name: r.chosen.name, + price: r.chosen.price ?? null, + categories: r.chosen.categories || [], + attributes: r.chosen.attributes || [], + default_unit: unit, + }, + }, }; logStage(stageDebug, "applyBusinessLogic", decision); return decision; @@ -668,30 +1061,71 @@ function applyBusinessLogic({ return decision; } - // 2) Precio: si tenemos precio real, responder server-side y no dejar al LLM inventar "$X" + // 2) Precio: siempre server-side (si no, el LLM inventa o pregunta cosas raras) if (isPriceFlow && products_context && Array.isArray(products_context.items)) { - // Preferimos mostrar 1–2 precios por “label” consultado (si vienen múltiples queries) - const priced = products_context.items - .filter((p) => p && p.name && p.price != null) - .slice(0, 6); + const uniq = new Map(); + for (const p of products_context.items) { + if (!p?.woo_product_id || !p?.name) continue; + if (!uniq.has(p.woo_product_id)) uniq.set(p.woo_product_id, p); + } + const candidates = [...uniq.values()].slice(0, 60).map((p) => ({ + woo_product_id: p.woo_product_id, + name: p.name, + price: p.price ?? null, + categories: p.categories || [], + attributes: p.attributes || [], + })); - if (priced.length) { - const lines = priced - .map((p) => { - const prefix = p._query ? `${p._query}: ` : ""; - return `- ${prefix}${p.name}: $${formatARS(p.price)}`; - }) - .join("\n"); + // Si hay un único candidato, respondemos el precio directo (y guardamos pending_item para cantidad) + if (candidates.length === 1) { + const c = candidates[0]; + const price = c.price ?? null; + const unit = inferDefaultUnit({ name: c.name, categories: c.categories }); const decision = { mode: "server_price", - reply: `Perfecto 🙂\n${lines}\n¿Cuántos kilos querés?`, + reply: price != null + ? `Perfecto. ${c.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}` + : `Perfecto. ${askQtyText(unit)} de ${c.name}?`, next_state: "BROWSING", intent: "ask_price", missing_fields: [], order_action: "none", basket_resolved: { items: [] }, - meta: { skipped: "price_server_side", source: products_context.source || null }, - context_patch: { pending_clarification: null, awaiting_price: null }, + meta: { server: "price_single_candidate" }, + context_patch: { + pending_clarification: null, + awaiting_price: null, + pending_item: { + product_id: Number(c.woo_product_id), + variation_id: null, + name: c.name, + price: c.price ?? null, + categories: c.categories || [], + attributes: c.attributes || [], + default_unit: unit, + }, + }, + }; + logStage(stageDebug, "applyBusinessLogic", decision); + return decision; + } + + if (candidates.length) { + const { question, pending } = buildPagedOptions({ candidates, candidateOffset: 0, baseIdx: 1, pageSize: 9 }); + const label = + (Array.isArray(products_context.queries) ? products_context.queries.join(" / ") : null) || + extracted?.items?.[0]?.label || + "ese producto"; + const decision = { + mode: "ask_clarification", + reply: `Para cotizar "${label}", ¿cuál es?\n${question.split("\n").slice(1).join("\n")}`, + next_state: "BROWSING", + intent: "ask_price", + missing_fields: ["product_selection"], + order_action: "none", + basket_resolved: { items: [] }, + meta: { server: "price_pick_product" }, + context_patch: { pending_clarification: pending, awaiting_price: { labels: [label] } }, }; logStage(stageDebug, "applyBusinessLogic", decision); return decision; @@ -923,7 +1357,8 @@ export async function processMessage({ mark("after_insertMessage_in"); mark("before_classifyIntent"); - const classification = classifyIntent(text, prev?.context); + const useTurnV2 = String(process.env.TURN_ENGINE || "").toLowerCase() === "v2"; + const classification = useTurnV2 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context); mark("after_classifyIntent"); logStage(stageDebug, "classifyIntent", classification); @@ -938,62 +1373,87 @@ export async function processMessage({ }); logStage(stageDebug, "llmInput.base", { has_history: Array.isArray(llmInput.conversation_history), state: llmInput.current_conversation_state }); - // Reducer de contexto: consolidar slots y evitar “olvidos” - mark("before_reduceContext"); - const resolveDebug = dbg.resolve; - const { extracted, resolvedBasket, unresolved, products_context } = await extractProducts({ - tenantId, - text, - llmInput, - mark, - resolveDebug, - classification, - stageDebug, - }); - const reducedContext = reduceConversationContext(prev?.context, { text, extracted, products_context: llmInput.products_context || products_context }); - mark("after_reduceContext"); - logStage(stageDebug, "reduceContext", reducedContext?.slots || {}); + let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {}; + let decision; + let plan; + let llmMeta; + let resolvedBasket = null; - // Persistimos canasta consolidada (para no "perder" items confirmados entre turnos) - if (resolvedBasket?.items?.length) { - reducedContext.order_basket = mergeBasket(reducedContext.order_basket, resolvedBasket); - } - - // (extra) por si el extractor no corrió, pero igual es pregunta de precio - if (!products_context && classification.kind === "price") { - await extractProducts({ + if (useTurnV2) { + mark("before_turn_v2"); + const out = await runTurnV2({ + tenantId, + chat_id, + text, + prev_state, + prev_context: reducedContext, + conversation_history: llmInput.conversation_history || [], + }); + plan = out.plan; + llmMeta = out.llmMeta; + decision = out.decision || { context_patch: {} }; + mark("after_turn_v2"); + } else { + // Reducer de contexto: consolidar slots y evitar “olvidos” + mark("before_reduceContext"); + const resolveDebug = dbg.resolve; + const { extracted, resolvedBasket: rb, unresolved, products_context } = await extractProducts({ tenantId, text, llmInput, - classification: { ...classification, needs_extract: false, kind: "price" }, mark, resolveDebug, + classification, stageDebug, }); + resolvedBasket = rb || null; + reducedContext = reduceConversationContext(prev?.context, { text, extracted, products_context: llmInput.products_context || products_context }); + mark("after_reduceContext"); + logStage(stageDebug, "reduceContext", reducedContext?.slots || {}); + + // Persistimos canasta consolidada (para no "perder" items confirmados entre turnos) + if (resolvedBasket?.items?.length) { + reducedContext.order_basket = mergeBasket(reducedContext.order_basket, resolvedBasket); + } + + // (extra) por si el extractor no corrió, pero igual es pregunta de precio + if (!products_context && classification.kind === "price") { + await extractProducts({ + tenantId, + text, + llmInput, + classification: { ...classification, needs_extract: false, kind: "price" }, + mark, + resolveDebug, + stageDebug, + }); + } + + mark("before_applyBusinessLogic"); + decision = applyBusinessLogic({ + text, + classification, + extracted, + resolvedBasket, + unresolved, + products_context: llmInput.products_context || products_context, + prev_context: reducedContext, + stageDebug, + }); + mark("after_applyBusinessLogic"); + + const out = await composeReply({ + text, + llmInput, + decision, + resolvedBasket, + mark, + stageDebug, + }); + plan = out.plan; + llmMeta = out.llmMeta; } - mark("before_applyBusinessLogic"); - const decision = applyBusinessLogic({ - text, - classification, - extracted, - resolvedBasket, - unresolved, - products_context: llmInput.products_context || products_context, - prev_context: reducedContext, - stageDebug, - }); - mark("after_applyBusinessLogic"); - - const { plan, llmMeta } = await composeReply({ - text, - llmInput, - decision, - resolvedBasket, - mark, - stageDebug, - }); - sanitizeIntentAndState({ plan, text, classification, prev_state }); const runStatus = llmMeta?.error ? "warn" : "ok"; diff --git a/src/services/turnEngineV2.js b/src/services/turnEngineV2.js new file mode 100644 index 0000000..86fd33d --- /dev/null +++ b/src/services/turnEngineV2.js @@ -0,0 +1,415 @@ +import { z } from "zod"; +import { llmNlu } from "./openai.js"; +import { searchProducts } from "./wooProducts.js"; + +// --- Types / Contracts (runtime-validated where it matters) --- + +const TurnActionSchema = z.object({ + type: z.enum(["show_options", "quote_price", "add_to_cart", "ask_clarification"]), + payload: z.record(z.any()).default({}), +}); + +function normalizeUnit(unit) { + const u = unit == null ? null : String(unit).toLowerCase(); + if (!u) return null; + if (u === "kg" || u === "kilo" || u === "kilos") return "kg"; + if (u === "g" || u === "gramo" || u === "gramos") return "g"; + if (u === "unidad" || u === "unidades" || u === "unit") return "unit"; + if (u === "docena" || u === "docenas") return "docena"; + return null; +} + +function pickProductQuery({ nlu, prevContext }) { + const q = nlu?.entities?.product_query || null; + if (q && String(q).trim()) return String(q).trim(); + const last = prevContext?.slots?.product_label || prevContext?.pending_item?.name || null; + return last && String(last).trim() ? String(last).trim() : null; +} + +function mapIntentToLegacy(intent) { + switch (intent) { + case "price_query": + return "ask_price"; + case "browse": + return "browse_products"; + case "add_to_cart": + return "add_item"; + case "remove_from_cart": + return "remove_item"; + case "checkout": + return "checkout"; + case "greeting": + return "other"; + default: + return "other"; + } +} + +function formatARS(n) { + const num = Number(n); + if (!Number.isFinite(num)) return String(n); + // simple ARS formatting (miles con punto) + return Math.round(num).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "."); +} + +function inferUnitHintByName(name) { + const n = String(name || "").toLowerCase(); + // Embutidos / parrilleros: puede ser kg o unidad + if (/\b(chorizo|chorizos|morcilla|morcillas|salchicha|salchichas|parrillera|parrilleras)\b/.test(n)) { + return { defaultUnit: "unit", ask: "¿Los querés por kg o por unidad?" }; + } + // Bebidas: unidad + if (/\b(vino|vinos|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/.test(n)) { + return { defaultUnit: "unit", ask: "¿Cuántas unidades querés?" }; + } + // Carnes: kg (no preguntar “por kilo”, preguntar cantidad) + return { defaultUnit: "kg", ask: "¿Cuántos kilos querés?" }; +} + +function unitAskFor(unit) { + if (unit === "g") return "¿Cuántos gramos querés?"; + if (unit === "unit") return "¿Cuántas unidades querés?"; + return "¿Cuántos kilos querés?"; +} + +function formatQty({ quantity, unit }) { + const q = Number(quantity); + if (!Number.isFinite(q) || q <= 0) return String(quantity); + if (unit === "g") return `${q}g`; + if (unit === "unit") return `${q}u`; + return `${q}kg`; +} + +function buildOptionsList(candidates, { baseIdx = 1, pageSize = 9 } = {}) { + const slice = candidates.slice(0, Math.max(1, Math.min(20, pageSize))); + const options = slice.map((c, i) => ({ + idx: baseIdx + i, + woo_product_id: c.woo_product_id, + name: c.name, + price: c.price ?? null, + })); + const list = options.map((o) => `- ${o.idx}) ${o.name}`).join("\n"); + return { options, text: `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.` }; +} + +function parseIndexSelection(text) { + const t = String(text || "").toLowerCase(); + const m = /\b(\d{1,2})\b/.exec(t); + if (!m) return null; + const n = parseInt(m[1], 10); + return Number.isFinite(n) ? n : null; +} + +/** + * Turn Engine v2: “LLM-first NLU, deterministic core” + * + * Devuelve un objeto compatible con el pipeline actual: + * { plan, llmMeta, decision } + * - plan: { reply, next_state, intent, missing_fields, order_action, basket_resolved } + * - decision.context_patch: para que pipeline lo persista + */ +export async function runTurnV2({ + tenantId, + chat_id, + text, + prev_state, + prev_context, + conversation_history, +} = {}) { + const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; + + const last_shown_options = Array.isArray(prev?.pending_clarification?.options) + ? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null })) + : []; + + const nluInput = { + last_user_message: text, + conversation_state: prev_state || "IDLE", + memory_summary: Array.isArray(conversation_history) + ? conversation_history + .slice(-6) + .map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`) + .join(" | ") + : "", + pending_context: { + pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length), + pending_item: prev?.pending_item?.name || null, + }, + last_shown_options, + locale: "es-AR", + customer_profile: prev?.customer_profile || null, + feature_flags: { turn_engine: "v2" }, + }; + + const { nlu, raw_text, model, usage } = await llmNlu({ input: nluInput }); + const llmMeta = { model, usage, raw_text, kind: "nlu_v2" }; + + const actions = []; + const context_patch = {}; + + // --- AWAITING_QUANTITY / pending_item --- + // Si ya hay un producto elegido, el siguiente turno suele ser cantidad/unidad. + if (prev?.pending_item?.product_id && !prev?.pending_clarification?.candidates?.length) { + const q0 = nlu?.entities?.quantity; + const u0 = normalizeUnit(nlu?.entities?.unit); + // Permitir "por kg"/"por unidad" sin número: actualizamos default_unit y preguntamos cantidad. + if (!q0 && u0) { + context_patch.pending_item = { ...(prev.pending_item || {}), default_unit: u0 === "g" ? "g" : u0 === "unit" ? "unit" : "kg" }; + const plan = { + reply: unitAskFor(context_patch.pending_item.default_unit), + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: ["quantity"], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_set_unit" } }; + return { plan, llmMeta, decision }; + } + + // Número solo (ej "4") -> quantity, con unit por default_unit del pending_item. + if (q0 && Number(q0) > 0) { + const defaultUnit = prev.pending_item.default_unit || "kg"; + const unit = u0 === "g" ? "g" : u0 === "unit" || u0 === "docena" ? "unit" : defaultUnit === "g" ? "g" : defaultUnit === "unit" ? "unit" : "kg"; + const qty = u0 === "docena" ? Number(q0) * 12 : Number(q0); + const it = { + product_id: Number(prev.pending_item.product_id), + variation_id: prev.pending_item.variation_id ?? null, + quantity: qty, + unit, + label: prev.pending_item.name || "ese producto", + }; + context_patch.pending_item = null; + context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] }; + actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } }); + const plan = { + reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${it.label}. ¿Algo más?`, + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: [], + order_action: "none", + basket_resolved: { items: [it] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_qty" } }; + return { plan, llmMeta, decision }; + } + + // Si no entendimos cantidad, re-preguntamos con la unidad correcta. + const fallbackUnit = prev.pending_item.default_unit || inferUnitHintByName(prev.pending_item.name || "").defaultUnit; + const plan = { + reply: unitAskFor(fallbackUnit === "unit" ? "unit" : fallbackUnit === "g" ? "g" : "kg"), + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: ["quantity"], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_reask" } }; + return { plan, llmMeta, decision }; + } + + // --- Deterministic overrides from state/pending --- + // Si hay pending_clarification, una respuesta numérica debe interpretarse como selección. + const selectionIdx = + (nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ?? + (prev?.pending_clarification?.candidates?.length ? parseIndexSelection(text) : null); + + // Si hay selectionIdx y options vigentes, elegimos producto. + let chosen = null; + if (selectionIdx && Array.isArray(prev?.pending_clarification?.options)) { + const opt = prev.pending_clarification.options.find((o) => o.idx === selectionIdx && o.type === "product"); + if (opt) { + chosen = prev.pending_clarification.candidates?.find((c) => c.woo_product_id === opt.woo_product_id) || null; + } + } + + // --- Retrieval (catálogo) --- + const productQuery = pickProductQuery({ nlu, prevContext: prev }); + let candidates = []; + if (nlu?.needs?.catalog_lookup || ["price_query", "browse", "add_to_cart"].includes(nlu.intent)) { + if (productQuery) { + const { items } = await searchProducts({ tenantId, q: productQuery, limit: 12 }); + candidates = Array.isArray(items) ? items : []; + } + } + + // Si no venía de pending_clarification pero NLU pidió browse, y tenemos candidatos, mostramos lista. + if (!chosen && (nlu.intent === "browse" || (nlu.intent === "price_query" && candidates.length > 1))) { + const { options, text: listText } = buildOptionsList(candidates, { baseIdx: 1, pageSize: 9 }); + actions.push({ type: "show_options", payload: { count: options.length } }); + context_patch.pending_clarification = { + candidates: candidates.map((c) => ({ + woo_product_id: c.woo_product_id, + name: c.name, + price: c.price ?? null, + categories: c.categories || [], + attributes: c.attributes || [], + })), + options: options.map((o) => ({ idx: o.idx, type: "product", woo_product_id: o.woo_product_id, name: o.name })), + candidate_offset: 0, + page_size: 9, + base_idx: 1, + has_more: false, + next_candidate_offset: options.length, + next_base_idx: options.length + 1, + }; + // clave: al entrar en selección por lista, anulamos pending_item viejo + context_patch.pending_item = null; + const plan = { + reply: listText, + next_state: "BROWSING", + intent: "browse_products", + missing_fields: ["product_selection"], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } }; + return { plan, llmMeta, decision }; + } + + // Si hubo elección por índice (chosen), pasamos a AWAITING_QUANTITY (representado como BUILDING_ORDER). + if (chosen) { + actions.push({ type: "ask_clarification", payload: { reason: "missing_quantity" } }); + const unitHint = inferUnitHintByName(chosen.name); + context_patch.pending_clarification = null; + context_patch.pending_item = { + product_id: Number(chosen.woo_product_id), + variation_id: null, + name: chosen.name, + price: chosen.price ?? null, + categories: chosen.categories || [], + attributes: chosen.attributes || [], + default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg", + }; + + // si el mismo turno ya trajo cantidad, agregamos al carrito directo + const q = nlu?.entities?.quantity; + const u = normalizeUnit(nlu?.entities?.unit); + if (q && Number(q) > 0) { + const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : "kg"; + const qty = u === "docena" ? Number(q) * 12 : Number(q); + const it = { + product_id: Number(chosen.woo_product_id), + variation_id: null, + quantity: qty, + unit, + label: chosen.name, + }; + context_patch.pending_item = null; + context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] }; + actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } }); + const plan = { + reply: `Perfecto, anoto ${unit === "kg" ? `${qty}kg` : `${qty}u`} de ${chosen.name}. ¿Algo más?`, + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: [], + order_action: "none", + basket_resolved: { items: [it] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } }; + return { plan, llmMeta, decision }; + } + + const plan = { + reply: unitHint.ask, + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: ["quantity"], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } }; + return { plan, llmMeta, decision }; + } + + // price_query: si hay 1 candidato claro => cotizar + if (nlu.intent === "price_query" && candidates.length === 1) { + const c = candidates[0]; + actions.push({ type: "quote_price", payload: { product_id: c.woo_product_id } }); + const unitHint = inferUnitHintByName(c.name); + const price = c.price; + const per = unitHint.defaultUnit === "unit" ? "por unidad" : "el kilo"; + const reply = + price != null + ? `${c.name} está $${formatARS(price)} ${per}. ${unitHint.ask}` + : `Dale, lo reviso y te confirmo el precio en un momento. ${unitHint.ask}`; + // en price, dejamos pending_item para que el siguiente turno sea cantidad + context_patch.pending_item = { + product_id: Number(c.woo_product_id), + variation_id: null, + name: c.name, + price: c.price ?? null, + categories: c.categories || [], + attributes: c.attributes || [], + default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg", + }; + context_patch.pending_clarification = null; + const plan = { + reply, + next_state: "BROWSING", + intent: "ask_price", + missing_fields: ["quantity"], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, candidates_count: candidates.length } }; + return { plan, llmMeta, decision }; + } + + // add_to_cart sin candidates: preguntamos producto + if (nlu.intent === "add_to_cart" && !productQuery) { + actions.push({ type: "ask_clarification", payload: { reason: "missing_product" } }); + const plan = { + reply: "Dale. ¿Qué producto querés agregar?", + next_state: prev_state || "IDLE", + intent: "add_item", + missing_fields: ["product_query"], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu } }; + return { plan, llmMeta, decision }; + } + + // add_to_cart con candidates y cantidad: si hay 1 candidato claro, agregamos directo. + if (nlu.intent === "add_to_cart" && candidates.length === 1 && nlu?.entities?.quantity && Number(nlu.entities.quantity) > 0) { + const c = candidates[0]; + const u = normalizeUnit(nlu?.entities?.unit); + const unitHint = inferUnitHintByName(c.name); + const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : unitHint.defaultUnit === "unit" ? "unit" : "kg"; + const qty = u === "docena" ? Number(nlu.entities.quantity) * 12 : Number(nlu.entities.quantity); + const it = { product_id: Number(c.woo_product_id), variation_id: null, quantity: qty, unit, label: c.name }; + context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] }; + actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } }); + const plan = { + reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${c.name}. ¿Algo más?`, + next_state: "BUILDING_ORDER", + intent: "add_item", + missing_fields: [], + order_action: "none", + basket_resolved: { items: [it] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "add_to_cart_direct" } }; + return { plan, llmMeta, decision }; + } + + // Fallback: respuesta segura y corta, sin inventar. + const legacyIntent = mapIntentToLegacy(nlu.intent); + const plan = { + reply: nlu?.clarification?.question || "Dale. ¿Qué necesitás exactamente?", + next_state: prev_state || "IDLE", + intent: legacyIntent, + missing_fields: [], + order_action: "none", + basket_resolved: { items: [] }, + }; + const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } }; + // validate actions shape (best-effort, no throw) + try { + decision.actions = Array.isArray(actions) ? actions.map((a) => TurnActionSchema.parse(a)) : []; + } catch { + decision.actions = []; + } + return { plan, llmMeta, decision }; +} + diff --git a/src/services/wooProducts.js b/src/services/wooProducts.js index 67e42f7..07aeb21 100644 --- a/src/services/wooProducts.js +++ b/src/services/wooProducts.js @@ -95,6 +95,13 @@ function normalizeWooProduct(p) { price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price), currency: null, type: p?.type || null, // simple | variable | grouped | external + categories: Array.isArray(p?.categories) + ? p.categories.map((c) => ({ + id: c?.id ?? null, + name: c?.name ?? null, + slug: c?.slug ?? null, + })) + : [], attributes: Array.isArray(p?.attributes) ? p.attributes.map((a) => ({ name: a?.name || null, @@ -126,8 +133,9 @@ export async function searchProducts({ // 1) Cache en Postgres const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim }); - // 2) Si no hay nada (o force), buscamos en Woo y cacheamos - const needWooSearch = forceWoo || cached.length === 0; + // 2) Si no hay suficiente (o force), buscamos en Woo y cacheamos + // Nota: si el cache tiene 3 items pero pedimos 12, igual necesitamos ir a Woo para no “recortar” catálogo. + const needWooSearch = forceWoo || cached.length < lim; const client = await getWooClient({ tenantId }); let wooItems = []; @@ -228,6 +236,7 @@ export async function searchProducts({ price: p.price, currency: p.currency, type: p.type, + categories: p.categories, attributes: p.attributes, raw_price: p.raw_price, source: "woo", @@ -240,6 +249,13 @@ export async function searchProducts({ currency: c.currency, refreshed_at: c.refreshed_at, type: c?.payload?.type || null, + categories: Array.isArray(c?.payload?.categories) + ? c.payload.categories.map((cat) => ({ + id: cat?.id ?? null, + name: cat?.name ?? null, + slug: cat?.slug ?? null, + })) + : [], attributes: Array.isArray(c?.payload?.attributes) ? c.payload.attributes.map((a) => ({ name: a?.name || null,