From f784ddd62d2fcdce6c93f219faedb4d4c2a0bf98 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Fri, 1 May 2026 19:29:02 -0300 Subject: [PATCH] =?UTF-8?q?Tier=201:=20chat=20quality=20=E2=80=94=20fuzzy?= =?UTF-8?q?=20aliases,=20reply=20templates,=20dedup,=20rewriter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foco: matar repetición y adaptar respuestas. Los handlers tenían ~30 strings hardcodeadas (3-7 lugares cada una). Aliases hacían substring exacto. - pg_trgm + GIN indexes en product_aliases / alias_product_mappings. Captura plurales, diminutivos, typos sin reglas. catalogRetrieval re-busca el snapshot con normalized_alias cuando el query original no rinde (vasio→vacio→Vacío). - reply_templates table + replyTemplates.js. 20 keys, 2-3 variantes c/u con DEFAULTS hardcodeados como fallback. pickVariant excluye las usadas en context.recent_replies (FIFO cap 8). Wired en idle/cart/cartHelpers/ shipping/payment/waiting. - failed_searches counter en context. count>=3 escala via humanFallback. Reset en cada add_to_cart exitoso. - storeContext.js: vars derivadas de getStoreConfig (delivery_zones, hours, zonas) listas para inyectar en templates cuando los datos se carguen. - replyRewriter.js: LLM call opcional (REPLY_REWRITER=1) que adapta el template al hilo conversacional. 1.5s timeout, fallback al template puro. Sólo activo en 8 slots semánticamente importantes. - 12 unit tests para replyTemplates (rotation, recency, FIFO, vars). 208 tests totales pasando. Plan completo: ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 11 + .../20260501100000_product_aliases_trgm.sql | 17 + .../20260501110000_reply_templates.sql | 24 ++ env.example | 6 + src/modules/2-identity/db/repo.js | 61 +++- src/modules/3-turn-engine/catalogRetrieval.js | 78 ++--- src/modules/3-turn-engine/replyRewriter.js | 204 +++++++++++ src/modules/3-turn-engine/replyTemplates.js | 317 ++++++++++++++++++ .../3-turn-engine/replyTemplates.test.js | 136 ++++++++ .../3-turn-engine/stateHandlers/cart.js | 222 +++++++----- .../stateHandlers/cartHelpers.js | 275 +++++++++------ .../3-turn-engine/stateHandlers/idle.js | 26 +- .../3-turn-engine/stateHandlers/payment.js | 46 +-- .../3-turn-engine/stateHandlers/shipping.js | 76 +++-- .../3-turn-engine/stateHandlers/waiting.js | 12 +- src/modules/3-turn-engine/storeContext.js | 104 ++++++ src/modules/3-turn-engine/turnEngineV3.js | 40 ++- 17 files changed, 1347 insertions(+), 308 deletions(-) create mode 100644 db/migrations/20260501100000_product_aliases_trgm.sql create mode 100644 db/migrations/20260501110000_reply_templates.sql create mode 100644 src/modules/3-turn-engine/replyRewriter.js create mode 100644 src/modules/3-turn-engine/replyTemplates.js create mode 100644 src/modules/3-turn-engine/replyTemplates.test.js create mode 100644 src/modules/3-turn-engine/storeContext.js diff --git a/CLAUDE.md b/CLAUDE.md index 076abce..52c49de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,17 @@ npm run seed # Seed a tenant via scripts/seed-tenant.mjs No lint command is configured. +## Product goal + +The bot must be **conversational and intelligent**, not a menu-driven flow. Customers reach out via WhatsApp **with intent to buy** — the bot's job is to: + +1. **Engage in conversation** — answer questions about products, prices, availability/stock; recommend; clarify. +2. **Take orders** — build a cart through natural dialogue (multi-product turns, quantities, units). +3. **Collect delivery data** — address, delivery vs pickup, payment method. +4. **Operate within store rules** — delivery zones, days/hours, pickup windows. These config tables (`delivery_zones`, store schedule in `tenant_settings`) will be populated later; the bot has to read and respect them when present. + +Repetitive, hardcoded responses are a known quality problem and the focus of the active improvement plan (see `~/.claude/plans/ok-creo-que-tiene-humming-sutton.md`). The system is **not yet in production** — refactors that change behavior are acceptable. + ## Architecture This is a **multi-tenant WhatsApp e-commerce chatbot** powered by Express.js. Tenants are WooCommerce store operators; their customers interact via WhatsApp to browse products, build carts, and place orders. All database operations are isolated by `tenant_id`. diff --git a/db/migrations/20260501100000_product_aliases_trgm.sql b/db/migrations/20260501100000_product_aliases_trgm.sql new file mode 100644 index 0000000..9d027da --- /dev/null +++ b/db/migrations/20260501100000_product_aliases_trgm.sql @@ -0,0 +1,17 @@ +-- migrate:up +-- pg_trgm para fuzzy matching de aliases: +-- - Captura plurales (vacio↔vacios), diminutivos (costillita↔costilla), +-- typos (vasio↔vacio) sin escribir reglas. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS product_aliases_norm_trgm_idx + ON product_aliases USING gin (normalized_alias gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS alias_product_mappings_alias_trgm_idx + ON alias_product_mappings USING gin (alias gin_trgm_ops); + +-- migrate:down +DROP INDEX IF EXISTS alias_product_mappings_alias_trgm_idx; +DROP INDEX IF EXISTS product_aliases_norm_trgm_idx; +-- Intencionalmente NO se hace DROP EXTENSION pg_trgm: +-- puede ser usada por otras consultas/migraciones futuras. diff --git a/db/migrations/20260501110000_reply_templates.sql b/db/migrations/20260501110000_reply_templates.sql new file mode 100644 index 0000000..63168d4 --- /dev/null +++ b/db/migrations/20260501110000_reply_templates.sql @@ -0,0 +1,24 @@ +-- migrate:up +-- Templates de respuestas con variantes para evitar repetición. +-- Filosofía: cada slot semántico (ej. cart.ask_more) tiene N variantes; +-- el código rota entre ellas excluyendo las recientemente usadas. +CREATE TABLE reply_templates ( + id SERIAL PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + template_key VARCHAR(80) NOT NULL, + variant INTEGER NOT NULL DEFAULT 1, + content TEXT NOT NULL, + weight INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_reply_variant UNIQUE(tenant_id, template_key, variant) +); + +CREATE INDEX idx_reply_active + ON reply_templates(tenant_id, template_key) + WHERE is_active = true; + +-- migrate:down +DROP INDEX IF EXISTS idx_reply_active; +DROP TABLE IF EXISTS reply_templates; diff --git a/env.example b/env.example index 29f49f6..f6124fa 100644 --- a/env.example +++ b/env.example @@ -48,6 +48,12 @@ EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producci LIMIT_CONVERSATIONS=100 MAX_CHARS_PER_MESSAGE=4000 +# =================== +# Reply Rewriter (LLM adapta templates al contexto) +# =================== +REPLY_REWRITER=0 +REPLY_REWRITER_TIMEOUT_MS=1500 + # =================== # Debug Flags (1/true/yes/on para activar) # =================== diff --git a/src/modules/2-identity/db/repo.js b/src/modules/2-identity/db/repo.js index 2e704ed..098c3a7 100644 --- a/src/modules/2-identity/db/repo.js +++ b/src/modules/2-identity/db/repo.js @@ -535,32 +535,57 @@ export async function getDecryptedTenantEcommerceConfig({ return rows[0] || null; } -export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) { +export async function searchProductAliases({ tenant_id, q = "", limit = 20, threshold = 0.3 }) { const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20)); const query = String(q || "").trim(); if (!query) return []; const normalized = query.toLowerCase(); - const like = `%${query}%`; - const nlike = `%${normalized}%`; const sql = ` - select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at + select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at, + greatest(similarity(alias, $2), similarity(normalized_alias, $3)) as sim from product_aliases - where tenant_id=$1 - and (alias ilike $2 or normalized_alias ilike $3) - order by boost desc, updated_at desc + where tenant_id = $1 + and (alias % $2 or normalized_alias % $3) + order by sim desc, boost desc, updated_at desc limit $4 `; - const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]); - return rows.map((r) => ({ - tenant_id: r.tenant_id, - alias: r.alias, - normalized_alias: r.normalized_alias, - woo_product_id: r.woo_product_id, - category_hint: r.category_hint, - boost: r.boost, - metadata: r.metadata, - updated_at: r.updated_at, - })); + const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]); + return rows + .filter((r) => Number(r.sim) >= threshold) + .map((r) => ({ + tenant_id: r.tenant_id, + alias: r.alias, + normalized_alias: r.normalized_alias, + woo_product_id: r.woo_product_id, + category_hint: r.category_hint, + boost: r.boost, + metadata: r.metadata, + updated_at: r.updated_at, + similarity: Number(r.sim), + })); +} + +export async function searchAliasProductMappings({ tenant_id, q = "", limit = 50, threshold = 0.3 }) { + const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50)); + const query = String(q || "").trim(); + if (!query) return []; + const normalized = query.toLowerCase(); + const sql = ` + select alias, woo_product_id, score, similarity(alias, $2) as sim + from alias_product_mappings + where tenant_id = $1 and alias % $2 + order by sim desc, score desc + limit $3 + `; + const { rows } = await pool.query(sql, [tenant_id, normalized, lim]); + return rows + .filter((r) => Number(r.sim) >= threshold) + .map((r) => ({ + alias: r.alias, + woo_product_id: Number(r.woo_product_id), + score: Number(r.score || 1), + similarity: Number(r.sim), + })); } export async function getRecoRules({ tenant_id }) { diff --git a/src/modules/3-turn-engine/catalogRetrieval.js b/src/modules/3-turn-engine/catalogRetrieval.js index 7dabcad..66f63f3 100644 --- a/src/modules/3-turn-engine/catalogRetrieval.js +++ b/src/modules/3-turn-engine/catalogRetrieval.js @@ -6,7 +6,7 @@ import { searchProductAliases, getProductEmbedding, upsertProductEmbedding, - getAllAliasProductMappings, + searchAliasProductMappings, } from "../2-identity/db/repo.js"; function getOpenAiKey() { @@ -138,59 +138,61 @@ export async function retrieveCandidates({ const audit = { query: q, sources: {}, boosts: {}, embeddings: {} }; - // 1) Buscar aliases que matcheen la query - const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 }); + // 1) Buscar aliases con fuzzy matching (pg_trgm). + // Captura plurales, diminutivos y typos sin reglas escritas. + const [aliases, mappings] = await Promise.all([ + searchProductAliases({ tenant_id: tenantId, q, limit: 20 }), + searchAliasProductMappings({ tenant_id: tenantId, q, limit: 50 }), + ]); + const aliasBoostByProduct = new Map(); const aliasProductIds = new Set(); - - // También buscar en alias_product_mappings (multi-producto) - const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId }); - const normalizedQuery = normalizeText(q); - const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean)); - - // Buscar mappings cuyos aliases matcheen la query - for (const mapping of allMappings) { - const aliasNorm = normalizeText(mapping.alias); - // Match exacto o parcial del alias - if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) { - const id = Number(mapping.woo_product_id); - const score = Number(mapping.score || 1); - aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score)); - aliasProductIds.add(id); - } else { - // Check word overlap - const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean)); - for (const word of queryWords) { - if (aliasWords.has(word)) { - const id = Number(mapping.woo_product_id); - const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score - aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score)); - aliasProductIds.add(id); - break; - } - } - } + + // alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness) + for (const m of mappings) { + const id = m.woo_product_id; + const boost = m.score * m.similarity; + aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost)); + aliasProductIds.add(id); } - - // También incluir aliases legacy (product_aliases.woo_product_id) + + // product_aliases legacy (1 alias → 1 producto) for (const a of aliases) { if (a?.woo_product_id) { const id = Number(a.woo_product_id); - const boost = Number(a.boost || 0); - aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0)); + const boost = Number(a.boost || 0) * (a.similarity || 1); + aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost)); aliasProductIds.add(id); } } audit.sources.aliases = aliases.length; - audit.sources.alias_mappings = aliasProductIds.size; + audit.sources.alias_mappings = mappings.length; - // 2) Buscar productos por nombre/slug (búsqueda literal) - const { items: wooItems, source: wooSource } = await searchSnapshotItems({ + // 2) Buscar productos por nombre/slug (búsqueda literal con query original) + let { items: wooItems, source: wooSource } = await searchSnapshotItems({ tenantId, q, limit: lim, }); audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 }; + + // 2b) Si el query literal no rinde pero un alias matcheó (typo/plural/diminutivo), + // re-buscar el snapshot con el normalized_alias del mejor match. + // Esto cierra el loop: "vasio" → alias "vacio" → buscar "vacio" en productos. + if ((!wooItems || wooItems.length === 0) && aliases.length > 0) { + const refined = aliases[0]?.normalized_alias; + if (refined && refined.toLowerCase() !== q.toLowerCase()) { + const { items: aliasRefined, source: refSource } = await searchSnapshotItems({ + tenantId, + q: refined, + limit: lim, + }); + if (aliasRefined?.length) { + wooItems = aliasRefined; + audit.sources.snapshot_refined = { query: refined, source: refSource, count: aliasRefined.length }; + } + } + } // 3) Traer productos que matchearon por alias pero no por búsqueda literal const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id))); diff --git a/src/modules/3-turn-engine/replyRewriter.js b/src/modules/3-turn-engine/replyRewriter.js new file mode 100644 index 0000000..b82bf76 --- /dev/null +++ b/src/modules/3-turn-engine/replyRewriter.js @@ -0,0 +1,204 @@ +/** + * Reply Rewriter — adapta un template base usando contexto conversacional. + * + * Default ON en pre-producción. Si falla o tarda >1.5s, fallback al template puro. + * + * Slots que se reescriben (según plan): + * cart.didnt_understand, cart.not_found, idle.greeting (1er turno), + * cart.added_confirm, cart.ask_more, shipping.ask_method, + * shipping.ask_address, payment.ask_method + * + * El rewriter recibe historial y vars de tienda para que pueda mencionar + * datos contextuales (zonas, horarios) cuando estén disponibles. + */ + +import OpenAI from "openai"; +import { debug as dbg } from "../shared/debug.js"; + +let _client = null; +let _clientKey = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + const err = new Error("OPENAI_API_KEY is not set"); + err.code = "OPENAI_NO_KEY"; + throw err; + } + if (_client && _clientKey === apiKey) return _client; + _clientKey = apiKey; + const baseURL = process.env.OPENAI_BASE_URL || undefined; + _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); + return _client; +} + +function isEnabled() { + const v = String(process.env.REPLY_REWRITER || "").toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +function getModel() { + return process.env.REPLY_REWRITER_MODEL || process.env.OPENAI_MODEL || "gpt-4o-mini"; +} + +function getTimeoutMs() { + const n = parseInt(process.env.REPLY_REWRITER_TIMEOUT_MS || "1500", 10); + return Number.isFinite(n) && n > 0 ? n : 1500; +} + +function lastN(history, n) { + if (!Array.isArray(history) || history.length === 0) return []; + return history.slice(-n).map((m) => ({ + role: m.role === "user" ? "user" : "assistant", + content: String(m.content || "").slice(0, 200), + })); +} + +function buildSystemPrompt() { + return [ + "Sos un asistente de carnicería argentina (es-AR), conversacional y cálido.", + "Tu tarea: REESCRIBIR un mensaje base de respuesta para que suene natural,", + "adaptado al hilo de la conversación, sin sonar repetitivo ni robótico.", + "", + "REGLAS ESTRICTAS (no negociables):", + "1. Mantené la INTENCIÓN exacta del mensaje base. No agregues ofertas,", + " precios, productos ni datos que no estén en el contexto.", + "2. Si el mensaje base contiene listas, números, opciones (1) X 2) Y),", + " tenés que conservarlas EXACTAMENTE.", + "3. Largo máximo: ≈ longitud del base + 30 caracteres.", + "4. Tono: porteño/argentino, informal pero respetuoso. Sin emojis a menos", + " que el base los tenga.", + "5. NO repitas la frase exacta de tu mensaje anterior (te la paso en history).", + "6. Devolvé SOLO el texto reescrito, sin comillas, sin explicaciones, sin prefijos.", + "", + "Si no podés mejorar el base manteniendo las reglas, devolvelo tal cual.", + ].join("\n"); +} + +const _inflightCache = new Map(); +const _resultCache = new Map(); +const RESULT_TTL_MS = 30_000; + +function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) { + return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`; +} + +function withTimeout(promise, ms, label) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms) + ), + ]); +} + +/** + * Reescribe una respuesta base usando contexto. + * + * @param {Object} params + * @param {string} params.baseText - el texto del template renderizado + * @param {string} params.templateKey - 'cart.didnt_understand', etc. + * @param {Array} params.history - últimos mensajes [{role, content}] + * @param {string} params.state - estado FSM actual + * @param {string} params.userText - último mensaje del usuario + * @param {Object} params.vars - vars de tienda (opcional) + * + * @returns {Promise<{ text: string, rewritten: boolean, model?: string, error?: string, ms: number }>} + */ +export async function rewriteReply({ + baseText, + templateKey, + history = [], + state = null, + userText = "", + vars = {}, +}) { + const t0 = Date.now(); + if (!isEnabled()) { + return { text: baseText, rewritten: false, ms: 0 }; + } + if (!baseText) { + return { text: "", rewritten: false, ms: 0 }; + } + + const recentMsgs = lastN(history, 4); + const lastUser = recentMsgs.filter((m) => m.role === "user").pop()?.content || userText || ""; + const lastAssistant = recentMsgs.filter((m) => m.role === "assistant").pop()?.content || ""; + + const key = cacheKey({ templateKey, baseText, lastUserMsg: lastUser, lastAssistantMsg: lastAssistant }); + + const cached = _resultCache.get(key); + if (cached && Date.now() - cached.t < RESULT_TTL_MS) { + return { ...cached.value, ms: Date.now() - t0 }; + } + + if (_inflightCache.has(key)) { + try { + const value = await _inflightCache.get(key); + return { ...value, ms: Date.now() - t0 }; + } catch (_) { + // fall through to re-attempt + } + } + + const promise = (async () => { + try { + const client = getClient(); + const model = getModel(); + const userPayload = { + template_key: templateKey, + base_message: baseText, + conversation_state: state, + last_user_message: userText, + recent_history: recentMsgs, + store_context: { + store_name: vars?.store_name || "", + delivery_hours: vars?.delivery_hours || "", + pickup_hours: vars?.pickup_hours || "", + delivery_zones_summary: vars?.delivery_zones_summary || "", + }, + }; + + if (dbg.llm) console.log("[rewriter] request", { templateKey, model }); + + const resp = await withTimeout( + client.chat.completions.create({ + model, + temperature: 0.6, + max_tokens: 200, + messages: [ + { role: "system", content: buildSystemPrompt() }, + { role: "user", content: JSON.stringify(userPayload) }, + ], + }), + getTimeoutMs(), + "rewriter" + ); + + const text = (resp?.choices?.[0]?.message?.content || "").trim(); + if (!text) { + return { text: baseText, rewritten: false, error: "empty" }; + } + + // Sanity: no debe ser drásticamente más largo que el base + const maxLen = baseText.length + 60; + const safeText = text.length > maxLen ? text.slice(0, maxLen) : text; + + const result = { text: safeText, rewritten: true, model }; + _resultCache.set(key, { value: result, t: Date.now() }); + return result; + } catch (err) { + const msg = String(err?.message || err); + if (dbg.llm) console.log("[rewriter] error fallback to base", msg); + return { text: baseText, rewritten: false, error: msg }; + } + })(); + + _inflightCache.set(key, promise); + try { + const value = await promise; + return { ...value, ms: Date.now() - t0 }; + } finally { + _inflightCache.delete(key); + } +} diff --git a/src/modules/3-turn-engine/replyTemplates.js b/src/modules/3-turn-engine/replyTemplates.js new file mode 100644 index 0000000..323091b --- /dev/null +++ b/src/modules/3-turn-engine/replyTemplates.js @@ -0,0 +1,317 @@ +/** + * Reply Templates - rotación de variantes con dedup por recencia. + * + * Cada slot (template_key) tiene N variantes. pickVariant: + * 1. Filtra variantes ya usadas en recentReplies (FIFO cap 8 turnos). + * 2. Si quedan, weighted-random sobre el resto. + * 3. Si todas están en recent, usa la menos reciente. + * + * Soporta variables {{name}} con applyVariables. + * + * Si la tabla reply_templates está vacía, fallback a DEFAULTS. + */ + +import { pool } from "../shared/db/pool.js"; +import { rewriteReply } from "./replyRewriter.js"; + +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; + +// Variantes por defecto. Cuando reply_templates esté vacía o no responda, +// el bot igual rota. Diseñado para no requerir seed del DB para shippear. +export const DEFAULTS = { + // ---------------- IDLE ---------------- + "idle.greeting": [ + "¡Hola! ¿En qué te puedo ayudar?", + "¡Hola! Estoy para ayudarte con tu pedido. ¿Qué andás buscando?", + "Buenas. ¿Querés que te muestre algo en particular o hacemos un pedido?", + ], + "idle.help_prompt": [ + "Decime qué necesitás. Podés pedirme productos, precios, o armar el pedido directo.", + "¿Qué te tiro? Podés pedir algo, preguntar precios o consultar disponibilidad.", + ], + + // ---------------- CART ---------------- + "cart.ask_more": [ + "¿Algo más?", + "¿Querés agregar algo más al pedido?", + "¿Sumamos algo más o cerramos así?", + ], + "cart.empty_prompt": [ + "Tu carrito está vacío. ¿Qué querés agregar?", + "Todavía no hay nada en el carrito. ¿Por dónde empezamos?", + ], + "cart.not_found": [ + "No encontré \"{{query}}\". ¿Podés decirlo de otra forma?", + "Mmm, no tengo \"{{query}}\" exacto. ¿Probamos con otra cosa?", + "No me aparece \"{{query}}\". Si querés, dame otro nombre o detalle más.", + ], + "cart.not_found_v2": [ + "No encontré \"{{query}}\". ¿Quisiste decir {{suggestions}}?", + "No tengo \"{{query}}\" como tal. ¿Te referís a {{suggestions}}?", + ], + "cart.didnt_understand": [ + "Perdón, no te entendí.", + "No me quedó claro, ¿me lo decís de otra forma?", + "No te seguí, ¿podés repetir?", + ], + "cart.skip_acknowledged": [ + "Ok, lo dejamos.", + "Listo, no lo agregamos.", + ], + "cart.confirm_to_shipping": [ + "Buenísimo. ¿Es para delivery o lo pasás a buscar?", + "Perfecto. ¿Te lo enviamos o lo retirás?", + ], + "cart.pending_before_close": [ + "Antes de cerrar, ¿qué hacemos con lo que quedó pendiente?", + "Tenemos algo pendiente para resolver antes de cerrar el pedido.", + ], + "cart.added_confirm": [ + "Anoté {{summary}}. ¿Algo más?", + "Listo, {{summary}} agregado. ¿Sumamos algo más?", + "Sumé {{summary}}. ¿Querés agregar algo más?", + "Va {{summary}}. ¿Algo más?", + ], + "cart.ask_what_product": [ + "¿Qué producto querés?", + "Decime el producto y lo busco.", + ], + "cart.price_no_query": [ + "¿De qué producto querés saber el precio?", + "Decime el producto y te paso el precio.", + ], + "cart.price_results_header": [ + "Estos son los precios:", + "Precios disponibles:", + ], + + // ---------------- SHIPPING ---------------- + "shipping.ask_method": [ + "¿Lo enviamos a domicilio o lo pasás a buscar?", + "¿Es para delivery o pickup?", + ], + "shipping.ask_address": [ + "Pasame la dirección de entrega.", + "Decime dónde lo entregamos (calle y altura).", + ], + "shipping.address_recorded": [ + "Anotado: {{address}}.", + "Listo, dirección guardada: {{address}}.", + ], + "shipping.pickup_to_payment": [ + "Genial, lo pasás a buscar. ¿Cómo abonás?", + "Pickup confirmado. ¿Pago en efectivo o link?", + ], + + // ---------------- PAYMENT ---------------- + "payment.ask_method": [ + "¿Cómo querés abonar? Efectivo o link de pago.", + "Para cerrar, ¿pagás en efectivo o con link?", + ], + "payment.confirmed": [ + "Listo, te paso los datos del pedido.", + "Perfecto, queda armado. Te paso los datos.", + ], + + // ---------------- WAITING ---------------- + "waiting.in_progress": [ + "Tu pedido está en proceso. Cualquier cosa avisame.", + "Esperando confirmación del pago. ¿Necesitás algo más?", + ], +}; + +const RECENT_CAP = 8; + +function pickWeightedRandom(variants) { + const total = variants.reduce((s, v) => s + (v.weight || 1), 0); + if (total <= 0) return variants[0]; + let r = Math.random() * total; + for (const v of variants) { + r -= v.weight || 1; + if (r <= 0) return v; + } + return variants[variants.length - 1]; +} + +async function loadFromDb({ tenantId, templateKey }) { + const sql = ` + select variant, content, weight + from reply_templates + where tenant_id = $1 and template_key = $2 and is_active = true + order by variant asc + `; + const { rows } = await pool.query(sql, [tenantId, templateKey]); + return rows.map((r) => ({ + variant: Number(r.variant), + content: r.content, + weight: Number(r.weight || 1), + })); +} + +export async function loadReplyVariants({ tenantId, templateKey, skipCache = false }) { + const cacheKey = `${tenantId}:${templateKey}`; + if (!skipCache) { + const cached = cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.variants; + } + } + + let variants = []; + try { + variants = await loadFromDb({ tenantId, templateKey }); + } catch (err) { + console.error(`[replyTemplates] DB error loading ${templateKey}: ${err.message}`); + } + + if (variants.length === 0) { + const defaults = DEFAULTS[templateKey]; + if (defaults && defaults.length) { + variants = defaults.map((content, i) => ({ variant: i + 1, content, weight: 1 })); + } + } + + cache.set(cacheKey, { variants, timestamp: Date.now() }); + return variants; +} + +export function pickVariant({ variants, recent = [], templateKey }) { + if (!variants || variants.length === 0) { + return { variant: 0, content: "" }; + } + if (variants.length === 1) { + return variants[0]; + } + const recentSet = new Set(recent || []); + const fresh = variants.filter((v) => !recentSet.has(`${templateKey}:${v.variant}`)); + + if (fresh.length > 0) { + return pickWeightedRandom(fresh); + } + // Todas usadas: elegir la que aparece más temprano en recent (= la menos reciente) + let oldestIdx = -1; + let oldestVariant = variants[0]; + for (const v of variants) { + const idx = recent.indexOf(`${templateKey}:${v.variant}`); + if (idx >= 0 && (oldestIdx < 0 || idx < oldestIdx)) { + oldestIdx = idx; + oldestVariant = v; + } + } + return oldestVariant; +} + +export function applyVariables(content, vars = {}) { + let out = String(content || ""); + // Inject current_date if missing + if (!vars.current_date) { + const now = new Date(); + const months = ["enero","febrero","marzo","abril","mayo","junio","julio","agosto","septiembre","octubre","noviembre","diciembre"]; + vars = { ...vars, current_date: `${now.getDate()} de ${months[now.getMonth()]}` }; + } + for (const [key, value] of Object.entries(vars)) { + const re = new RegExp(`{{\\s*${key}\\s*}}`, "g"); + out = out.replace(re, value == null ? "" : String(value)); + } + // Limpiar variables no reemplazadas (deja vacío para tolerar datos faltantes) + out = out.replace(/\{\{[^}]+\}\}/g, ""); + return out; +} + +/** + * Renderiza una respuesta del template, devolviendo texto + template_id + * para tracking de recencia. Si conversation_history+state+userText vienen, + * y la key está en REWRITE_KEYS, intenta adaptar via LLM rewriter. + * + * @returns {Promise<{ reply, template_id, variant, rewritten?, rewriter_ms? }>} + */ +export async function renderReply({ + tenantId, + templateKey, + vars = {}, + recentReplies = [], + conversation_history = null, + state = null, + userText = null, +}) { + const variants = await loadReplyVariants({ tenantId, templateKey }); + if (variants.length === 0) { + return { reply: "", template_id: `${templateKey}:0`, variant: 0 }; + } + const picked = pickVariant({ variants, recent: recentReplies, templateKey }); + const baseReply = applyVariables(picked.content, vars); + const base = { + reply: baseReply, + template_id: `${templateKey}:${picked.variant}`, + variant: picked.variant, + }; + + // Solo intentamos rewriter si el handler nos dio contexto conversacional. + if (conversation_history === null && userText === null) { + return base; + } + if (!shouldRewrite(templateKey, conversation_history || [])) { + return base; + } + + const rewritten = await rewriteReply({ + baseText: baseReply, + templateKey, + history: conversation_history || [], + state, + userText: userText || "", + vars, + }); + + return { + ...base, + reply: rewritten.text || baseReply, + rewritten: rewritten.rewritten, + rewriter_ms: rewritten.ms, + }; +} + +// Slots donde el rewriter aporta valor (mensajes más visibles / repetitivos). +// El resto se renderiza puro; la rotación de variantes ya da variedad. +const REWRITE_KEYS = new Set([ + "cart.didnt_understand", + "cart.not_found", + "cart.added_confirm", + "cart.ask_more", + "idle.greeting", // se filtra adicionalmente: solo en 1er turno + "shipping.ask_method", + "shipping.ask_address", + "payment.ask_method", +]); + +function shouldRewrite(templateKey, history) { + if (!REWRITE_KEYS.has(templateKey)) return false; + if (templateKey === "idle.greeting") { + // Solo reescribir greeting en el primer turno (no hay history aún) + return !Array.isArray(history) || history.length === 0; + } + return true; +} + +/** + * Agrega un template_id a la lista de recent_replies, manteniendo cap. + */ +export function pushRecent(recentReplies = [], template_id) { + if (!template_id) return recentReplies; + const next = [...(recentReplies || []), template_id]; + if (next.length > RECENT_CAP) { + return next.slice(next.length - RECENT_CAP); + } + return next; +} + +export function invalidateCache(tenantId, templateKey) { + if (templateKey) { + cache.delete(`${tenantId}:${templateKey}`); + } else { + for (const k of cache.keys()) { + if (k.startsWith(`${tenantId}:`)) cache.delete(k); + } + } +} diff --git a/src/modules/3-turn-engine/replyTemplates.test.js b/src/modules/3-turn-engine/replyTemplates.test.js new file mode 100644 index 0000000..ea3005f --- /dev/null +++ b/src/modules/3-turn-engine/replyTemplates.test.js @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock del pool de DB para que loadFromDb devuelva [] (siempre fallback a DEFAULTS) +vi.mock("../shared/db/pool.js", () => ({ + pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, +})); + +// Mock del rewriter para que sea no-op por default en estos tests +vi.mock("./replyRewriter.js", () => ({ + rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })), +})); + +import { + pickVariant, + applyVariables, + pushRecent, + renderReply, + invalidateCache, + DEFAULTS, +} from "./replyTemplates.js"; + +const TENANT = "00000000-0000-0000-0000-000000000000"; + +beforeEach(() => { + invalidateCache(TENANT); +}); + +describe("pickVariant", () => { + const variants = [ + { variant: 1, content: "A", weight: 1 }, + { variant: 2, content: "B", weight: 1 }, + { variant: 3, content: "C", weight: 1 }, + ]; + + it("returns one variant when none are recent", () => { + const r = pickVariant({ variants, recent: [], templateKey: "k" }); + expect([1, 2, 3]).toContain(r.variant); + }); + + it("excludes recent variants", () => { + const r = pickVariant({ + variants, + recent: ["k:1", "k:2"], + templateKey: "k", + }); + expect(r.variant).toBe(3); + }); + + it("falls back to least-recent when all are recent", () => { + // recent order is FIFO: oldest first. With ['k:2','k:1','k:3'], k:2 is oldest. + const r = pickVariant({ + variants, + recent: ["k:2", "k:1", "k:3"], + templateKey: "k", + }); + expect(r.variant).toBe(2); + }); + + it("returns single variant when only one exists", () => { + const r = pickVariant({ + variants: [{ variant: 1, content: "only", weight: 1 }], + recent: ["k:1"], + templateKey: "k", + }); + expect(r.variant).toBe(1); + }); +}); + +describe("applyVariables", () => { + it("replaces named variables", () => { + expect(applyVariables("Hola {{name}}!", { name: "Pepe" })).toBe("Hola Pepe!"); + }); + + it("strips unmatched variables", () => { + expect(applyVariables("a {{missing}} b", {})).toBe("a b"); + }); + + it("auto-injects current_date", () => { + const out = applyVariables("Hoy es {{current_date}}.", {}); + expect(out).toMatch(/Hoy es \d+ de \w+\./); + }); +}); + +describe("pushRecent", () => { + it("appends template_id", () => { + expect(pushRecent([], "x:1")).toEqual(["x:1"]); + }); + + it("caps at 8 entries (FIFO)", () => { + let r = []; + for (let i = 1; i <= 10; i++) r = pushRecent(r, `k:${i}`); + expect(r).toHaveLength(8); + expect(r[0]).toBe("k:3"); + expect(r[7]).toBe("k:10"); + }); +}); + +describe("renderReply (DEFAULTS fallback)", () => { + it("renders from DEFAULTS when DB returns empty", async () => { + const out = await renderReply({ + tenantId: TENANT, + templateKey: "idle.greeting", + vars: {}, + recentReplies: [], + }); + expect(out.template_id).toMatch(/^idle\.greeting:\d+$/); + expect(DEFAULTS["idle.greeting"]).toContain(out.reply); + }); + + it("rotates variants across consecutive calls when feeding recent", async () => { + let recent = []; + const seen = new Set(); + for (let i = 0; i < 3; i++) { + const r = await renderReply({ + tenantId: TENANT, + templateKey: "cart.added_confirm", + vars: { summary: "X" }, + recentReplies: recent, + }); + seen.add(r.variant); + recent = pushRecent(recent, r.template_id); + } + // 3 distintas variantes en 3 turnos + expect(seen.size).toBe(3); + }); + + it("returns empty string when key has no variants and no DEFAULT", async () => { + const out = await renderReply({ + tenantId: TENANT, + templateKey: "nonexistent.key", + vars: {}, + recentReplies: [], + }); + expect(out.reply).toBe(""); + }); +}); diff --git a/src/modules/3-turn-engine/stateHandlers/cart.js b/src/modules/3-turn-engine/stateHandlers/cart.js index d35d643..e1be74d 100644 --- a/src/modules/3-turn-engine/stateHandlers/cart.js +++ b/src/modules/3-turn-engine/stateHandlers/cart.js @@ -19,18 +19,20 @@ import { import { handleRecommend } from "../recommendations.js"; import { getProductQtyRules } from "../../0-ui/db/repo.js"; import { inferDefaultUnit, unitAskFor } from "./utils.js"; -import { - extractProductQueries, - createPendingItemFromSearch, - processPendingClarification +import { + extractProductQueries, + createPendingItemFromSearch, + processPendingClarification } from "./cartHelpers.js"; +import { renderReply } from "../replyTemplates.js"; /** * Maneja el estado CART (carrito activo) */ -export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) { +export async function handleCartState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history, failedSearches = { count: 0 }, fromIdle = false }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); + const rewriteCtx = { conversation_history, state: "CART", userText: text }; // Intents que tienen prioridad sobre pending items const priorityIntents = ["view_cart", "confirm_order", "greeting"]; @@ -43,109 +45,124 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI // Si quiere saltar el pending - PERO solo si NO es un intent prioritario if (wantsToSkipPending && pendingItem && !isPriorityIntent) { - return handleSkipPending({ currentOrder, pendingItem, audit }); + return handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx }); } - + // 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación if (pendingItem && !isPriorityIntent) { - const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit }); + const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }); if (result) return result; } - + // 2) view_cart: mostrar carrito actual if (intent === "view_cart") { - return handleViewCart({ currentOrder }); + return handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx }); } - + // 2.5) remove_from_cart: quitar productos del carrito if (intent === "remove_from_cart") { - return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }); + return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx }); } - + // 3) confirm_order: ir a SHIPPING si hay items if (intent === "confirm_order") { - return handleConfirmOrder({ currentOrder, audit }); + return handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx }); } - + // 4) recommend if (intent === "recommend") { const result = await handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit }); if (result) return result; } - + // 4.5) price_query - consulta de precios if (intent === "price_query") { - return handlePriceQuery({ tenantId, nlu, currentOrder, audit }); + return handlePriceQuery({ tenantId, nlu, currentOrder, audit, recentReplies, rewriteCtx }); } - + // 5) add_to_cart / browse: buscar productos if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) { - return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }); + return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit, recentReplies, failedSearches, rewriteCtx }); } - + // Default + const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); return { plan: { - reply: "¿Qué más querés agregar?", + reply: r.reply, next_state: ConversationState.CART, intent: "other", missing_fields: [], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } /** * Maneja el skip de un pending item */ -function handleSkipPending({ currentOrder, pendingItem, audit }) { +async function handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx }) { const updatedOrder = { ...currentOrder, pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id), }; audit.skipped_pending = pendingItem.query; - + + const skipAck = await renderReply({ + tenantId, + templateKey: "cart.skip_acknowledged", + vars: { query: pendingItem.query }, + recentReplies, + }); + const nextPending = getNextPendingItem(updatedOrder); if (nextPending) { const { question } = formatOptionsForDisplay(nextPending); return { plan: { - reply: `Ok, salteo "${pendingItem.query}". ${question}`, + reply: `${skipAck.reply} ${question}`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: ["product_selection"], order_action: "none", }, - decision: { actions: [], order: updatedOrder, audit }, + decision: { actions: [], order: updatedOrder, audit, template_ids_used: [skipAck.template_id] }, }; } - + const cartDisplay = formatCartForDisplay(updatedOrder); + const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); return { plan: { - reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`, + reply: `${skipAck.reply}\n\n${cartDisplay}\n\n${askMore.reply}`, next_state: ConversationState.CART, intent: "other", missing_fields: [], order_action: "none", }, - decision: { actions: [], order: updatedOrder, audit }, + decision: { + actions: [], + order: updatedOrder, + audit, + template_ids_used: [skipAck.template_id, askMore.template_id], + }, }; } /** * Maneja view_cart */ -function handleViewCart({ currentOrder }) { +async function handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx }) { const cartDisplay = formatCartForDisplay(currentOrder); const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0; let reply = cartDisplay; if (pendingCount > 0) { reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`; } - reply += "\n\n¿Algo más?"; - + const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); + reply += `\n\n${askMore.reply}`; + return { plan: { reply, @@ -154,14 +171,14 @@ function handleViewCart({ currentOrder }) { missing_fields: [], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit: {} }, + decision: { actions: [], order: currentOrder, audit: {}, template_ids_used: [askMore.template_id] }, }; } /** * Maneja remove_from_cart */ -async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }) { +async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx }) { const items = nlu?.entities?.items || []; const removedItems = []; const addedItems = []; @@ -233,8 +250,9 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit } } const cartDisplay = formatCartForDisplay(updatedOrder); - reply += `\n\n${cartDisplay}\n\n¿Algo más?`; - + const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); + reply += `\n\n${cartDisplay}\n\n${askMore.reply}`; + return { plan: { reply: reply.trim(), @@ -243,10 +261,11 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit } missing_fields: [], order_action: removedItems.length > 0 ? "remove_from_cart" : "none", }, - decision: { - actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], - order: updatedOrder, - audit + decision: { + actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], + order: updatedOrder, + audit, + template_ids_used: [askMore.template_id], }, }; } @@ -254,47 +273,50 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit } /** * Maneja confirm_order */ -function handleConfirmOrder({ currentOrder, audit }) { +async function handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx }) { let order = moveReadyToCart(currentOrder); - + if (!hasCartItems(order)) { + const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies }); return { plan: { - reply: "Tu carrito está vacío. ¿Qué querés agregar?", + reply: r.reply, next_state: ConversationState.CART, intent: "confirm_order", missing_fields: ["cart_items"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } - + if (hasPendingItems(order)) { const nextPending = getNextPendingItem(order); const { question } = formatOptionsForDisplay(nextPending); + const r = await renderReply({ tenantId, templateKey: "cart.pending_before_close", recentReplies }); return { plan: { - reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`, + reply: `${r.reply}\n\n${question}`, next_state: ConversationState.CART, intent: "confirm_order", missing_fields: ["pending_items"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } - + const { next_state } = safeNextState(ConversationState.CART, order, { confirm_order: true }); + const r = await renderReply({ tenantId, templateKey: "cart.confirm_to_shipping", recentReplies }); return { plan: { - reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", + reply: `${r.reply}\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal`, next_state, intent: "confirm_order", missing_fields: ["shipping_method"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } @@ -364,20 +386,21 @@ function isCartTotalQuery(nlu) { /** * Maneja price_query */ -async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { +async function handlePriceQuery({ tenantId, nlu, currentOrder, audit, recentReplies, failedSearches = { count: 0 }, rewriteCtx }) { // Si pregunta por el total del carrito if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) { const cartItems = currentOrder?.cart || []; if (cartItems.length === 0) { + const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies }); return { plan: { - reply: "Tu carrito está vacío. ¿Qué te gustaría agregar?", + reply: r.reply, next_state: ConversationState.CART, intent: "price_query", missing_fields: [], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } @@ -390,7 +413,8 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { return `• ${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`; }); - const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Algo más?`; + const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); + const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n${askMore.reply}`; return { plan: { reply, @@ -399,12 +423,12 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { missing_fields: [], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [askMore.template_id] }, }; } const productQueries = extractProductQueries(nlu); - + if (productQueries.length === 0) { // Si no hay query pero hay carrito, mostrar el carrito const cartItems = currentOrder?.cart || []; @@ -430,18 +454,19 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { }; } + const r = await renderReply({ tenantId, templateKey: "cart.price_no_query", recentReplies }); return { plan: { - reply: "¿De qué producto querés saber el precio?", + reply: r.reply, next_state: ConversationState.CART, intent: "price_query", missing_fields: ["product_query"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } - + const priceResults = []; for (const pq of productQueries.slice(0, 5)) { const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 }); @@ -458,19 +483,45 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { } if (priceResults.length === 0) { + const failedQuery = productQueries[0]?.query || ""; + const nextCount = (failedSearches?.count || 0) + 1; + if (nextCount >= 3) { + // Escalación: 3 fallos consecutivos → human takeover + const { createHumanTakeoverResponse } = await import("../nlu/humanFallback.js"); + const escalated = createHumanTakeoverResponse({ + pendingQuery: failedQuery, + order: currentOrder, + context: { failed_count: nextCount, last_query: failedQuery, source: "price_query" }, + }); + return { + ...escalated, + decision: { + ...escalated.decision, + failed_searches_next: { count: 0, last_query: failedQuery, last_at: new Date().toISOString() }, + }, + }; + } + const r = await renderReply({ tenantId, templateKey: "cart.not_found", vars: { query: failedQuery }, recentReplies, ...rewriteCtx }); return { plan: { - reply: "No encontré ese producto. ¿Podés ser más específico?", + reply: r.reply, next_state: ConversationState.CART, intent: "price_query", missing_fields: ["product_query"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { + actions: [], + order: currentOrder, + audit, + template_ids_used: [r.template_id], + failed_searches_next: { count: nextCount, last_query: failedQuery, last_at: new Date().toISOString() }, + }, }; } - - const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?"; + + const header = await renderReply({ tenantId, templateKey: "cart.price_results_header", recentReplies }); + const reply = `${header.reply}\n\n${priceResults.join("\n")}\n\n¿Querés agregar alguno al carrito?`; return { plan: { reply, @@ -479,26 +530,27 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { missing_fields: [], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [header.template_id] }, }; } /** * Maneja add_to_cart / browse */ -async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }) { +async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit, recentReplies }) { const productQueries = extractProductQueries(nlu); - + if (productQueries.length === 0) { + const r = await renderReply({ tenantId, templateKey: "cart.ask_what_product", recentReplies }); return { plan: { - reply: "¿Qué producto querés agregar?", + reply: r.reply, next_state: ConversationState.CART, intent, missing_fields: ["product_query"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } @@ -540,7 +592,7 @@ async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audi }; } if (nextPending.status === PendingStatus.NEEDS_QUANTITY) { - return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }); + return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx }); } } @@ -548,35 +600,48 @@ async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audi const lastAdded = order.cart[order.cart.length - 1]; if (lastAdded) { const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`; + const summary = `${qtyStr} de ${lastAdded.name}`; const cartSummary = formatCartForDisplay(order); + const added = await renderReply({ + tenantId, + templateKey: "cart.added_confirm", + vars: { summary }, + recentReplies, + }); return { plan: { - reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}.\n\n${cartSummary}\n\n¿Algo más?`, + reply: `${added.reply}\n\n${cartSummary}`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, - decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order, audit }, + decision: { + actions: [{ type: "add_to_cart", payload: lastAdded }], + order, + audit, + template_ids_used: [added.template_id], + }, }; } - + + const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); return { plan: { - reply: "¿Qué más querés agregar?", + reply: r.reply, next_state: ConversationState.CART, intent: "other", missing_fields: [], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } /** * Maneja cuando se necesita cantidad */ -async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }) { +async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx }) { // Detectar "para X personas" en el texto original const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || /\bpara\s+(\d+)\b/i.exec(text || "") || @@ -615,16 +680,17 @@ async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; const cartSummary = formatCartForDisplay(finalOrder); - + const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); + return { plan: { - reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`, + reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n${askMore.reply}`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, - decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [askMore.template_id] }, }; } } diff --git a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js index 96e4aef..cfebd1f 100644 --- a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js +++ b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js @@ -7,8 +7,8 @@ import { retrieveCandidates } from "../catalogRetrieval.js"; import { ConversationState } from "../fsm.js"; -import { - createPendingItem, +import { + createPendingItem, PendingStatus, moveReadyToCart, updatePendingItem, @@ -27,13 +27,14 @@ import { normalizeUnit, unitAskFor, } from "./utils.js"; +import { renderReply } from "../replyTemplates.js"; /** * Extrae queries de productos del resultado NLU */ export function extractProductQueries(nlu) { const queries = []; - + // Multi-items if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) { for (const item of nlu.entities.items) { @@ -47,7 +48,7 @@ export function extractProductQueries(nlu) { } return queries; } - + // Single item if (nlu?.entities?.product_query) { queries.push({ @@ -56,7 +57,7 @@ export function extractProductQueries(nlu) { unit: nlu.entities.unit, }); } - + return queries; } @@ -65,7 +66,7 @@ export function extractProductQueries(nlu) { */ export function createPendingItemFromSearch({ query, quantity, unit, candidates }) { const cands = (candidates || []).filter(c => c && c.woo_product_id); - + if (cands.length === 0) { return createPendingItem({ query, @@ -73,13 +74,13 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates status: PendingStatus.NEEDS_TYPE, }); } - + // Check for strong match const best = cands[0]; const second = cands[1]; - const isStrong = cands.length === 1 || + const isStrong = cands.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2)); - + if (isStrong) { const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; @@ -87,7 +88,7 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates const hasExplicitUnit = unit != null && unit !== ""; const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit; const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric); - + return createPendingItem({ query, candidates: [], @@ -100,7 +101,7 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, }); } - + // Multiple candidates, needs selection const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; return createPendingItem({ @@ -119,42 +120,62 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates } /** - * Procesa la clarificación de un pending item - * Retorna un resultado si se pudo procesar, null si debe escapar al handler principal + * Helper interno: arma la respuesta "se agregó X al carrito" con template rotativo. */ -export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) { +async function buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName, finalOrder, rewriteCtx }) { + const summary = `${qtyDisplay} de ${productName}`; + const cartSummary = formatCartForDisplay(finalOrder); + const r = await renderReply({ + tenantId, + templateKey: "cart.added_confirm", + vars: { summary }, + recentReplies, + ...(rewriteCtx || {}), + }); + return { + reply: `${r.reply}\n\n${cartSummary}`, + template_id: r.template_id, + failed_searches_next: { count: 0, last_query: null, last_at: null }, + }; +} + +/** + * Procesa la clarificación de un pending item. + * Retorna un resultado si se pudo procesar, null si debe escapar al handler principal. + */ +export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }) { // Detectar intents que deberían escapar de la clarificación const escapeIntents = ["view_cart", "confirm_order", "greeting", "cancel_order"]; if (escapeIntents.includes(nlu?.intent)) { audit.escape_from_pending = { reason: "intent", intent: nlu?.intent }; return null; } - + // Detectar frases de escape explícitas if (isEscapeRequest(text)) { audit.escape_from_pending = { reason: "text_pattern", text }; return null; } - + // Si necesita seleccionar tipo if (pendingItem.status === PendingStatus.NEEDS_TYPE) { - return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }); + return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }); } - + // Si necesita cantidad if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) { - return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }); + return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, rewriteCtx }); } - + return null; } /** * Procesa la selección de tipo de producto */ -async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }) { +async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }) { const idx = parseIndexSelection(text); - + // Show more o mostrar opciones if (isShowMoreRequest(text) || isShowOptionsRequest(text)) { const { question } = formatOptionsForDisplay(pendingItem); @@ -169,59 +190,60 @@ async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, a decision: { actions: [], order, audit }, }; } - + // Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar - const textMatch = !idx && pendingItem.candidates?.length > 0 - ? findMatchingCandidate(pendingItem.candidates, text) + const textMatch = !idx && pendingItem.candidates?.length > 0 + ? findMatchingCandidate(pendingItem.candidates, text) : null; - + const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null); - + // Selection by index (o por match de texto) if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) { - return processIndexSelection({ order, pendingItem, effectiveIdx, audit }); + return processIndexSelection({ tenantId, order, pendingItem, effectiveIdx, audit, recentReplies, rewriteCtx }); } - + // Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar const isNumberSelection = idx !== null; const hadTextMatch = effectiveIdx !== null && !idx; const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2; - + if (isTextClarification) { - return processTextClarification({ tenantId, text, order, pendingItem, audit }); + return processTextClarification({ tenantId, text, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }); } - - // No entendió (no es número, no es texto largo), volver a preguntar + + // No entendió, volver a preguntar const { question } = formatOptionsForDisplay(pendingItem); + const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx }); return { plan: { - reply: "No entendí. " + question, + reply: `${r.reply} ${question}`, next_state: ConversationState.CART, intent: "other", missing_fields: ["product_selection"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } /** * Procesa selección por índice */ -function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) { +async function processIndexSelection({ tenantId, order, pendingItem, effectiveIdx, audit, recentReplies, rewriteCtx }) { const selected = pendingItem.candidates[effectiveIdx - 1]; const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); - + const requestedQty = pendingItem.requested_qty; const requestedUnit = pendingItem.requested_unit || displayUnit; const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0; - + const sellsByWeight = displayUnit !== "unit"; const needsQuantity = sellsByWeight && !hasRequestedQty; - + const finalQty = hasRequestedQty ? requestedQty : 1; const finalUnit = requestedUnit || displayUnit; - + const updatedOrder = updatePendingItem(order, pendingItem.id, { selected_woo_id: selected.woo_id, selected_name: selected.name, @@ -232,7 +254,7 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) { qty: needsQuantity ? null : finalQty, unit: finalUnit, }); - + if (needsQuantity) { const unitQuestion = unitAskFor(displayUnit); return { @@ -246,34 +268,34 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) { decision: { actions: [], order: updatedOrder, audit }, }; } - + const finalOrder = moveReadyToCart(updatedOrder); - const qtyDisplay = displayUnit === "unit" - ? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}` + const qtyDisplay = displayUnit === "unit" + ? `${finalQty} ${finalQty === 1 ? "unidad" : "unidades"}` : `${finalQty}${displayUnit}`; - const cartSummary = formatCartForDisplay(finalOrder); - + const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: selected.name, finalOrder, rewriteCtx }); + return { plan: { - reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`, + reply: built.reply, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, - decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next }, }; } /** * Procesa clarificación por texto libre (re-búsqueda) */ -async function processTextClarification({ tenantId, text, order, pendingItem, audit }) { +async function processTextClarification({ tenantId, text, order, pendingItem, audit, recentReplies, failedSearches = { count: 0 }, rewriteCtx }) { const newQuery = text.trim(); const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 }); const newCandidates = searchResult?.candidates || []; audit.retry_search = { query: newQuery, count: newCandidates.length, had_previous: pendingItem.candidates?.length || 0 }; - + if (newCandidates.length > 0) { const updatedPending = { ...pendingItem, @@ -286,17 +308,17 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), })), }; - + const updatedOrder = { ...order, pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p), }; - + // Si hay match fuerte, seleccionar automáticamente if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) { - return processStrongMatch({ updatedOrder, pendingItem, best: newCandidates[0], audit }); + return processStrongMatch({ tenantId, updatedOrder, pendingItem, best: newCandidates[0], audit, recentReplies, rewriteCtx }); } - + // Múltiples candidatos, mostrar opciones const { question } = formatOptionsForDisplay(updatedPending); return { @@ -310,19 +332,19 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au decision: { actions: [], order: updatedOrder, audit }, }; } - + // No encontró nada con la nueva búsqueda if (!pendingItem.candidates || pendingItem.candidates.length === 0) { const orderWithoutPending = { ...order, pending: (order.pending || []).filter(p => p.id !== pendingItem.id), }; - + audit.escalated_to_human = true; audit.original_query = pendingItem.query; audit.retry_query = newQuery; - - return createHumanTakeoverResponse({ + + const escalated = createHumanTakeoverResponse({ pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`, order: orderWithoutPending, context: { @@ -331,26 +353,68 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au search_attempts: 2, }, }); + return { + ...escalated, + decision: { + ...escalated.decision, + failed_searches_next: { count: 0, last_query: newQuery, last_at: new Date().toISOString() }, + }, + }; } - + // Había candidatos pero la búsqueda nueva no encontró, mantener los anteriores + const nextCount = (failedSearches?.count || 0) + 1; + if (nextCount >= 3) { + audit.escalated_to_human = true; + audit.escalation_reason = "failed_searches_threshold"; + const orderWithoutPending = { + ...order, + pending: (order.pending || []).filter(p => p.id !== pendingItem.id), + }; + const escalated = createHumanTakeoverResponse({ + pendingQuery: `${pendingItem.query} (intentos: ${nextCount})`, + order: orderWithoutPending, + context: { original_query: pendingItem.query, last_query: newQuery, failed_count: nextCount }, + }); + return { + ...escalated, + decision: { + ...escalated.decision, + failed_searches_next: { count: 0, last_query: newQuery, last_at: new Date().toISOString() }, + }, + }; + } + const { question } = formatOptionsForDisplay(pendingItem); + const r = await renderReply({ + tenantId, + templateKey: "cart.not_found", + vars: { query: newQuery }, + recentReplies, + ...(rewriteCtx || {}), + }); return { plan: { - reply: `No encontré "${newQuery}". ${question}`, + reply: `${r.reply} ${question}`, next_state: ConversationState.CART, intent: "other", missing_fields: ["product_selection"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { + actions: [], + order, + audit, + template_ids_used: [r.template_id], + failed_searches_next: { count: nextCount, last_query: newQuery, last_at: new Date().toISOString() }, + }, }; } /** * Procesa un match fuerte (selección automática) */ -function processStrongMatch({ updatedOrder, pendingItem, best, audit }) { +async function processStrongMatch({ tenantId, updatedOrder, pendingItem, best, audit, recentReplies, rewriteCtx }) { const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0; const needsQuantity = displayUnit !== "unit" && !hasQty; @@ -365,7 +429,7 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) { qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1), unit: pendingItem.requested_unit || displayUnit, }); - + if (needsQuantity) { const unitQuestion = unitAskFor(displayUnit); return { @@ -379,33 +443,33 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) { decision: { actions: [], order: autoSelectedOrder, audit }, }; } - + const finalOrder = moveReadyToCart(autoSelectedOrder); const qty = hasQty ? pendingItem.requested_qty : 1; - const qtyDisplay = displayUnit === "unit" - ? `${qty} ${qty === 1 ? 'unidad de' : 'unidades de'}` + const qtyDisplay = displayUnit === "unit" + ? `${qty} ${qty === 1 ? "unidad" : "unidades"}` : `${qty}${displayUnit}`; - const cartSummary = formatCartForDisplay(finalOrder); - + const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: best.name, finalOrder, rewriteCtx }); + return { plan: { - reply: `Perfecto, anoto ${qtyDisplay} ${best.name}.\n\n${cartSummary}\n\n¿Algo más?`, + reply: built.reply, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, - decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next }, }; } /** * Procesa input de cantidad */ -async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }) { +async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, rewriteCtx }) { const qty = nlu?.entities?.quantity; const unit = nlu?.entities?.unit; - + // Try to parse quantity from text let parsedQty = qty; if (parsedQty == null) { @@ -414,7 +478,7 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a parsedQty = parseFloat(m[1].replace(",", ".")); } } - + if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) { const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg"; const updatedOrder = updatePendingItem(order, pendingItem.id, { @@ -422,66 +486,68 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a unit: finalUnit, status: PendingStatus.READY, }); - + const finalOrder = moveReadyToCart(updatedOrder); - const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`; - const cartSummary = formatCartForDisplay(finalOrder); - + const qtyDisplay = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`; + const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: pendingItem.selected_name, finalOrder, rewriteCtx }); + return { plan: { - reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`, + reply: built.reply, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, - decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next }, }; } - + // Detectar "para X personas" y calcular cantidad automáticamente const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || /\bpara\s+(\d+)\b/i.exec(text || "") || /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); - + if (personasMatch && pendingItem.selected_woo_id) { - return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch }); + return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch, recentReplies, rewriteCtx }); } - + // No entendió cantidad const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); + const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx }); return { plan: { - reply: `No entendí la cantidad. ${unitQuestion}`, + reply: `${r.reply} ${unitQuestion}`, next_state: ConversationState.CART, intent: "other", missing_fields: ["quantity"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } /** * Procesa cantidad para X personas */ -async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch }) { +async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch, recentReplies, rewriteCtx }) { const peopleCount = parseInt(personasMatch[1], 10); - + if (peopleCount <= 0 || peopleCount > 100) { const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); + const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx }); return { plan: { - reply: `No entendí la cantidad. ${unitQuestion}`, + reply: `${r.reply} ${unitQuestion}`, next_state: ConversationState.CART, intent: "other", missing_fields: ["quantity"], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } - + // Buscar reglas de cantidad por persona para este producto let qtyRules = []; try { @@ -489,15 +555,15 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p } catch (e) { audit.qty_rules_error = e?.message; } - + const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") || qtyRules.find(r => r.event_type === null && r.person_type === "adult") || qtyRules.find(r => r.person_type === "adult") || qtyRules[0]; - + let calculatedQty; let calculatedUnit = pendingItem.selected_unit || "kg"; - + if (rule && rule.qty_per_person > 0) { calculatedQty = rule.qty_per_person * peopleCount; calculatedUnit = rule.unit || calculatedUnit; @@ -507,32 +573,39 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p calculatedQty = fallbackPerPerson * peopleCount; audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit }; } - - // Redondear + if (calculatedUnit === "unit") { calculatedQty = Math.ceil(calculatedQty); } else { calculatedQty = Math.round(calculatedQty * 10) / 10; } - + const updatedOrder = updatePendingItem(order, pendingItem.id, { qty: calculatedQty, unit: calculatedUnit, status: PendingStatus.READY, }); - + const finalOrder = moveReadyToCart(updatedOrder); - const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; + const qtyDisplay = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; + const summary = `${qtyDisplay} de ${pendingItem.selected_name} (sugerencia para ${peopleCount} pers.)`; const cartSummary = formatCartForDisplay(finalOrder); - + const r = await renderReply({ + tenantId, + templateKey: "cart.added_confirm", + vars: { summary }, + recentReplies, + ...(rewriteCtx || {}), + }); + return { plan: { - reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`, + reply: `${r.reply}\n\n${cartSummary}`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, - decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [r.template_id] }, }; } diff --git a/src/modules/3-turn-engine/stateHandlers/idle.js b/src/modules/3-turn-engine/stateHandlers/idle.js index 78939cd..a51ce17 100644 --- a/src/modules/3-turn-engine/stateHandlers/idle.js +++ b/src/modules/3-turn-engine/stateHandlers/idle.js @@ -4,41 +4,43 @@ import { ConversationState } from "../fsm.js"; import { handleCartState } from "./cart.js"; +import { renderReply } from "../replyTemplates.js"; +import { buildStoreContextVars } from "../storeContext.js"; -/** - * Maneja el estado IDLE (inicio de conversación) - */ -export async function handleIdleState({ tenantId, text, nlu, order, audit }) { +export async function handleIdleState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history }) { const intent = nlu?.intent || "other"; - + const vars = buildStoreContextVars(storeConfig); + // Greeting if (intent === "greeting") { + const r = await renderReply({ tenantId, templateKey: "idle.greeting", vars, recentReplies, conversation_history, state: "IDLE", userText: text }); return { plan: { - reply: "¡Hola! ¿En qué te puedo ayudar hoy?", + reply: r.reply, next_state: ConversationState.IDLE, intent: "greeting", missing_fields: [], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } - + // Cualquier intent relacionado con productos → ir a CART if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) { - return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true }); + return handleCartState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history, fromIdle: true }); } - + // Other + const r = await renderReply({ tenantId, templateKey: "idle.help_prompt", vars, recentReplies, conversation_history, state: "IDLE", userText: text }); return { plan: { - reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.", + reply: r.reply, next_state: ConversationState.IDLE, intent: "other", missing_fields: [], order_action: "none", }, - decision: { actions: [], order, audit }, + decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, }; } diff --git a/src/modules/3-turn-engine/stateHandlers/payment.js b/src/modules/3-turn-engine/stateHandlers/payment.js index be7a667..ab04faa 100644 --- a/src/modules/3-turn-engine/stateHandlers/payment.js +++ b/src/modules/3-turn-engine/stateHandlers/payment.js @@ -5,18 +5,18 @@ import { ConversationState, safeNextState } from "../fsm.js"; import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; import { parseIndexSelection } from "./utils.js"; +import { renderReply } from "../replyTemplates.js"; -/** - * Maneja el estado PAYMENT (selección de método de pago) - */ -export async function handlePaymentState({ tenantId, text, nlu, order, audit }) { +const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente."; + +export async function handlePaymentState({ tenantId, text, nlu, order, audit, recentReplies, conversation_history }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); const actions = []; - - // Detectar selección de pago + const rewriteCtx = { conversation_history, state: "PAYMENT", userText: text }; + let paymentMethod = nlu?.entities?.payment_method; - + if (!paymentMethod) { const t = String(text || "").toLowerCase(); const idx = parseIndexSelection(text); @@ -26,58 +26,58 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit }) paymentMethod = "link"; } } - + if (paymentMethod) { currentOrder = { ...currentOrder, payment_type: paymentMethod }; actions.push({ type: "create_order", payload: { payment: paymentMethod } }); - + const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true }); - + const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago"; - const deliveryInfo = currentOrder.is_delivery + const deliveryInfo = currentOrder.is_delivery ? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.` : "Retiro en sucursal."; - const paymentInfo = paymentMethod === "link" ? "Te contactamos para coordinar el pago." : "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + "."; - + + const r = await renderReply({ tenantId, templateKey: "payment.confirmed", recentReplies, ...rewriteCtx }); return { plan: { - reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`, + reply: `${r.reply} Pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}`, next_state, intent: "select_payment", missing_fields: [], order_action: "create_order", }, - decision: { actions, order: currentOrder, audit }, + decision: { actions, order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } - - // view_cart + if (intent === "view_cart") { const cartDisplay = formatCartForDisplay(currentOrder); + const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx }); return { plan: { - reply: cartDisplay + "\n\n¿Cómo preferís pagar?", + reply: `${cartDisplay}\n\n${r.reply}`, next_state: ConversationState.PAYMENT, intent: "view_cart", missing_fields: ["payment_method"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } - - // Default + + const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx }); return { plan: { - reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", + reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`, next_state: ConversationState.PAYMENT, intent: "other", missing_fields: ["payment_method"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } diff --git a/src/modules/3-turn-engine/stateHandlers/shipping.js b/src/modules/3-turn-engine/stateHandlers/shipping.js index ef29810..60a2b7e 100644 --- a/src/modules/3-turn-engine/stateHandlers/shipping.js +++ b/src/modules/3-turn-engine/stateHandlers/shipping.js @@ -5,18 +5,24 @@ import { ConversationState, safeNextState } from "../fsm.js"; import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; import { parseIndexSelection } from "./utils.js"; +import { renderReply } from "../replyTemplates.js"; +import { buildStoreContextVars } from "../storeContext.js"; + +const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal"; +const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente."; /** * Maneja el estado SHIPPING (selección de envío) */ -export async function handleShippingState({ tenantId, text, nlu, order, audit }) { +export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); - + const storeVars = buildStoreContextVars(storeConfig); + const rewriteCtx = { conversation_history, state: "SHIPPING", userText: text }; + // Detectar selección de shipping (delivery/pickup) let shippingMethod = nlu?.entities?.shipping_method; - - // Detectar por número o texto + if (!shippingMethod) { const t = String(text || "").toLowerCase(); const idx = parseIndexSelection(text); @@ -26,95 +32,111 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit }) shippingMethod = "pickup"; } } - + if (shippingMethod) { currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; - + if (shippingMethod === "pickup") { const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); + const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx }); return { plan: { - reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", + reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`, next_state, intent: "select_shipping", missing_fields: ["payment_method"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } - + // Delivery: pedir dirección si no la tiene if (!currentOrder.shipping_address) { + const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx }); return { plan: { - reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?", + reply: r.reply, next_state: ConversationState.SHIPPING, intent: "select_shipping", missing_fields: ["address"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } } - + // Si ya eligió delivery y ahora da dirección if (currentOrder.is_delivery === true && !currentOrder.shipping_address) { const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null); - + if (address) { currentOrder = { ...currentOrder, shipping_address: address }; const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); - + const recorded = await renderReply({ + tenantId, + templateKey: "shipping.address_recorded", + vars: { address }, + recentReplies, + }); + const askPay = await renderReply({ tenantId, templateKey: "payment.ask_method", vars: storeVars, recentReplies, ...rewriteCtx }); return { plan: { - reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`, + reply: `${recorded.reply}\n\n${askPay.reply}${PAYMENT_OPTIONS_TAIL}`, next_state, intent: "provide_address", missing_fields: ["payment_method"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { + actions: [], + order: currentOrder, + audit, + template_ids_used: [recorded.template_id, askPay.template_id], + }, }; } - + + const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx }); return { plan: { - reply: "Necesito la dirección de entrega. ¿Me la pasás?", + reply: r.reply, next_state: ConversationState.SHIPPING, intent: "other", missing_fields: ["address"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } - - // view_cart + + // view_cart en SHIPPING if (intent === "view_cart") { const cartDisplay = formatCartForDisplay(currentOrder); + const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx }); return { plan: { - reply: cartDisplay + "\n\n¿Es para delivery o retiro?", + reply: `${cartDisplay}\n\n${r.reply}${SHIPPING_OPTIONS_TAIL}`, next_state: ConversationState.SHIPPING, intent: "view_cart", missing_fields: ["shipping_method"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } - - // Default: preguntar de nuevo + + // Default + const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx }); return { plan: { - reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", + reply: `${r.reply}${SHIPPING_OPTIONS_TAIL}`, next_state: ConversationState.SHIPPING, intent: "other", missing_fields: ["shipping_method"], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } diff --git a/src/modules/3-turn-engine/stateHandlers/waiting.js b/src/modules/3-turn-engine/stateHandlers/waiting.js index c7346a5..ee24bfb 100644 --- a/src/modules/3-turn-engine/stateHandlers/waiting.js +++ b/src/modules/3-turn-engine/stateHandlers/waiting.js @@ -4,6 +4,7 @@ import { ConversationState } from "../fsm.js"; import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; +import { renderReply } from "../replyTemplates.js"; /** * Detecta si el usuario pregunta por horarios/días de entrega @@ -41,7 +42,7 @@ function isPickupInfoQuestion(text) { /** * Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago) */ -export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {} }) { +export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {}, recentReplies }) { const intent = nlu?.intent || "other"; const currentOrder = order || createEmptyOrder(); @@ -118,18 +119,15 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st } // Default - const reply = currentOrder.payment_type === "link" - ? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago." - : "Tu pedido está listo. Avisame si necesitás algo más."; - + const r = await renderReply({ tenantId, templateKey: "waiting.in_progress", recentReplies }); return { plan: { - reply, + reply: r.reply, next_state: ConversationState.WAITING_WEBHOOKS, intent: "other", missing_fields: [], order_action: "none", }, - decision: { actions: [], order: currentOrder, audit }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } diff --git a/src/modules/3-turn-engine/storeContext.js b/src/modules/3-turn-engine/storeContext.js new file mode 100644 index 0000000..31847b0 --- /dev/null +++ b/src/modules/3-turn-engine/storeContext.js @@ -0,0 +1,104 @@ +/** + * Store Context - Helpers para inyectar info de la tienda en respuestas. + * + * Estos helpers leen del storeConfig ya cargado por getStoreConfig (en + * pipeline / turnEngine) y producen variables consumibles por reply templates + * (renderReply via {{var}}) y por el reply rewriter (como hint contextual). + * + * Filosofía: cuando los datos no están cargados, las vars resuelven a strings + * vacíos / hints neutros. Los templates están diseñados para tolerar ausencia. + */ + +const DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; +const DAY_KEYS_LONG = ["lunes", "martes", "miercoles", "jueves", "viernes", "sabado", "domingo"]; + +/** + * Devuelve la clave de día (mon..sun) para hoy. + */ +function todayKey() { + // Date.getDay(): 0 = domingo, 1 = lunes + const d = new Date().getDay(); + // mapear a mon..sun + return DAY_KEYS[(d + 6) % 7]; +} + +/** + * Extrae el horario (string) de un day-key del schedule jsonb. + * Formato esperado: { mon: { start: '09:00', end: '14:00', enabled: true }, ... } + * Tolera variantes (lunes, monday, etc.) y formatos planos. + */ +function pickDaySlot(scheduleObj, dayIdx) { + if (!scheduleObj || typeof scheduleObj !== "object") return null; + const keys = [DAY_KEYS[dayIdx], DAY_KEYS_LONG[dayIdx]]; + for (const k of keys) { + if (scheduleObj[k]) return scheduleObj[k]; + } + return null; +} + +function formatDaySlot(slot) { + if (!slot) return null; + if (slot.enabled === false) return null; + const start = (slot.start || "").slice(0, 5); + const end = (slot.end || "").slice(0, 5); + if (!start || !end) return null; + return `${start} a ${end}`; +} + +/** + * Resumen de zonas de delivery: lista de barrios o "Consultar zonas". + */ +function summarizeDeliveryZones(deliveryZones) { + if (!deliveryZones || typeof deliveryZones !== "object") return ""; + const names = []; + // Soporta varios formatos: + // 1) { caba: { barrios: ["Palermo", "Belgrano"] } } + // 2) { zones: [{ name }] } + // 3) { palermo: true, belgrano: true } (flat) + if (Array.isArray(deliveryZones.zones)) { + for (const z of deliveryZones.zones) if (z?.name) names.push(z.name); + } else if (deliveryZones.caba?.barrios) { + if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios); + } else { + for (const [k, v] of Object.entries(deliveryZones)) { + if (v === true) names.push(k); + else if (v?.name) names.push(v.name); + } + } + if (!names.length) return ""; + if (names.length <= 5) return names.join(", "); + return `${names.slice(0, 5).join(", ")} y otros`; +} + +/** + * Construye variables de contexto de tienda para usar en reply templates. + * Cuando los datos no están, las vars vienen vacías — los templates las + * absorben sin romper. + * + * @param {Object} storeConfig - resultado de getStoreConfig({ tenantId }) + * @returns {Object} vars para applyVariables / renderReply + */ +export function buildStoreContextVars(storeConfig = {}) { + const dayIdx = (new Date().getDay() + 6) % 7; // 0=lunes + const sched = storeConfig.schedule || {}; + + const deliverySlot = pickDaySlot(sched.delivery, dayIdx); + const pickupSlot = pickDaySlot(sched.pickup, dayIdx); + + const storeHoursToday = formatDaySlot(deliverySlot) || formatDaySlot(pickupSlot) || ""; + const deliveryAvailableNow = deliverySlot ? "sí" : (storeConfig.deliveryEnabled === false ? "no" : ""); + const deliveryZonesSummary = summarizeDeliveryZones(storeConfig.delivery_zones); + + return { + store_name: storeConfig.name || "", + bot_name: storeConfig.botName || "", + store_address: storeConfig.address || "", + store_phone: storeConfig.phone || "", + store_hours: storeConfig.hours || "", + store_hours_today: storeHoursToday, + delivery_hours: storeConfig.deliveryHours || "", + pickup_hours: storeConfig.pickupHours || "", + delivery_available_now: deliveryAvailableNow, + delivery_zones_summary: deliveryZonesSummary, + }; +} diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index 4f24ec2..b752597 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -19,6 +19,7 @@ import { handleWaitingState, } from "./stateHandlers.js"; import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; +import { pushRecent } from "./replyTemplates.js"; // Feature flag para NLU modular const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; @@ -60,10 +61,20 @@ export async function runTurnV3({ // Migrar contexto viejo a nuevo formato de orden const order = migrateOldContext(prev_context); - + // Mapear estados viejos a nuevos const normalizedState = normalizeState(prev_state); + // Recent replies para dedup de templates (FIFO cap 8) + const recentReplies = Array.isArray(prev_context?.recent_replies) + ? prev_context.recent_replies + : []; + + // Counter de búsquedas fallidas consecutivas para escalación + const failedSearches = (prev_context?.failed_searches && typeof prev_context.failed_searches === "object") + ? prev_context.failed_searches + : { count: 0, last_query: null, last_at: null }; + // ───────────────────────────────────────────────────────────── // NLU (con feature flag para sistema modular) // ───────────────────────────────────────────────────────────── @@ -124,13 +135,16 @@ export async function runTurnV3({ order, audit, storeConfig, + recentReplies, + conversation_history: conversation_history || [], + failedSearches, }; // Regla universal: si quiere agregar productos, volver a CART const returnToCart = shouldReturnToCart(normalizedState, nlu, text); if (returnToCart) { const result = await handleCartState({ ...handlerParams, fromIdle: false }); - return formatResult(result, prev_context); + return formatResult(result, prev_context, recentReplies, failedSearches); } // Dispatch por estado actual @@ -162,7 +176,7 @@ export async function runTurnV3({ result = await handleIdleState(handlerParams); } - return formatResult(result, prev_context); + return formatResult(result, prev_context, recentReplies, failedSearches); } /** @@ -198,9 +212,22 @@ function normalizeState(state) { /** * Formatea el resultado para compatibilidad con el sistema existente */ -function formatResult(result, prevContext) { +function formatResult(result, prevContext, recentReplies = [], failedSearches = { count: 0 }) { const { plan, decision } = result; const order = decision?.order || createEmptyOrder(); + + // Mergear template_ids usados por los handlers en recent_replies + const idsUsed = Array.isArray(decision?.template_ids_used) + ? decision.template_ids_used.filter(Boolean) + : []; + let nextRecent = recentReplies; + for (const id of idsUsed) { + nextRecent = pushRecent(nextRecent, id); + } + + // failed_searches: handlers pueden devolver decision.failed_searches_next. + // Si no, mantener el previo. + const nextFailedSearches = decision?.failed_searches_next || failedSearches; // Construir context_patch para compatibilidad con pipeline const context_patch = { @@ -238,6 +265,11 @@ function formatResult(result, prevContext) { order.is_delivery === false ? "pickup" : null, delivery_address: order.shipping_address ? { text: order.shipping_address } : null, woo_order_id: order.woo_order_id, + + // Dedup de respuestas: ids de templates usados, FIFO cap 8 + recent_replies: nextRecent, + // Counter de búsquedas fallidas para escalación + failed_searches: nextFailedSearches, }; // Construir basket_resolved para UI