From 675a449ce8a437f0fc2b4e5f60b4c692132c9fd4 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Sat, 2 May 2026 13:14:59 -0300 Subject: [PATCH] D9 cleanup: borrar NLU/handlers/machine/replyTemplates legacy + activar agente + prompt caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Después de validar el agente E2E con DeepSeek, el legacy queda muerto. 51 archivos cambiados (la mayoría borrados), el motor único es ahora el agente tool-calling. Borrados (~3500 LOC): - src/modules/3-turn-engine/nlu/ (router + 4 specialists + promptLoader + schemas + humanFallback + 6 default prompts) — reemplazado por systemPrompt.js - src/modules/3-turn-engine/stateHandlers/ (cart.js, cartHelpers.js, idle.js, shipping.js, utils.js, index.js) — reemplazado por tools del agente - src/modules/3-turn-engine/stateHandlers.js (re-export shim) - src/modules/3-turn-engine/openai.js (NLU clásico v3 + jsonCompletion + llmRecommendWriter + llmPlanningRecommend) — el agente crea su propio cliente OpenAI con tools nativos - src/modules/3-turn-engine/replyRewriter.js (rewriting LLM) — el agente escribe say directo, no necesita reescribir - src/modules/3-turn-engine/replyTemplates.js + test (rotación de variantes) — el agente varía naturalmente con tool_choice=required + temperature - src/modules/3-turn-engine/recommendations.js (cross-sell + planning) — el agente decide cuándo recomendar via tool calls - src/modules/3-turn-engine/machine/ (XState v5 completo + 19 tests) — reemplazado por la FSM podada en fsm.js + agent/runTurn.js - src/modules/3-turn-engine/turnEngineV3.helpers.js, .units.js, .pendingSelection.js (helpers del legacy) - src/modules/0-ui/controllers/prompts.js, handlers/prompts.js, db/promptsRepo.js — admin de prompts NLU (ya no hay prompts editables) - public/components/prompts-crud.js + nav entry en ops-shell turnEngineV3.js se reduce a un thin wrapper que exporta runTurnV3 (alias de runTurnAgent) + safeNextState (re-export de fsm.js). Mantiene la firma pública para no tocar pipeline.js. Activado: - AGENT_MAX_TOOL_CALLS=10 y AGENT_TURN_TIMEOUT_MS=25000 son los únicos flags. Borradas: USE_MODULAR_NLU, USE_XSTATE, XSTATE_SHADOW, XSTATE_SETTLE_MS, REPLY_REWRITER, REPLY_REWRITER_TIMEOUT_MS, TURN_ENGINE, AGENT_TURN_ENGINE, AGENT_TURN_ENGINE_SHADOW (el agente es default). Prompt caching DeepSeek: - systemPrompt.js: era función con storeName interpolado → ahora export const SYSTEM_PROMPT (100% estático). storeName se pasa por user message via working_memory.store.name. Cualquier cambio al system invalida cache, por eso es estático estricto. - runTurn.js: captura usage.prompt_cache_hit_tokens (DeepSeek) o prompt_tokens_details.cached_tokens (OpenAI compat) y suma a métricas. - /api/metrics/agent ahora reporta prompt_tokens_total, completion_tokens_total, prompt_cache_hit_tokens, cache_hit_ratio. - Smoke test 3 turnos: cache_hit_ratio = 0.72 (17664 cached / 24546 total prompt tokens). Saving directo en costo: ~$0.02/M cached vs $0.27/M no cached en DeepSeek. Tests: 148/148 (perdimos 90 tests del legacy XState/replyTemplates que ya no aplican). Sim flow E2E confirmado: hola → agent responde, multi-turn con cache caliente. Si más adelante hace falta volver al legacy: git revert este commit (c c9c69cf8 es el último estado verde con doble motor). Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 58 +- env.example | 34 +- public/app.js | 1 - public/components/ops-shell.js | 7 - public/components/prompts-crud.js | 469 ------------ src/modules/0-ui/controllers/prompts.js | 158 ---- src/modules/0-ui/db/promptsRepo.js | 182 ----- src/modules/0-ui/handlers/prompts.js | 258 ------- src/modules/1-intake/routes/simulator.js | 13 +- src/modules/3-turn-engine/agent/runTurn.js | 42 +- .../3-turn-engine/agent/systemPrompt.js | 18 +- src/modules/3-turn-engine/machine/actions.js | 403 ---------- src/modules/3-turn-engine/machine/actors.js | 63 -- src/modules/3-turn-engine/machine/e2e.test.js | 152 ---- src/modules/3-turn-engine/machine/guards.js | 88 --- src/modules/3-turn-engine/machine/index.js | 359 --------- .../3-turn-engine/machine/index.test.js | 231 ------ .../3-turn-engine/machine/nluToEvent.js | 46 -- src/modules/3-turn-engine/machine/runner.js | 244 ------ .../3-turn-engine/nlu/defaults/browse.txt | 73 -- .../3-turn-engine/nlu/defaults/greeting.txt | 23 - .../3-turn-engine/nlu/defaults/orders.txt | 120 --- .../3-turn-engine/nlu/defaults/payment.txt | 60 -- .../3-turn-engine/nlu/defaults/router.txt | 33 - .../3-turn-engine/nlu/defaults/shipping.txt | 64 -- .../3-turn-engine/nlu/humanFallback.js | 164 ---- src/modules/3-turn-engine/nlu/index.js | 182 ----- src/modules/3-turn-engine/nlu/promptLoader.js | 204 ----- src/modules/3-turn-engine/nlu/router.js | 159 ---- src/modules/3-turn-engine/nlu/schemas.js | 259 ------- .../3-turn-engine/nlu/specialists/browse.js | 171 ----- .../3-turn-engine/nlu/specialists/greeting.js | 100 --- .../3-turn-engine/nlu/specialists/orders.js | 163 ---- .../3-turn-engine/nlu/specialists/shipping.js | 157 ---- src/modules/3-turn-engine/openai.js | 606 --------------- src/modules/3-turn-engine/recommendations.js | 511 ------------- src/modules/3-turn-engine/replyRewriter.js | 229 ------ src/modules/3-turn-engine/replyTemplates.js | 311 -------- .../3-turn-engine/replyTemplates.test.js | 136 ---- src/modules/3-turn-engine/stateHandlers.js | 29 - .../3-turn-engine/stateHandlers/cart.js | 710 ------------------ .../stateHandlers/cartHelpers.js | 611 --------------- .../3-turn-engine/stateHandlers/idle.js | 46 -- .../3-turn-engine/stateHandlers/index.js | 30 - .../3-turn-engine/stateHandlers/shipping.js | 168 ----- .../3-turn-engine/stateHandlers/utils.js | 147 ---- .../3-turn-engine/stateHandlers/utils.test.js | 448 ----------- .../3-turn-engine/turnEngineV3.helpers.js | 16 - src/modules/3-turn-engine/turnEngineV3.js | 393 +--------- .../turnEngineV3.pendingSelection.js | 112 --- .../3-turn-engine/turnEngineV3.units.js | 51 -- 51 files changed, 106 insertions(+), 9206 deletions(-) delete mode 100644 public/components/prompts-crud.js delete mode 100644 src/modules/0-ui/controllers/prompts.js delete mode 100644 src/modules/0-ui/db/promptsRepo.js delete mode 100644 src/modules/0-ui/handlers/prompts.js delete mode 100644 src/modules/3-turn-engine/machine/actions.js delete mode 100644 src/modules/3-turn-engine/machine/actors.js delete mode 100644 src/modules/3-turn-engine/machine/e2e.test.js delete mode 100644 src/modules/3-turn-engine/machine/guards.js delete mode 100644 src/modules/3-turn-engine/machine/index.js delete mode 100644 src/modules/3-turn-engine/machine/index.test.js delete mode 100644 src/modules/3-turn-engine/machine/nluToEvent.js delete mode 100644 src/modules/3-turn-engine/machine/runner.js delete mode 100644 src/modules/3-turn-engine/nlu/defaults/browse.txt delete mode 100644 src/modules/3-turn-engine/nlu/defaults/greeting.txt delete mode 100644 src/modules/3-turn-engine/nlu/defaults/orders.txt delete mode 100644 src/modules/3-turn-engine/nlu/defaults/payment.txt delete mode 100644 src/modules/3-turn-engine/nlu/defaults/router.txt delete mode 100644 src/modules/3-turn-engine/nlu/defaults/shipping.txt delete mode 100644 src/modules/3-turn-engine/nlu/humanFallback.js delete mode 100644 src/modules/3-turn-engine/nlu/index.js delete mode 100644 src/modules/3-turn-engine/nlu/promptLoader.js delete mode 100644 src/modules/3-turn-engine/nlu/router.js delete mode 100644 src/modules/3-turn-engine/nlu/schemas.js delete mode 100644 src/modules/3-turn-engine/nlu/specialists/browse.js delete mode 100644 src/modules/3-turn-engine/nlu/specialists/greeting.js delete mode 100644 src/modules/3-turn-engine/nlu/specialists/orders.js delete mode 100644 src/modules/3-turn-engine/nlu/specialists/shipping.js delete mode 100644 src/modules/3-turn-engine/openai.js delete mode 100644 src/modules/3-turn-engine/recommendations.js delete mode 100644 src/modules/3-turn-engine/replyRewriter.js delete mode 100644 src/modules/3-turn-engine/replyTemplates.js delete mode 100644 src/modules/3-turn-engine/replyTemplates.test.js delete mode 100644 src/modules/3-turn-engine/stateHandlers.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/cart.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/cartHelpers.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/idle.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/index.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/shipping.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/utils.js delete mode 100644 src/modules/3-turn-engine/stateHandlers/utils.test.js delete mode 100644 src/modules/3-turn-engine/turnEngineV3.helpers.js delete mode 100644 src/modules/3-turn-engine/turnEngineV3.pendingSelection.js delete mode 100644 src/modules/3-turn-engine/turnEngineV3.units.js diff --git a/CLAUDE.md b/CLAUDE.md index 1f8e4a3..9cbe3f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,36 +47,51 @@ The DB schema retains `tenant_id` columns (it was originally multi-tenant) but t ### Request flow ``` -WhatsApp → Evolution API webhook → /webhook/evolution +WhatsApp → Evolution API webhook → /webhook/evolution (or /sim/send) ↓ 1-intake: route & normalize message ↓ - 3-turn-engine: NLU → FSM → state handler + 2-identity/pipeline.processMessage (idempotency, history, side effects) + ↓ + 3-turn-engine/agent: tool-calling LLM loop ↓ Response persisted to DB + sent back via Evolution API ``` +### Turn engine: tool-calling agent (DeepSeek) + +`src/modules/3-turn-engine/agent/` es el único motor. Cada turno arma un **WorkingMemory** (cart, pending, last_shown_options, store, history truncado, customer_profile, preparsed quantity) y se lo pasa al LLM como user message. El LLM decide qué tools llamar: + +- `search_catalog`, `add_to_cart`, `set_quantity`, `select_candidate`, `remove_from_cart` +- `set_shipping`, `set_address`, `confirm_order` +- `pause`, `escalate_to_human` +- `say` (último siempre — es el reply al usuario) + +El system prompt es **estático** (en `agent/systemPrompt.js` como `SYSTEM_PROMPT` const) para que DeepSeek lo cachée prefix-cache automáticamente. Cache hit ratio típico ≥70% después de 2 turnos. El parser de cantidades (`agent/quantityParser.js`) preprocesa el texto y se pasa como `working_memory.preparsed` (fracciones, "media docena", "cuarto kilo", etc.). + +La FSM (`fsm.js`) sigue siendo guardrail: estados `IDLE / CART / SHIPPING / PAUSED / AWAITING_HUMAN` con transiciones validadas. PAUSED tiene TTL 7d (cart preservado para "después te digo"). + ### Module structure (numbered layers) -- **`src/modules/0-UI/`** — Admin dashboard: REST controllers for products, conversations, settings, prompts, takeovers, recommendations, aliases. Each controller has a `db/` sub-layer for persistence. +- **`src/modules/0-UI/`** — Admin dashboard: REST controllers para products, conversations, settings, takeovers, recommendations, aliases. -- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp). Normalizes incoming messages before passing to turn engine. +- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp). -- **`src/modules/2-identity/`** — Tenant and user management. Maps WhatsApp numbers to WooCommerce customers. Stores encrypted WooCommerce credentials per tenant in `tenant_ecommerce_config`. Routes WooCommerce webhooks. +- **`src/modules/2-identity/`** — User mapping (WhatsApp ↔ WooCommerce customer), encrypted WooCommerce credentials, pipeline orchestrator. -- **`src/modules/3-turn-engine/`** — Core logic. NLU classifies intents; FSM transitions states (`IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS`). Two NLU versions controlled by `USE_MODULAR_NLU` env flag. Two turn engine versions controlled by `TURN_ENGINE` env flag. State handlers map to FSM states. +- **`src/modules/3-turn-engine/`** — Agente tool-calling (`agent/`), FSM (`fsm.js`), order model (`orderModel.js`), catalog retrieval (`catalogRetrieval.js`), store context (`storeContext.js`). -- **`src/modules/4-woo-orders/`** — WooCommerce order sync. Fetches and caches customer order history for conversation context. +- **`src/modules/4-woo-orders/`** — WooCommerce order sync (lectura). El bot crea orders nuevas vía `wooOrders.createOrder` desde `pipeline.js` cuando emite la action `create_order`. -- **`src/modules/shared/`** — DB pool (PostgreSQL via `pg`), SSE for real-time admin UI updates, WooSnapshot (product catalog cache), debug utilities. +- **`src/modules/shared/`** — DB pool, SSE, WooSnapshot, tenant resolver (`getTenantId()`), debug. ### Key integrations | System | Purpose | Config | |--------|---------|--------| -| OpenAI | NLU intent classification & response generation | `OPENAI_API_KEY`, `OPENAI_MODEL` | -| Evolution API | WhatsApp send/receive | `EVOLUTION_API_URL`, `EVOLUTION_API_KEY`, `EVOLUTION_INSTANCE_NAME`, `EVOLUTION_SEND_ENABLED` | -| WooCommerce REST API | Products, orders, customers | `WOO_*` env vars or per-tenant in DB | +| LLM (DeepSeek) | Agente tool-calling — único motor | `OPENAI_API_KEY`, `OPENAI_BASE_URL=https://api.deepseek.com/v1`, `OPENAI_MODEL=deepseek-chat` | +| Evolution API | WhatsApp send/receive | `EVOLUTION_*`, `EVOLUTION_SEND_ENABLED` | +| WooCommerce REST API | Products, orders, customers | `WOO_BASE_URL`, `WOO_CONSUMER_KEY`, `WOO_CONSUMER_SECRET` | | PostgreSQL | Primary database | `DATABASE_URL` | ### Database @@ -84,18 +99,23 @@ WhatsApp → Evolution API webhook → /webhook/evolution Migrations live in `db/migrations/` as timestamped SQL files managed by `dbmate`. Key tables: - `tenants`, `tenant_config`, `tenant_settings`, `tenant_ecommerce_config`, `tenant_channels` - `wa_identity_map` — WhatsApp ↔ WooCommerce customer mapping -- `wa_conversation_state` — FSM state + context per conversation -- `wa_messages` — Message history -- `woo_products_snapshot` — Cached product catalog -- `prompt_templates` — Versioned LLM prompts +- `wa_conversation_state` — FSM state + context (cart, pending, last_shown_options, paused_until) en JSONB +- `wa_messages` — Message history (idempotencia por message_id) +- `woo_products_snapshot` — Cached product catalog (con índices pg_trgm en aliases) +- `product_aliases`, `alias_product_mappings` — fuzzy alias resolution +- `woo_orders_cache` + `woo_order_items` — orders sync para customer_profile / stats - `human_takeovers`, `audit_log`, `conversation_runs` ### Feature flags (env vars) -- `TURN_ENGINE=v1|v2` — Which turn engine version to use -- `USE_MODULAR_NLU=1` — Use modular NLU (prompt templates from DB) vs. v3 hardcoded -- `EVOLUTION_SEND_ENABLED=1` — Actually send messages to WhatsApp (disable in dev/test) -- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — Granular debug logging +- `AGENT_MAX_TOOL_CALLS=10` — cap de tool calls por turno +- `AGENT_TURN_TIMEOUT_MS=25000` — timeout total del turno +- `EVOLUTION_SEND_ENABLED=1` — enviar a WhatsApp real (off en dev) +- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — debug logs granular + +### Métricas + +- `GET /api/metrics/agent` — turns, avg tool calls, fallback rate, escalations, **cache_hit_ratio** (prompt caching de DeepSeek) ### Local development diff --git a/env.example b/env.example index bc48d82..4a84755 100644 --- a/env.example +++ b/env.example @@ -13,24 +13,17 @@ PG_CONN_TIMEOUT_MS=5000 APP_ENCRYPTION_KEY=your-32-char-encryption-key-here # =================== -# OpenAI +# LLM (OpenAI-compatible: DeepSeek, OpenAI, Anthropic via gateway, etc.) # =================== +# Default actual: DeepSeek V3.x con tool-calling nativo + prompt caching automático. OPENAI_API_KEY=sk-xxx -OPENAI_MODEL=gpt-4o-mini +OPENAI_BASE_URL=https://api.deepseek.com/v1 +OPENAI_MODEL=deepseek-chat # =================== -# Turn Engine +# WooCommerce # =================== -# v1 = pipeline actual (heurísticas + guardrails + LLM plan final) -# v2 = LLM-first NLU, deterministic core (nuevo motor) -TURN_ENGINE=v1 - -# =================== -# WooCommerce (usado por seed y fallback) -# Estas variables son leídas por scripts/seed-tenant.mjs para crear -# la configuración inicial del tenant en la base de datos. -# =================== -WOO_BASE_URL=https://tu-tienda.com +WOO_BASE_URL=https://tu-tienda.com/wp-json/wc/v3 WOO_CONSUMER_KEY=ck_xxx WOO_CONSUMER_SECRET=cs_xxx @@ -49,19 +42,10 @@ LIMIT_CONVERSATIONS=100 MAX_CHARS_PER_MESSAGE=4000 # =================== -# Reply Rewriter (LLM adapta templates al contexto) +# Agent (tool-calling) — único motor de turno # =================== -REPLY_REWRITER=0 -REPLY_REWRITER_TIMEOUT_MS=1500 - -# =================== -# XState (Turn engine v2) -# =================== -# USE_XSTATE=1 → reemplaza dispatcher legacy con statechart formal -# XSTATE_SHADOW=1 → corre ambos paths, devuelve legacy, loguea diffs en logs -USE_XSTATE=0 -XSTATE_SHADOW=0 -XSTATE_SETTLE_MS=10000 +AGENT_MAX_TOOL_CALLS=10 +AGENT_TURN_TIMEOUT_MS=25000 # =================== # Debug Flags (1/true/yes/on para activar) diff --git a/public/app.js b/public/app.js index fd21d20..0190944 100644 --- a/public/app.js +++ b/public/app.js @@ -11,7 +11,6 @@ import "./components/recommendations-crud.js"; import "./components/quantities-crud.js"; import "./components/orders-crud.js"; import "./components/test-panel.js"; -import "./components/prompts-crud.js"; import "./components/takeovers-crud.js"; import "./components/settings-crud.js"; import { connectSSE } from "./lib/sse.js"; diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js index 72bea49..fbc9bc9 100644 --- a/public/components/ops-shell.js +++ b/public/components/ops-shell.js @@ -67,7 +67,6 @@ class OpsShell extends HTMLElement { Cross-sell Cantidades Pedidos - Prompts Config Test @@ -141,12 +140,6 @@ class OpsShell extends HTMLElement { -
-
- -
-
-
diff --git a/public/components/prompts-crud.js b/public/components/prompts-crud.js deleted file mode 100644 index b50bf32..0000000 --- a/public/components/prompts-crud.js +++ /dev/null @@ -1,469 +0,0 @@ -import { api } from "../lib/api.js"; -import { on } from "../lib/bus.js"; -import { modal } from "../lib/modal.js"; - -const PROMPT_LABELS = { - router: "Router (clasificador de dominio)", - greeting: "Saludos", - orders: "Pedidos", - shipping: "Envio/Retiro", - browse: "Consultas de catalogo", -}; - -class PromptsCrud extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - this.items = []; - this.selected = null; - this.loading = false; - this.versions = []; - this.availableVariables = []; - this.availableModels = []; - this.currentSettings = {}; // Valores actuales de las variables - this.testResult = null; - this.testLoading = false; - - this.shadowRoot.innerHTML = ` - - -
-
-
Prompts del Sistema
-
-
Cargando...
-
-
- -
-
Editor de Prompt
-
-
Selecciona un prompt para editarlo
-
-
-
- `; - } - - connectedCallback() { - this.load(); - - // Refrescar settings cuando se vuelve a esta vista (por si cambiaron en Config) - this._unsubRouter = on("router:viewChanged", ({ view }) => { - if (view === "prompts") { - this.refreshSettings(); - } - }); - } - - disconnectedCallback() { - this._unsubRouter?.(); - } - - async refreshSettings() { - try { - const settings = await api.getSettings(); - this.currentSettings = { - store_name: settings.store_name || "", - store_hours: this.formatStoreHours(settings), - store_address: settings.store_address || "", - store_phone: settings.store_phone || "", - bot_name: settings.bot_name || "", - current_date: new Date().toLocaleDateString("es-AR"), - customer_name: "(nombre del cliente)", - state: "(estado actual)", - }; - // Re-renderizar el form si hay uno seleccionado - if (this.selected) { - this.renderForm(); - } - } catch (e) { - console.debug("Error refreshing settings:", e); - } - } - - async load() { - this.loading = true; - this.renderList(); - - try { - // Cargar prompts y settings en paralelo - const [data, settings] = await Promise.all([ - api.prompts(), - api.getSettings().catch(() => ({})), - ]); - - this.items = data.items || []; - this.availableVariables = data.available_variables || []; - this.availableModels = data.available_models || []; - - // Mapear settings a variables - this.currentSettings = { - store_name: settings.store_name || "", - store_hours: this.formatStoreHours(settings), - store_address: settings.store_address || "", - store_phone: settings.store_phone || "", - bot_name: settings.bot_name || "", - current_date: new Date().toLocaleDateString("es-AR"), - customer_name: "(nombre del cliente)", - state: "(estado actual)", - }; - - this.loading = false; - this.renderList(); - } catch (e) { - console.error("Error loading prompts:", e); - this.items = []; - this.loading = false; - this.renderList(); - } - } - - formatStoreHours(settings) { - if (!settings.pickup_days) return ""; - - // Mapeo de días cortos a nombres legibles - const dayNames = { - lun: "Lun", mar: "Mar", mie: "Mié", jue: "Jue", - vie: "Vie", sab: "Sáb", dom: "Dom" - }; - - const days = settings.pickup_days.split(",").map(d => dayNames[d.trim()] || d).join(", "); - const start = (settings.pickup_hours_start || "08:00").slice(0, 5); - const end = (settings.pickup_hours_end || "20:00").slice(0, 5); - - return `${days} de ${start} a ${end}`; - } - - renderList() { - const list = this.shadowRoot.getElementById("list"); - - if (this.loading) { - list.innerHTML = `
Cargando...
`; - return; - } - - if (!this.items.length) { - list.innerHTML = `
No se encontraron prompts
`; - return; - } - - list.innerHTML = ""; - for (const item of this.items) { - const el = document.createElement("div"); - el.className = "item" + (this.selected?.prompt_key === item.prompt_key ? " active" : ""); - - const label = PROMPT_LABELS[item.prompt_key] || item.prompt_key; - const statusClass = item.is_default ? "default" : "custom"; - const statusText = item.is_default ? "Default" : `v${item.version}`; - - el.innerHTML = ` -
${label}
-
- ${statusText} - ${item.model ? ` | ${item.model}` : ""} -
- `; - - el.onclick = () => this.selectPrompt(item); - list.appendChild(el); - } - } - - async selectPrompt(item) { - this.selected = item; - this.testResult = null; - this.renderList(); - - // Cargar detalles con versiones - try { - const details = await api.getPrompt(item.prompt_key); - this.selected = { ...item, ...details.current }; - this.versions = details.versions || []; - this.availableVariables = details.available_variables || this.availableVariables; - this.availableModels = details.available_models || this.availableModels; - this.renderForm(); - } catch (e) { - console.error("Error loading prompt details:", e); - this.renderForm(); - } - } - - renderForm() { - const form = this.shadowRoot.getElementById("form"); - const title = this.shadowRoot.getElementById("formTitle"); - - if (!this.selected) { - title.textContent = "Editor de Prompt"; - form.innerHTML = `
Selecciona un prompt para editarlo
`; - return; - } - - const label = PROMPT_LABELS[this.selected.prompt_key] || this.selected.prompt_key; - title.textContent = `Editar: ${label}`; - - const content = this.selected.content || ""; - const model = this.selected.model || "gpt-4-turbo"; - - form.innerHTML = ` -
-
- - -
-
- - -
-
- -
- - -
- Variables disponibles (click para insertar): -
- ${this.availableVariables.map(v => { - const key = typeof v === 'string' ? v : v.key; - const desc = typeof v === 'string' ? '' : (v.description || ''); - const value = this.currentSettings[key] || ''; - const displayValue = value ? `= ${value}` : '(vacío)'; - return ` - - ${this.escapeHtml(displayValue)} - `; - }).join("")} -
-
-
- - ${this.versions.length > 0 ? ` -
- -
- ${this.versions.map(v => ` -
- v${v.version} ${v.is_active ? "(activa)" : ""} - ${this.formatDate(v.created_at)} - ${!v.is_active ? `` : ""} -
- `).join("")} -
-
- ` : ""} - -
- - -
- - - `; - - // Event listeners - this.shadowRoot.getElementById("saveBtn").onclick = () => this.save(); - this.shadowRoot.getElementById("resetBtn").onclick = () => this.reset(); - this.shadowRoot.getElementById("testBtn").onclick = () => this.toggleTestSection(); - this.shadowRoot.getElementById("runTestBtn").onclick = () => this.runTest(); - - // Variable buttons - this.shadowRoot.querySelectorAll(".var-btn").forEach(btn => { - btn.onclick = () => this.insertVariable(btn.dataset.var); - }); - - // Version restore buttons - this.shadowRoot.querySelectorAll(".versions-list button").forEach(btn => { - btn.onclick = () => this.rollback(parseInt(btn.dataset.version, 10)); - }); - } - - escapeHtml(str) { - return (str || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - - formatDate(dateStr) { - if (!dateStr) return ""; - const d = new Date(dateStr); - return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); - } - - insertVariable(varName) { - const textarea = this.shadowRoot.getElementById("contentInput"); - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const text = textarea.value; - const insertion = `{{${varName}}}`; - textarea.value = text.slice(0, start) + insertion + text.slice(end); - textarea.selectionStart = textarea.selectionEnd = start + insertion.length; - textarea.focus(); - } - - toggleTestSection() { - const section = this.shadowRoot.getElementById("testSection"); - section.style.display = section.style.display === "none" ? "block" : "none"; - } - - async save() { - const content = this.shadowRoot.getElementById("contentInput").value; - const model = this.shadowRoot.getElementById("modelSelect").value; - - if (!content.trim()) { - modal.warn("El contenido no puede estar vacío"); - return; - } - - try { - await api.savePrompt(this.selected.prompt_key, { content, model }); - modal.success("Prompt guardado correctamente"); - await this.load(); - // Re-seleccionar el prompt actual - const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); - if (updated) this.selectPrompt(updated); - } catch (e) { - console.error("Error saving prompt:", e); - modal.error("Error guardando: " + (e.message || e)); - } - } - - async reset() { - const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?"); - if (!confirmed) return; - - try { - await api.resetPrompt(this.selected.prompt_key); - modal.success("Prompt reseteado a default"); - await this.load(); - const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); - if (updated) this.selectPrompt(updated); - } catch (e) { - console.error("Error resetting prompt:", e); - modal.error("Error: " + (e.message || e)); - } - } - - async rollback(version) { - const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`); - if (!confirmed) return; - - try { - await api.rollbackPrompt(this.selected.prompt_key, version); - modal.success("Versión restaurada"); - await this.load(); - const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); - if (updated) this.selectPrompt(updated); - } catch (e) { - console.error("Error rolling back:", e); - modal.error("Error: " + (e.message || e)); - } - } - - async runTest() { - const testMessage = this.shadowRoot.getElementById("testMessage").value; - if (!testMessage.trim()) { - modal.warn("Ingresa un mensaje de prueba"); - return; - } - - const content = this.shadowRoot.getElementById("contentInput").value; - const container = this.shadowRoot.getElementById("testResultContainer"); - container.innerHTML = `
Ejecutando prueba...
`; - - try { - const result = await api.testPrompt(this.selected.prompt_key, { - content, - test_message: testMessage, - store_config: { store_name: "Carniceria Demo", bot_name: "Piaf" }, - }); - - if (result.ok) { - let parsed = result.response; - try { - parsed = JSON.stringify(JSON.parse(result.response), null, 2); - } catch (e) { /* no es JSON */ } - - container.innerHTML = ` -
${this.escapeHtml(parsed)}
-
- Modelo: ${result.model} | Latencia: ${result.latency_ms}ms | - Tokens: ${result.usage?.total_tokens || "?"} -
- `; - } else { - container.innerHTML = `
Error: ${result.error || "Unknown"}
`; - } - } catch (e) { - console.error("Error testing prompt:", e); - container.innerHTML = `
Error: ${e.message || e}
`; - } - } -} - -customElements.define("prompts-crud", PromptsCrud); diff --git a/src/modules/0-ui/controllers/prompts.js b/src/modules/0-ui/controllers/prompts.js deleted file mode 100644 index d132fa7..0000000 --- a/src/modules/0-ui/controllers/prompts.js +++ /dev/null @@ -1,158 +0,0 @@ -import { - handleListPrompts, - handleGetPrompt, - handleSavePrompt, - handleRollbackPrompt, - handleResetPrompt, - handleGetPromptVersion, - handleTestPrompt, -} from "../handlers/prompts.js"; - -/** - * GET /prompts - Lista todos los prompts del tenant - */ -export const makeListPrompts = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const result = await handleListPrompts({ tenantId }); - res.json(result); - } catch (err) { - console.error("[prompts] List error:", err); - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; - -/** - * GET /prompts/:key - Obtiene un prompt específico con versiones - */ -export const makeGetPrompt = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const promptKey = req.params.key; - const result = await handleGetPrompt({ tenantId, promptKey }); - res.json(result); - } catch (err) { - console.error("[prompts] Get error:", err); - if (err.message.includes("Invalid prompt_key")) { - return res.status(400).json({ ok: false, error: "invalid_prompt_key" }); - } - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; - -/** - * POST /prompts/:key - Crea/actualiza un prompt (nueva versión) - */ -export const makeSavePrompt = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const promptKey = req.params.key; - const { content, model, created_by } = req.body || {}; - - if (!content) { - return res.status(400).json({ ok: false, error: "content_required" }); - } - - const result = await handleSavePrompt({ - tenantId, - promptKey, - content, - model, - createdBy: created_by || null, - }); - res.json(result); - } catch (err) { - console.error("[prompts] Save error:", err); - if (err.message.includes("Invalid prompt_key")) { - return res.status(400).json({ ok: false, error: "invalid_prompt_key" }); - } - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; - -/** - * POST /prompts/:key/rollback/:version - Restaura una versión anterior - */ -export const makeRollbackPrompt = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const { key, version } = req.params; - const { created_by } = req.body || {}; - - const result = await handleRollbackPrompt({ - tenantId, - promptKey: key, - toVersion: version, - createdBy: created_by || null, - }); - res.json(result); - } catch (err) { - console.error("[prompts] Rollback error:", err); - if (err.message.includes("not found")) { - return res.status(404).json({ ok: false, error: "version_not_found" }); - } - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; - -/** - * POST /prompts/:key/reset - Resetea al default - */ -export const makeResetPrompt = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const promptKey = req.params.key; - - const result = await handleResetPrompt({ tenantId, promptKey }); - res.json(result); - } catch (err) { - console.error("[prompts] Reset error:", err); - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; - -/** - * GET /prompts/:key/versions/:version - Obtiene contenido de una versión específica - */ -export const makeGetPromptVersion = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const { key, version } = req.params; - - const result = await handleGetPromptVersion({ tenantId, promptKey: key, version }); - res.json(result); - } catch (err) { - console.error("[prompts] GetVersion error:", err); - if (err.message.includes("not found")) { - return res.status(404).json({ ok: false, error: "version_not_found" }); - } - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; - -/** - * POST /prompts/:key/test - Prueba un prompt con un mensaje - */ -export const makeTestPrompt = (tenantIdOrFn) => async (req, res) => { - try { - const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; - const promptKey = req.params.key; - const { content, test_message, store_config } = req.body || {}; - - if (!test_message) { - return res.status(400).json({ ok: false, error: "test_message_required" }); - } - - const result = await handleTestPrompt({ - tenantId, - promptKey, - content, - testMessage: test_message, - storeConfig: store_config || {}, - }); - res.json(result); - } catch (err) { - console.error("[prompts] Test error:", err); - res.status(500).json({ ok: false, error: "internal_error", message: err.message }); - } -}; diff --git a/src/modules/0-ui/db/promptsRepo.js b/src/modules/0-ui/db/promptsRepo.js deleted file mode 100644 index e9b1fb9..0000000 --- a/src/modules/0-ui/db/promptsRepo.js +++ /dev/null @@ -1,182 +0,0 @@ -import { pool } from "../../shared/db/pool.js"; - -// ───────────────────────────────────────────────────────────── -// Prompt Templates - CRUD con versionado -// ───────────────────────────────────────────────────────────── - -// Prompt keys válidos -export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "browse"]; - -// Modelos por defecto para cada prompt -export const DEFAULT_MODELS = { - router: "gpt-4o-mini", - greeting: "gpt-4-turbo", - orders: "gpt-4-turbo", - shipping: "gpt-4o-mini", - browse: "gpt-4-turbo", -}; - -/** - * Obtiene el prompt activo para un tenant y key - * @returns {Object|null} { id, prompt_key, content, model, version, is_active, created_at, created_by } - */ -export async function getActivePrompt({ tenantId, promptKey }) { - const sql = ` - SELECT id, prompt_key, content, model, version, is_active, created_at, created_by - FROM prompt_templates - WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true - LIMIT 1 - `; - const { rows } = await pool.query(sql, [tenantId, promptKey]); - return rows[0] || null; -} - -/** - * Lista todos los prompts activos de un tenant - */ -export async function listActivePrompts({ tenantId }) { - const sql = ` - SELECT id, prompt_key, content, model, version, is_active, created_at, created_by - FROM prompt_templates - WHERE tenant_id = $1 AND is_active = true - ORDER BY prompt_key - `; - const { rows } = await pool.query(sql, [tenantId]); - return rows; -} - -/** - * Obtiene todas las versiones de un prompt - */ -export async function getPromptVersions({ tenantId, promptKey, limit = 20 }) { - const sql = ` - SELECT id, prompt_key, content, model, version, is_active, created_at, created_by - FROM prompt_templates - WHERE tenant_id = $1 AND prompt_key = $2 - ORDER BY version DESC - LIMIT $3 - `; - const { rows } = await pool.query(sql, [tenantId, promptKey, limit]); - return rows; -} - -/** - * Obtiene una versión específica de un prompt - */ -export async function getPromptVersion({ tenantId, promptKey, version }) { - const sql = ` - SELECT id, prompt_key, content, model, version, is_active, created_at, created_by - FROM prompt_templates - WHERE tenant_id = $1 AND prompt_key = $2 AND version = $3 - LIMIT 1 - `; - const { rows } = await pool.query(sql, [tenantId, promptKey, version]); - return rows[0] || null; -} - -/** - * Desactiva el prompt activo actual (para crear nueva versión) - */ -export async function deactivatePrompt({ tenantId, promptKey }) { - const sql = ` - UPDATE prompt_templates - SET is_active = false - WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true - `; - await pool.query(sql, [tenantId, promptKey]); -} - -/** - * Crea una nueva versión del prompt (automáticamente desactiva la anterior) - * @returns {Object} El prompt creado con su versión - */ -export async function createPrompt({ tenantId, promptKey, content, model, createdBy = null }) { - // Validar prompt_key - if (!PROMPT_KEYS.includes(promptKey)) { - throw new Error(`Invalid prompt_key: ${promptKey}. Valid keys: ${PROMPT_KEYS.join(", ")}`); - } - - // Desactivar versión anterior - await deactivatePrompt({ tenantId, promptKey }); - - // Insertar nueva versión (el trigger calcula la versión automáticamente) - const sql = ` - INSERT INTO prompt_templates (tenant_id, prompt_key, content, model, is_active, created_by) - VALUES ($1, $2, $3, $4, true, $5) - RETURNING id, prompt_key, content, model, version, is_active, created_at, created_by - `; - const { rows } = await pool.query(sql, [ - tenantId, - promptKey, - content, - model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo", - createdBy, - ]); - return rows[0]; -} - -/** - * Restaura una versión anterior del prompt (crea nueva versión con el contenido antiguo) - */ -export async function rollbackPrompt({ tenantId, promptKey, toVersion, createdBy = null }) { - // Obtener la versión a restaurar - const oldVersion = await getPromptVersion({ tenantId, promptKey, version: toVersion }); - if (!oldVersion) { - throw new Error(`Version ${toVersion} not found for prompt ${promptKey}`); - } - - // Crear nueva versión con el contenido antiguo - return createPrompt({ - tenantId, - promptKey, - content: oldVersion.content, - model: oldVersion.model, - createdBy, - }); -} - -/** - * Resetea un prompt a su default (desactiva todas las versiones custom) - */ -export async function resetPromptToDefault({ tenantId, promptKey }) { - const sql = ` - UPDATE prompt_templates - SET is_active = false - WHERE tenant_id = $1 AND prompt_key = $2 - `; - await pool.query(sql, [tenantId, promptKey]); - return { success: true, message: `Prompt ${promptKey} reset to default` }; -} - -/** - * Elimina todas las versiones de un prompt (usar con cuidado) - */ -export async function deleteAllPromptVersions({ tenantId, promptKey }) { - const sql = ` - DELETE FROM prompt_templates - WHERE tenant_id = $1 AND prompt_key = $2 - RETURNING id - `; - const { rows } = await pool.query(sql, [tenantId, promptKey]); - return { deleted: rows.length }; -} - -/** - * Obtiene estadísticas de prompts de un tenant - */ -export async function getPromptStats({ tenantId }) { - const sql = ` - SELECT - prompt_key, - COUNT(*) as total_versions, - MAX(version) as latest_version, - MAX(CASE WHEN is_active THEN version END) as active_version, - MAX(created_at) as last_updated - FROM prompt_templates - WHERE tenant_id = $1 - GROUP BY prompt_key - ORDER BY prompt_key - `; - const { rows } = await pool.query(sql, [tenantId]); - return rows; -} diff --git a/src/modules/0-ui/handlers/prompts.js b/src/modules/0-ui/handlers/prompts.js deleted file mode 100644 index 659e087..0000000 --- a/src/modules/0-ui/handlers/prompts.js +++ /dev/null @@ -1,258 +0,0 @@ -import { - getActivePrompt, - listActivePrompts, - getPromptVersions, - getPromptVersion, - createPrompt, - rollbackPrompt, - resetPromptToDefault, - getPromptStats, - PROMPT_KEYS, - DEFAULT_MODELS, -} from "../db/promptsRepo.js"; -import { loadDefaultPrompt, AVAILABLE_VARIABLES, invalidatePromptCache } from "../../3-turn-engine/nlu/promptLoader.js"; - -/** - * Lista todos los prompts del tenant (activos + defaults para los que no tienen custom) - */ -export async function handleListPrompts({ tenantId }) { - const activePrompts = await listActivePrompts({ tenantId }); - const stats = await getPromptStats({ tenantId }); - - // Construir lista completa con defaults para los que no tienen custom - const promptsMap = new Map(activePrompts.map(p => [p.prompt_key, p])); - - const items = PROMPT_KEYS.map(key => { - const custom = promptsMap.get(key); - const stat = stats.find(s => s.prompt_key === key); - - if (custom) { - return { - prompt_key: key, - content: custom.content, - model: custom.model, - version: custom.version, - is_default: false, - total_versions: stat?.total_versions || 1, - last_updated: custom.created_at, - created_by: custom.created_by, - }; - } else { - // Cargar default - let defaultContent = ""; - try { - defaultContent = loadDefaultPrompt(key); - } catch (e) { - defaultContent = `[Error loading default: ${e.message}]`; - } - - return { - prompt_key: key, - content: defaultContent, - model: DEFAULT_MODELS[key] || "gpt-4-turbo", - version: null, - is_default: true, - total_versions: stat?.total_versions || 0, - last_updated: null, - created_by: null, - }; - } - }); - - return { - items, - available_variables: AVAILABLE_VARIABLES, - available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"], - }; -} - -/** - * Obtiene un prompt específico con su historial de versiones - */ -export async function handleGetPrompt({ tenantId, promptKey }) { - if (!PROMPT_KEYS.includes(promptKey)) { - throw new Error(`Invalid prompt_key: ${promptKey}`); - } - - const current = await getActivePrompt({ tenantId, promptKey }); - const versions = await getPromptVersions({ tenantId, promptKey, limit: 20 }); - - let defaultContent = ""; - try { - defaultContent = loadDefaultPrompt(promptKey); - } catch (e) { - defaultContent = `[Error: ${e.message}]`; - } - - return { - prompt_key: promptKey, - current: current || { - content: defaultContent, - model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo", - version: null, - is_default: true, - }, - default_content: defaultContent, - default_model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo", - versions: versions.map(v => ({ - version: v.version, - is_active: v.is_active, - created_at: v.created_at, - created_by: v.created_by, - content_preview: v.content.slice(0, 100) + (v.content.length > 100 ? "..." : ""), - })), - available_variables: AVAILABLE_VARIABLES, - available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"], - }; -} - -/** - * Crea o actualiza un prompt (crea nueva versión) - */ -export async function handleSavePrompt({ tenantId, promptKey, content, model, createdBy }) { - if (!PROMPT_KEYS.includes(promptKey)) { - throw new Error(`Invalid prompt_key: ${promptKey}`); - } - - if (!content || content.trim().length === 0) { - throw new Error("Content is required"); - } - - const result = await createPrompt({ - tenantId, - promptKey, - content: content.trim(), - model: model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo", - createdBy, - }); - - // Invalidar cache - invalidatePromptCache(tenantId, promptKey); - - return { - ok: true, - item: result, - message: `Prompt ${promptKey} saved as version ${result.version}`, - }; -} - -/** - * Restaura una versión anterior del prompt - */ -export async function handleRollbackPrompt({ tenantId, promptKey, toVersion, createdBy }) { - if (!PROMPT_KEYS.includes(promptKey)) { - throw new Error(`Invalid prompt_key: ${promptKey}`); - } - - const result = await rollbackPrompt({ - tenantId, - promptKey, - toVersion: parseInt(toVersion, 10), - createdBy, - }); - - // Invalidar cache - invalidatePromptCache(tenantId, promptKey); - - return { - ok: true, - item: result, - message: `Prompt ${promptKey} rolled back to version ${toVersion}, new version is ${result.version}`, - }; -} - -/** - * Resetea un prompt al default (desactiva todas las versiones custom) - */ -export async function handleResetPrompt({ tenantId, promptKey }) { - if (!PROMPT_KEYS.includes(promptKey)) { - throw new Error(`Invalid prompt_key: ${promptKey}`); - } - - await resetPromptToDefault({ tenantId, promptKey }); - - // Invalidar cache - invalidatePromptCache(tenantId, promptKey); - - return { - ok: true, - message: `Prompt ${promptKey} reset to default`, - }; -} - -/** - * Obtiene el contenido de una versión específica - */ -export async function handleGetPromptVersion({ tenantId, promptKey, version }) { - if (!PROMPT_KEYS.includes(promptKey)) { - throw new Error(`Invalid prompt_key: ${promptKey}`); - } - - const versionData = await getPromptVersion({ - tenantId, - promptKey, - version: parseInt(version, 10) - }); - - if (!versionData) { - throw new Error(`Version ${version} not found for prompt ${promptKey}`); - } - - return { item: versionData }; -} - -/** - * Prueba un prompt con un mensaje de ejemplo - */ -export async function handleTestPrompt({ tenantId, promptKey, content, testMessage, storeConfig = {} }) { - // Importar dinámicamente para evitar dependencias circulares - const { loadPrompt } = await import("../../3-turn-engine/nlu/promptLoader.js"); - - // Si se proporciona content, usarlo directamente - // Si no, cargar el prompt actual - let promptContent = content; - let model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo"; - - if (!promptContent) { - const loaded = await loadPrompt({ tenantId, promptKey, variables: storeConfig }); - promptContent = loaded.content; - model = loaded.model; - } else { - // Aplicar variables al content proporcionado - for (const [key, value] of Object.entries(storeConfig)) { - promptContent = promptContent.replace(new RegExp(`{{${key}}}`, "g"), value || ""); - } - promptContent = promptContent.replace(/\{\{[^}]+\}\}/g, ""); - } - - // Importar OpenAI - const OpenAI = (await import("openai")).default; - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY not configured"); - } - - const openai = new OpenAI({ apiKey }); - - // Hacer la llamada de prueba - const startTime = Date.now(); - const response = await openai.chat.completions.create({ - model, - temperature: 0.2, - max_tokens: 500, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: promptContent }, - { role: "user", content: testMessage }, - ], - }); - const endTime = Date.now(); - - return { - ok: true, - response: response?.choices?.[0]?.message?.content || "", - model, - usage: response?.usage, - latency_ms: endTime - startTime, - }; -} diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index 1dc2c35..38e3a58 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -10,12 +10,11 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js"; import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js"; import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js"; -import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, makeResetPrompt, makeGetPromptVersion, makeTestPrompt } from "../../0-ui/controllers/prompts.js"; +// Prompts CRUD removido: el agente nuevo usa un system prompt único hardcoded. import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js"; import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js"; import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js"; -import { getRewriterMetrics } from "../../3-turn-engine/replyRewriter.js"; import { getAgentMetrics } from "../../3-turn-engine/agent/runTurn.js"; function nowIso() { @@ -52,7 +51,6 @@ export function createSimulatorRouter({ tenantId }) { * --- UI data endpoints --- */ router.post("/sim/send", makeSimSend()); - router.get("/api/metrics/rewriter", (req, res) => res.json(getRewriterMetrics())); router.get("/api/metrics/agent", (req, res) => res.json(getAgentMetrics())); router.get("/conversations", makeGetConversations(getTenantId)); @@ -85,13 +83,8 @@ export function createSimulatorRouter({ tenantId }) { router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId)); // --- Prompts routes --- - router.get("/prompts", makeListPrompts(getTenantId)); - router.get("/prompts/:key", makeGetPrompt(getTenantId)); - router.post("/prompts/:key", makeSavePrompt(getTenantId)); - router.post("/prompts/:key/rollback/:version", makeRollbackPrompt(getTenantId)); - router.post("/prompts/:key/reset", makeResetPrompt(getTenantId)); - router.get("/prompts/:key/versions/:version", makeGetPromptVersion(getTenantId)); - router.post("/prompts/:key/test", makeTestPrompt(getTenantId)); + // /prompts/* removido tras flip a agente tool-calling. El system prompt + // único vive en src/modules/3-turn-engine/agent/systemPrompt.js. // --- Human Takeovers routes --- router.get("/takeovers", makeListPendingTakeovers(getTenantId)); diff --git a/src/modules/3-turn-engine/agent/runTurn.js b/src/modules/3-turn-engine/agent/runTurn.js index 4590c87..f519ce1 100644 --- a/src/modules/3-turn-engine/agent/runTurn.js +++ b/src/modules/3-turn-engine/agent/runTurn.js @@ -12,7 +12,7 @@ import { migrateOldContext, createEmptyOrder } from "../orderModel.js"; import { getStoreConfig } from "../../0-ui/db/settingsRepo.js"; import { ConversationState, safeNextState } from "../fsm.js"; import { buildWorkingMemory } from "./workingMemory.js"; -import { buildSystemPrompt } from "./systemPrompt.js"; +import { SYSTEM_PROMPT } from "./systemPrompt.js"; import { TOOL_SCHEMAS } from "./tools/schemas.js"; import { executeToolCall } from "./tools/executor.js"; import { getCustomerProfile } from "./customerProfile.js"; @@ -21,7 +21,9 @@ import { debug as dbg } from "../../shared/debug.js"; const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10); const TURN_TIMEOUT_MS = parseInt(process.env.AGENT_TURN_TIMEOUT_MS || "20000", 10); -// Métricas in-memory: turns/calls, fallback rate, escalation rate, avg duration. +// Métricas in-memory: turns/calls, fallback rate, escalation rate, avg duration, +// prompt-cache hit ratio (DeepSeek devuelve prompt_cache_hit_tokens / prompt_cache_miss_tokens +// en usage; campos OpenAI-style equivalentes "prompt_tokens_details.cached_tokens"). const _metrics = { turns: 0, total_tool_calls: 0, @@ -32,10 +34,14 @@ const _metrics = { escalations: 0, pauses: 0, orders_confirmed: 0, + prompt_tokens_total: 0, + prompt_cache_hit_tokens: 0, + completion_tokens_total: 0, }; export function getAgentMetrics() { const t = _metrics.turns; + const promptTotal = _metrics.prompt_tokens_total; return { turns: t, avg_tool_calls_per_turn: t ? +(_metrics.total_tool_calls / t).toFixed(2) : 0, @@ -46,6 +52,10 @@ export function getAgentMetrics() { escalations: _metrics.escalations, pauses: _metrics.pauses, orders_confirmed: _metrics.orders_confirmed, + prompt_tokens_total: promptTotal, + completion_tokens_total: _metrics.completion_tokens_total, + prompt_cache_hit_tokens: _metrics.prompt_cache_hit_tokens, + cache_hit_ratio: promptTotal ? +(_metrics.prompt_cache_hit_tokens / promptTotal).toFixed(3) : 0, }; } @@ -132,10 +142,10 @@ export async function runTurnAgent({ fsm_state: prev_state || "IDLE", }; - // Mensajes para el LLM - const systemPrompt = buildSystemPrompt({ storeName: storeConfig?.name }); + // Mensajes para el LLM. system prompt PRIMERO siempre + estático + // (clave para hit del prompt cache de DeepSeek/OpenAI). const messages = [ - { role: "system", content: systemPrompt }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: JSON.stringify({ working_memory: wm }) }, ]; @@ -166,6 +176,28 @@ export async function runTurnAgent({ "agent_llm" ); + // Capturar usage para métricas de prompt caching. + // DeepSeek: usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens. + // OpenAI compat: usage.prompt_tokens_details.cached_tokens. + const usage = resp?.usage || {}; + const promptTokens = usage.prompt_tokens || 0; + const completionTokens = usage.completion_tokens || 0; + const cachedTokens = + usage.prompt_cache_hit_tokens || + usage.prompt_tokens_details?.cached_tokens || + 0; + _metrics.prompt_tokens_total += promptTokens; + _metrics.completion_tokens_total += completionTokens; + _metrics.prompt_cache_hit_tokens += cachedTokens; + if (dbg.llm) { + console.log("[agent] llm.usage", { + prompt_tokens: promptTokens, + cached_tokens: cachedTokens, + completion_tokens: completionTokens, + cache_hit_ratio: promptTokens ? +(cachedTokens / promptTokens).toFixed(2) : 0, + }); + } + const msg = resp?.choices?.[0]?.message || {}; messages.push({ role: "assistant", content: msg.content || "", tool_calls: msg.tool_calls || [] }); diff --git a/src/modules/3-turn-engine/agent/systemPrompt.js b/src/modules/3-turn-engine/agent/systemPrompt.js index 959ceca..991e1cb 100644 --- a/src/modules/3-turn-engine/agent/systemPrompt.js +++ b/src/modules/3-turn-engine/agent/systemPrompt.js @@ -1,14 +1,18 @@ /** * System prompt del agente conversacional. * - * Se mantiene estático para aprovechar prompt caching. La parte dinámica - * (cart, pending, store, history, preparsed) va en el primer user message - * como JSON estructurado. + * 100% estático para que DeepSeek (y cualquier proveedor con prefix-cache) + * lo cachee turn a turn. La parte dinámica — incluyendo el nombre de la + * tienda — va SIEMPRE en el primer user message vía working_memory.store.name. + * + * Reglas para mantener el cache caliente: + * - NO interpolar variables aquí. Si querés cambiar tono por tenant, + * hacelo agregando una sección al user message, no al system. + * - Cualquier cambio al texto invalida el cache para todos los turnos. */ -export function buildSystemPrompt({ storeName = "la carnicería" } = {}) { - return `Sos Botino, el empleado virtual de ${storeName} (carnicería argentina). -Hablás como vendedor: cálido, breve, "vos", sin emojis, sin marketing. +export const SYSTEM_PROMPT = `Sos Botino, el empleado virtual de la carnicería que te contrata. +Hablás como vendedor argentino: cálido, breve, "vos", sin emojis, sin marketing. TU TRABAJO ES UNO SOLO: tomar pedidos por WhatsApp. 1. Entendés lo que pide el cliente y lo anotás en el carrito. @@ -73,4 +77,4 @@ LIMITES TÉCNICOS: LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES): - Lo que diga el bloque store del working_memory. - Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos.`; -} + diff --git a/src/modules/3-turn-engine/machine/actions.js b/src/modules/3-turn-engine/machine/actions.js deleted file mode 100644 index e157ac4..0000000 --- a/src/modules/3-turn-engine/machine/actions.js +++ /dev/null @@ -1,403 +0,0 @@ -/** - * Actions XState — mutaciones de context (assign builders) y emisores de - * efectos (que se vuelcan al "effect log" en context para que el runner - * los drene fuera de la machine). - * - * Reusa orderModel.js para todas las mutaciones del carrito — no duplica - * lógica. - */ - -import { assign } from "xstate"; -import { - PendingStatus, - moveReadyToCart, - getNextPendingItem, - updatePendingItem, - addPendingItem, - removeCartItem, - formatCartForDisplay, - formatOptionsForDisplay, - createPendingItem, -} from "../orderModel.js"; -import { - inferDefaultUnit, - parseIndexSelection, - findMatchingCandidate, - normalizeUnit, - unitAskFor, -} from "../stateHandlers/utils.js"; -import { renderReply, pushRecent } from "../replyTemplates.js"; -import { buildStoreContextVars } from "../storeContext.js"; -import { createPendingItemFromSearch } from "../stateHandlers/cartHelpers.js"; - -// ───────────────────────────────────────────────────────────── -// Helpers internos -// ───────────────────────────────────────────────────────────── - -function rewriteCtx(context) { - return { - conversation_history: context.conversation_history || [], - state: context.fsmState || null, - userText: context.userText || "", - }; -} - -function storeVars(context) { - return buildStoreContextVars(context.storeConfig || {}); -} - -// ───────────────────────────────────────────────────────────── -// Actions sincrónicas (assign) -// ───────────────────────────────────────────────────────────── - -export const setUserText = assign({ - userText: ({ event }) => event.text || event.userText || "", -}); - -export const recordReply = assign({ - last_reply: ({ event }) => event.output || null, - recent_replies: ({ context, event }) => { - const tid = event.output?.template_id; - return tid ? pushRecent(context.recent_replies || [], tid) : context.recent_replies || []; - }, -}); - -export const bumpFailedSearch = assign({ - failed_searches: ({ context, event }) => { - const cur = context.failed_searches || { count: 0 }; - return { - count: (cur.count || 0) + 1, - last_query: event.query || cur.last_query || null, - last_at: new Date().toISOString(), - }; - }, -}); - -export const resetFailedSearch = assign({ - failed_searches: () => ({ count: 0, last_query: null, last_at: null }), -}); - -export const addPendingFromCandidates = assign({ - order: ({ context, event }) => { - const results = event.output || []; - let order = context.order; - for (const r of results) { - const pending = createPendingItemFromSearch({ - query: r.query, - quantity: r.quantity, - unit: r.unit, - candidates: r.candidates, - }); - order = addPendingItem(order, pending); - } - return moveReadyToCart(order); - }, -}); - -export const moveReady = assign({ - order: ({ context }) => moveReadyToCart(context.order), -}); - -export const removeFromCart = assign({ - order: ({ context, event }) => { - const items = event.items || []; - let order = context.order; - for (const item of items) { - if (!item.product_query) continue; - const { order: next } = removeCartItem(order, item.product_query); - order = next; - } - return order; - }, -}); - -export const skipFirstPending = assign({ - order: ({ context }) => { - const next = getNextPendingItem(context.order); - if (!next) return context.order; - return { - ...context.order, - pending: (context.order.pending || []).filter((p) => p.id !== next.id), - }; - }, -}); - -export const selectByIndex = assign({ - order: ({ context, event }) => { - const text = String(event.text || ""); - const next = getNextPendingItem(context.order); - if (!next || next.status !== "NEEDS_TYPE") return context.order; - - const idx = parseIndexSelection(text); - const textMatch = !idx && next.candidates?.length > 0 - ? findMatchingCandidate(next.candidates, text) - : null; - const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null); - if (!effectiveIdx || effectiveIdx > (next.candidates?.length || 0)) return context.order; - - const selected = next.candidates[effectiveIdx - 1]; - const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); - const requestedQty = next.requested_qty; - const requestedUnit = next.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; - - return updatePendingItem(context.order, next.id, { - selected_woo_id: selected.woo_id, - selected_name: selected.name, - selected_price: selected.price, - selected_unit: displayUnit, - candidates: [], - status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - qty: needsQuantity ? null : finalQty, - unit: finalUnit, - }); - }, -}); - -export const setPendingQuantity = assign({ - order: ({ context, event }) => { - const next = getNextPendingItem(context.order); - if (!next || next.status !== "NEEDS_QUANTITY") return context.order; - const text = String(event.text || ""); - const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text); - if (!m) return context.order; - const qty = parseFloat(m[1].replace(",", ".")); - if (!Number.isFinite(qty) || qty <= 0) return context.order; - const unitFromText = m[2] ? normalizeUnit(m[2]) : null; - const finalUnit = unitFromText || next.selected_unit || "kg"; - return updatePendingItem(context.order, next.id, { - qty, - unit: finalUnit, - status: PendingStatus.READY, - }); - }, -}); - -export const setQuantityFromRule = assign({ - order: ({ context, event }) => { - // event.output: array de rules. event.params: { peopleCount } - const next = getNextPendingItem(context.order); - if (!next) return context.order; - const rules = event.output || []; - const peopleCount = context._peopleCount || 1; - const rule = rules.find((r) => r.event_type === "asado" && r.person_type === "adult") - || rules.find((r) => r.event_type === null && r.person_type === "adult") - || rules.find((r) => r.person_type === "adult") - || rules[0]; - - let calculatedQty; - let calculatedUnit = next.selected_unit || "kg"; - if (rule && rule.qty_per_person > 0) { - calculatedQty = rule.qty_per_person * peopleCount; - calculatedUnit = rule.unit || calculatedUnit; - } else { - const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; - calculatedQty = fallbackPerPerson * peopleCount; - } - if (calculatedUnit === "unit") calculatedQty = Math.ceil(calculatedQty); - else calculatedQty = Math.round(calculatedQty * 10) / 10; - - return updatePendingItem(context.order, next.id, { - qty: calculatedQty, - unit: calculatedUnit, - status: PendingStatus.READY, - }); - }, -}); - -export const capturePeopleCount = assign({ - _peopleCount: ({ event }) => { - const text = String(event.text || ""); - const m = /(?: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); - return m ? parseInt(m[1], 10) : 1; - }, -}); - -export const setShipping = assign({ - order: ({ context, event }) => { - const method = event.method; - if (method !== "delivery" && method !== "pickup") return context.order; - return { ...context.order, is_delivery: method === "delivery" }; - }, -}); - -export const setAddress = assign({ - order: ({ context, event }) => { - const addr = event.address || event.text || ""; - if (!addr || addr.length < 5) return context.order; - return { ...context.order, shipping_address: String(addr).trim() }; - }, -}); - -export const enqueueWooCreateOrder = assign({ - pending_actions: ({ context }) => [ - ...(context.pending_actions || []), - { type: "create_order", payload: { source: "wa_bot" } }, - ], -}); - -export const enqueueAddToCart = assign({ - pending_actions: ({ context }) => { - const last = (context.order?.cart || []).slice(-1)[0]; - return [...(context.pending_actions || []), { type: "add_to_cart", payload: last || {} }]; - }, -}); - -export const enqueueRemoveFromCart = assign({ - pending_actions: ({ context, event }) => [ - ...(context.pending_actions || []), - { type: "remove_from_cart", payload: { items: event.items || [] } }, - ], -}); - -/** - * Absorbe el resultado del recommendActor: reply via rawText (ya viene - * formateado por handleRecommend), merge de order y enqueue de actions. - */ -export const ingestRecommendResult = assign({ - pending_reply: ({ event }) => { - const reply = event.output?.plan?.reply; - return reply ? { rawText: reply } : null; - }, - order: ({ context, event }) => event.output?.decision?.order || context.order, - pending_actions: ({ context, event }) => { - const incoming = event.output?.decision?.actions || []; - if (!incoming.length) return context.pending_actions || []; - return [...(context.pending_actions || []), ...incoming]; - }, -}); - -// ───────────────────────────────────────────────────────────── -// Async reply renderers (entry actions que producen reply async) -// ───────────────────────────────────────────────────────────── - -/** - * Helper que renderiza un reply y devuelve { reply, template_id } — - * el caller debe asignar a context.last_reply. - */ -async function _render(context, templateKey, vars = {}) { - const merged = { ...storeVars(context), ...vars }; - return await renderReply({ - tenantId: context.tenantId, - templateKey, - vars: merged, - recentReplies: context.recent_replies || [], - ...rewriteCtx(context), - }); -} - -/** - * Las "reply actions" no pueden ser async dentro de XState v5 directamente, - * así que las modelamos como side-effects que el runner ejecuta DESPUÉS - * de que la máquina settle, leyendo context.pending_reply (un descriptor). - * - * Cada estado que emite respuesta hace `assign({ pending_reply: { templateKey, vars } })` - * en su entry. El runner traduce eso a renderReply real. - */ - -function makeReplyAction(templateKey, varsBuilder = null) { - return assign({ - pending_reply: ({ context, event }) => ({ - templateKey, - vars: varsBuilder ? varsBuilder({ context, event }) : {}, - }), - }); -} - -export const replyIdleGreeting = makeReplyAction("idle.greeting"); -export const replyIdleHelp = makeReplyAction("idle.help_prompt"); -export const replyAskMore = makeReplyAction("cart.ask_more"); -export const replyEmptyCart = makeReplyAction("cart.empty_prompt"); -export const replyNotFound = makeReplyAction("cart.not_found", ({ event, context }) => ({ - query: event.query || context.failed_searches?.last_query || "", -})); -export const replyDidntUnderstand = makeReplyAction("cart.didnt_understand"); -export const replySkipAck = makeReplyAction("cart.skip_acknowledged"); -export const replyConfirmToShipping = makeReplyAction("cart.confirm_to_shipping"); -export const replyPendingBeforeClose = makeReplyAction("cart.pending_before_close"); -export const replyAskWhatProduct = makeReplyAction("cart.ask_what_product"); -export const replyAddedConfirm = makeReplyAction("cart.added_confirm", ({ context }) => { - const last = (context.order?.cart || []).slice(-1)[0]; - if (!last) return { summary: "" }; - const qtyStr = last.unit === "unit" ? last.qty : `${last.qty}${last.unit}`; - return { summary: `${qtyStr} de ${last.name}` }; -}); -export const replyShippingAskMethod = makeReplyAction("shipping.ask_method"); -export const replyShippingAskAddress = makeReplyAction("shipping.ask_address"); -export const replyShippingAddressRecorded = makeReplyAction("shipping.address_recorded", ({ context }) => ({ - address: context.order?.shipping_address || "", -})); -export const replyOrderConfirmed = makeReplyAction("order.confirmed"); - -// View cart: necesita armar reply con cartDisplay + ask_more -export const replyViewCart = assign({ - pending_reply: ({ context }) => ({ - templateKey: "cart.ask_more", - prefix: formatCartForDisplay(context.order), - }), -}); - -// Show options del primer pending -export const replyOptions = assign({ - pending_reply: ({ context }) => { - const next = getNextPendingItem(context.order); - if (!next) return null; - const { question } = formatOptionsForDisplay(next); - return { rawText: question }; - }, -}); - -// Ask quantity (data-driven, no template) -export const replyAskQuantity = assign({ - pending_reply: ({ context }) => { - const next = getNextPendingItem(context.order); - if (!next) return null; - const unitQuestion = unitAskFor(next.selected_unit || "kg"); - return { rawText: `Para ${next.selected_name || next.query}, ${unitQuestion}` }; - }, -}); - -export const actions = { - setUserText, - recordReply, - bumpFailedSearch, - resetFailedSearch, - addPendingFromCandidates, - moveReady, - removeFromCart, - skipFirstPending, - selectByIndex, - setPendingQuantity, - setQuantityFromRule, - capturePeopleCount, - setShipping, - setAddress, - enqueueWooCreateOrder, - enqueueAddToCart, - enqueueRemoveFromCart, - ingestRecommendResult, - replyIdleGreeting, - replyIdleHelp, - replyAskMore, - replyEmptyCart, - replyNotFound, - replyDidntUnderstand, - replySkipAck, - replyConfirmToShipping, - replyPendingBeforeClose, - replyAskWhatProduct, - replyAddedConfirm, - replyShippingAskMethod, - replyShippingAskAddress, - replyShippingAddressRecorded, - replyOrderConfirmed, - replyViewCart, - replyOptions, - replyAskQuantity, -}; diff --git a/src/modules/3-turn-engine/machine/actors.js b/src/modules/3-turn-engine/machine/actors.js deleted file mode 100644 index 95b2012..0000000 --- a/src/modules/3-turn-engine/machine/actors.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Actores XState (fromPromise) — wrappers de side effects async. - * La machine es pura; estos actores aíslan llamadas a DB / WooCommerce / LLM. - */ - -import { fromPromise } from "xstate"; -import { retrieveCandidates } from "../catalogRetrieval.js"; -import { getProductQtyRules } from "../../0-ui/db/repo.js"; -import { handleRecommend } from "../recommendations.js"; - -/** - * Busca candidatos para una lista de queries de producto. - * Input: { tenantId, items: [{product_query, quantity, unit}, ...] } - * Output: array paralelo a items con { query, quantity, unit, candidates } - */ -export const searchCatalogActor = fromPromise(async ({ input }) => { - const { tenantId, items = [] } = input || {}; - const results = []; - for (const it of items) { - if (!it?.product_query) continue; - const r = await retrieveCandidates({ tenantId, query: it.product_query, limit: 20 }); - results.push({ - query: it.product_query, - quantity: it.quantity ?? null, - unit: it.unit ?? null, - candidates: r?.candidates || [], - }); - } - return results; -}); - -/** - * Lookup de qty rules para un producto. - * Input: { tenantId, wooProductId } - * Output: array de rules - */ -export const getQtyRulesActor = fromPromise(async ({ input }) => { - const { tenantId, wooProductId } = input || {}; - if (!tenantId || !wooProductId) return []; - return await getProductQtyRules({ tenantId, wooProductId }); -}); - -/** - * Recomendación (cross-sell o planificación). Devuelve `{ plan, decision }` - * con shape compatible con el dispatcher legacy. - */ -export const recommendActor = fromPromise(async ({ input }) => { - const { tenantId, text, nlu, order } = input || {}; - return await handleRecommend({ - tenantId, - text, - nlu, - order, - prevContext: { order }, - audit: {}, - }); -}); - -export const actors = { - searchCatalogActor, - getQtyRulesActor, - recommendActor, -}; diff --git a/src/modules/3-turn-engine/machine/e2e.test.js b/src/modules/3-turn-engine/machine/e2e.test.js deleted file mode 100644 index 339a2da..0000000 --- a/src/modules/3-turn-engine/machine/e2e.test.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * E2E test del flow completo desde NLU output hasta serialización del - * snapshot, sin tocar DB real (mocks) ni LLM real. - * - * Cubre el camino feliz: hola → add → confirm → shipping pickup → IDLE - * con la orden creada. También valida snapshot persist/restore. - */ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { createActor } from "xstate"; - -// Mock pool de DB para que reply_templates devuelva [] (cae a DEFAULTS) -vi.mock("../../shared/db/pool.js", () => ({ - pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, -})); - -vi.mock("../replyRewriter.js", () => ({ - rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })), -})); - -vi.mock("../catalogRetrieval.js", () => ({ - retrieveCandidates: vi.fn(async ({ query }) => ({ - candidates: query === "chorizo" - ? [{ woo_product_id: 100, name: "Chorizo Parrillero", price: 1500, sell_unit: "kg", _score: 1.0 }] - : query === "vacio" - ? [{ woo_product_id: 200, name: "Vacío", price: 4500, sell_unit: "kg", _score: 1.0 }] - : [], - audit: {}, - })), -})); - -vi.mock("../../0-ui/db/repo.js", () => ({ - getProductQtyRules: vi.fn(async () => []), -})); - -import { machine } from "./index.js"; - -const TENANT = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; - -function makeActor(input = {}) { - return createActor(machine, { - input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {}, ...input }, - }); -} - -async function settle(actor) { - for (let i = 0; i < 100; i++) { - const snap = actor.getSnapshot(); - const children = Object.values(snap.children || {}); - const running = children.some((c) => { - try { return c.getSnapshot()?.status === "active"; } catch { return false; } - }); - if (!running) return snap; - await new Promise((r) => setTimeout(r, 5)); - } - return actor.getSnapshot(); -} - -describe("E2E — golden flow pickup", () => { - it("hola → add chorizo (qty+unit) → confirm → pickup → idle con create_order", async () => { - const a = makeActor(); - a.start(); - - // Greeting - a.send({ type: "GREETING" }); - expect(a.getSnapshot().value).toBe("idle"); - expect(a.getSnapshot().context.pending_reply).toMatchObject({ templateKey: "idle.greeting" }); - - // Add chorizo con qty/unit completos → strong-match → ready - a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" }); - await settle(a); - let snap = a.getSnapshot(); - expect(snap.context.order.cart).toHaveLength(1); - expect(snap.context.order.cart[0].name).toBe("Chorizo Parrillero"); - - // Confirm - a.send({ type: "CONFIRM_ORDER" }); - expect(a.getSnapshot().value).toBe("shipping"); - - // Pickup → IDLE con create_order - a.send({ type: "SELECT_SHIPPING", method: "pickup" }); - snap = a.getSnapshot(); - expect(snap.value).toBe("idle"); - expect(snap.context.order.is_delivery).toBe(false); - expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); - expect(snap.context.pending_reply?.templateKey).toBe("order.confirmed"); - - a.stop(); - }); -}); - -describe("E2E — golden flow delivery con address en zona", () => { - it("flow delivery con dirección en Palermo se confirma", async () => { - const a = makeActor({ - storeConfig: { delivery_zones: { caba: { barrios: ["Palermo", "Belgrano"] } } }, - }); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" }); - await settle(a); - a.send({ type: "CONFIRM_ORDER" }); - a.send({ type: "SELECT_SHIPPING", method: "delivery" }); - expect(a.getSnapshot().value).toBe("shipping"); - a.send({ type: "PROVIDE_ADDRESS", address: "Av Santa Fe 3000 Palermo" }); - const snap = a.getSnapshot(); - expect(snap.value).toBe("idle"); - expect(snap.context.order.shipping_address).toMatch(/Palermo/); - expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); - a.stop(); - }); -}); - -describe("E2E — snapshot rehydrate full flow", () => { - it("persiste snapshot a mitad de flow, hidrata, completa", async () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" }); - await settle(a); - a.send({ type: "CONFIRM_ORDER" }); - expect(a.getSnapshot().value).toBe("shipping"); - const persisted = a.getPersistedSnapshot(); - a.stop(); - - // Re-hidrato y completo - const b = createActor(machine, { - snapshot: persisted, - input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {} }, - }); - b.start(); - expect(b.getSnapshot().value).toBe("shipping"); - b.send({ type: "SELECT_SHIPPING", method: "pickup" }); - const snap = b.getSnapshot(); - expect(snap.value).toBe("idle"); - expect(snap.context.order.is_delivery).toBe(false); - expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); - b.stop(); - }); -}); - -describe("E2E — universal cart-on-add desde shipping", () => { - it("desde shipping, add_to_cart vuelve a cart.searching", async () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg chorizo" }); - await settle(a); - a.send({ type: "CONFIRM_ORDER" }); - expect(a.getSnapshot().value).toBe("shipping"); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" }); - expect(a.getSnapshot().value).toEqual({ cart: "searching" }); - await settle(a); - expect(a.getSnapshot().context.order.cart.length).toBe(2); - a.stop(); - }); -}); diff --git a/src/modules/3-turn-engine/machine/guards.js b/src/modules/3-turn-engine/machine/guards.js deleted file mode 100644 index 26454b0..0000000 --- a/src/modules/3-turn-engine/machine/guards.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Guards XState — predicados puros sobre context+event. - * Portados desde fsm.js manteniendo semántica idéntica. - */ - -import { - hasCartItems as hasCart, - hasPendingItems as hasPending, - hasReadyPendingItems as hasReadyPending, - hasShippingInfo as hasShipping, -} from "../fsm.js"; -import { - parseIndexSelection, - isShowMoreRequest, - isShowOptionsRequest, -} from "../stateHandlers/utils.js"; - -const ESCAPE_CANCEL_RE = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i; - -export const guards = { - hasCart: ({ context }) => hasCart(context.order), - hasPending: ({ context }) => hasPending(context.order), - hasReadyPending: ({ context }) => hasReadyPending(context.order), - hasShipping: ({ context }) => hasShipping(context.order), - - noCart: ({ context }) => !hasCart(context.order), - noShipping: ({ context }) => !hasShipping(context.order), - - // Universal "return to cart": el usuario quiere agregar productos desde un estado != IDLE/CART. - // Replica shouldReturnToCart de fsm.js. - wantsToAddProduct: ({ context, event }) => { - if (event.type !== "ADD_TO_CART" && event.type !== "BROWSE" && event.type !== "PRICE_QUERY") return false; - // Verificar que tiene un item real - const items = event.items || []; - return items.some((i) => String(i?.product_query || "").trim().length > 2); - }, - - // En checkout, "2" es selección de opción, no producto. - isCheckoutNumberOnly: ({ event }) => { - const text = String(event.text || event.userText || "").trim(); - return /^\s*\d+([.,]\d+)?\s*$/.test(text); - }, - - hasItems: ({ event }) => Array.isArray(event.items) && event.items.length > 0, - - isCancelText: ({ event }) => ESCAPE_CANCEL_RE.test(String(event.text || "")), - - isIndexSelection: ({ event }) => parseIndexSelection(String(event.text || "")) !== null, - - isShowMore: ({ event }) => { - const t = String(event.text || ""); - return isShowMoreRequest(t) || isShowOptionsRequest(t); - }, - - isTextRefinement: ({ event, context }) => { - const t = String(event.text || "").trim(); - if (parseIndexSelection(t) !== null) return false; - if (isShowMoreRequest(t) || isShowOptionsRequest(t)) return false; - return t.length > 2; - }, - - // Pending item inspections - pendingNeedsType: ({ context }) => { - const next = (context.order?.pending || []).find( - (p) => p.status === "NEEDS_TYPE" || p.status === "NEEDS_QUANTITY" - ); - return next?.status === "NEEDS_TYPE"; - }, - - pendingNeedsQuantity: ({ context }) => { - const next = (context.order?.pending || []).find( - (p) => p.status === "NEEDS_TYPE" || p.status === "NEEDS_QUANTITY" - ); - return next?.status === "NEEDS_QUANTITY"; - }, - - isPersonasInput: ({ event }) => { - const t = String(event.text || ""); - return /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.test(t) - || /\bpara\s+(\d+)\b/i.test(t) - || /\bcomo\s+para\s+(\d+)\b/i.test(t); - }, - - isQuantityInput: ({ event, context }) => { - const t = String(event.text || ""); - return /\d+(?:[.,]\d+)?\s*(?:kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.test(t); - }, -}; diff --git a/src/modules/3-turn-engine/machine/index.js b/src/modules/3-turn-engine/machine/index.js deleted file mode 100644 index 730ec81..0000000 --- a/src/modules/3-turn-engine/machine/index.js +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Botino conversation machine (XState v5). - * - * Reemplaza el dispatcher en turnEngineV3.js + stateHandlers/* con un - * statechart formal. La API externa queda igual: el runner consume el - * snapshot tras settle y emite { plan, decision } compatible con pipeline.js. - * - * Top-level: idle → cart → shipping → payment → waiting → idle. - * `cart` es un sub-statechart que maneja el flujo multi-turno de pending items - * (NEEDS_TYPE → NEEDS_QUANTITY → READY). - * - * Replies se modelan como entry actions que escriben a `context.pending_reply` - * (descriptor). El runner las traduce a texto via renderReply *después* del - * settle — esto evita awaits dentro de la machine. - */ - -import { setup } from "xstate"; -import { guards } from "./guards.js"; -import { actions } from "./actions.js"; -import { actors } from "./actors.js"; -import { createEmptyOrder } from "../orderModel.js"; - -export const ConversationStates = Object.freeze({ - IDLE: "idle", - CART: "cart", - SHIPPING: "shipping", - AWAITING_HUMAN: "awaiting_human", -}); - -export const machine = setup({ - types: { - context: {}, - events: {}, - }, - guards, - actions, - actors, -}).createMachine({ - id: "botino", - initial: ConversationStates.IDLE, - context: ({ input }) => ({ - tenantId: input?.tenantId || null, - chat_id: input?.chat_id || null, - storeConfig: input?.storeConfig || {}, - order: input?.initialOrder || createEmptyOrder(), - recent_replies: input?.recentReplies || [], - failed_searches: input?.failedSearches || { count: 0, last_query: null, last_at: null }, - conversation_history: input?.conversation_history || [], - userText: "", - last_reply: null, - pending_reply: null, - pending_actions: [], - fsmState: "IDLE", - _peopleCount: null, - }), - // Universal: si el usuario quiere agregar producto desde cualquier lado, va a cart. - on: { - ADD_TO_CART: { - guard: "wantsToAddProduct", - actions: "setUserText", - target: `.${ConversationStates.CART}.searching`, - }, - BROWSE: { - guard: "wantsToAddProduct", - actions: "setUserText", - target: `.${ConversationStates.CART}.searching`, - }, - }, - states: { - // ───────────────────────────────────────────────────────── - [ConversationStates.IDLE]: { - entry: ["resetFailedSearch"], - on: { - GREETING: { actions: ["replyIdleGreeting"], target: ConversationStates.IDLE, reenter: false }, - ADD_TO_CART: { - guard: "wantsToAddProduct", - actions: "setUserText", - target: `${ConversationStates.CART}.searching`, - }, - BROWSE: { - guard: "wantsToAddProduct", - actions: "setUserText", - target: `${ConversationStates.CART}.searching`, - }, - PRICE_QUERY: { actions: "setUserText", target: `${ConversationStates.CART}.pricing` }, - RECOMMEND: { actions: "setUserText", target: `${ConversationStates.CART}.recommending` }, - VIEW_CART: { target: `${ConversationStates.CART}.showing` }, - CONFIRM_ORDER: { actions: "replyEmptyCart" }, - OTHER: { actions: "replyIdleHelp" }, - }, - }, - - // ───────────────────────────────────────────────────────── - [ConversationStates.CART]: { - initial: "idle", - on: { - VIEW_CART: ".showing", - REMOVE_FROM_CART: { - actions: ["removeFromCart", "enqueueRemoveFromCart"], - target: ".showing", - }, - CONFIRM_ORDER: [ - { - guard: "hasPending", - target: ".askingClarification", - }, - { - guard: "hasCart", - actions: "replyConfirmToShipping", - target: `#botino.${ConversationStates.SHIPPING}`, - }, - { - actions: "replyEmptyCart", - target: ".idle", - }, - ], - PRICE_QUERY: { actions: "setUserText", target: ".pricing" }, - RECOMMEND: { actions: "setUserText", target: ".recommending" }, - GREETING: { actions: "replyIdleGreeting", target: ".idle" }, - }, - states: { - idle: { - // Reposo del cart, esperando próximo evento - }, - - searching: { - invoke: { - src: "searchCatalogActor", - input: ({ context, event }) => ({ - tenantId: context.tenantId, - items: event.items || [], - }), - onDone: { - actions: "addPendingFromCandidates", - target: "resolving", - }, - onError: { - actions: "replyDidntUnderstand", - target: "idle", - }, - }, - }, - - resolving: { - // moveReady ya fue aplicado en addPendingFromCandidates - always: [ - { guard: "pendingNeedsType", target: "askingClarification" }, - { guard: "pendingNeedsQuantity", target: "askingQuantity" }, - { target: "added" }, - ], - }, - - askingClarification: { - entry: ["replyOptions"], - on: { - OTHER: [ - { - guard: "isCancelText", - actions: ["skipFirstPending", "replySkipAck"], - target: "resolving", - }, - { - guard: "isShowMore", - target: "askingClarification", - reenter: true, - }, - { - guard: "isIndexSelection", - actions: ["selectByIndex"], - target: "resolving", - }, - { - guard: "isTextRefinement", - actions: ["setUserText"], - target: "researching", - }, - { - actions: ["replyDidntUnderstand"], - }, - ], - }, - }, - - researching: { - invoke: { - src: "searchCatalogActor", - input: ({ context }) => ({ - tenantId: context.tenantId, - items: [{ product_query: context.userText, quantity: null, unit: null }], - }), - onDone: { - actions: "addPendingFromCandidates", - target: "resolving", - }, - onError: { - actions: ["bumpFailedSearch", "replyDidntUnderstand"], - target: "askingClarification", - }, - }, - }, - - askingQuantity: { - entry: ["replyAskQuantity"], - on: { - OTHER: [ - { - guard: "isPersonasInput", - actions: ["capturePeopleCount"], - target: "computingFromPersonas", - }, - { - guard: "isQuantityInput", - actions: ["setPendingQuantity"], - target: "resolving", - }, - { - actions: ["replyDidntUnderstand"], - }, - ], - }, - }, - - computingFromPersonas: { - invoke: { - src: "getQtyRulesActor", - input: ({ context }) => { - const next = (context.order?.pending || []).find( - (p) => p.status === "NEEDS_TYPE" || p.status === "NEEDS_QUANTITY" - ); - return { - tenantId: context.tenantId, - wooProductId: next?.selected_woo_id, - }; - }, - onDone: { - actions: "setQuantityFromRule", - target: "resolving", - }, - onError: { - actions: ["replyDidntUnderstand"], - target: "askingQuantity", - }, - }, - }, - - added: { - entry: ["replyAddedConfirm", "enqueueAddToCart", "resetFailedSearch"], - always: "idle", - }, - - showing: { - entry: ["replyViewCart"], - always: "idle", - }, - - recommending: { - invoke: { - src: "recommendActor", - input: ({ context, event }) => ({ - tenantId: context.tenantId, - text: event.text || context.userText, - nlu: event.nlu || null, - order: context.order, - }), - onDone: { - actions: "ingestRecommendResult", - target: "idle", - }, - onError: { - actions: "replyDidntUnderstand", - target: "idle", - }, - }, - }, - - pricing: { - // Para v1, pricing reusa el flow de searching y muestra resultados. - // Una iteración futura podría tener un actor separado para no agregar al carrito. - invoke: { - src: "searchCatalogActor", - input: ({ context, event }) => ({ - tenantId: context.tenantId, - items: event.items || [], - }), - onDone: [ - { - guard: ({ event }) => (event.output || []).every((r) => (r.candidates || []).length === 0), - actions: ["bumpFailedSearch", "replyNotFound"], - target: "idle", - }, - { - actions: "addPendingFromCandidates", - target: "resolving", - }, - ], - onError: { - actions: ["replyDidntUnderstand"], - target: "idle", - }, - }, - }, - }, - }, - - // ───────────────────────────────────────────────────────── - [ConversationStates.SHIPPING]: { - entry: ({ context }) => { context.fsmState = "SHIPPING"; }, - on: { - SELECT_SHIPPING: [ - { - guard: ({ event }) => event.method === "pickup", - actions: ["setShipping", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"], - target: ConversationStates.IDLE, - }, - { - guard: ({ event }) => event.method === "delivery", - actions: ["setShipping", "replyShippingAskAddress"], - }, - { - actions: ["replyShippingAskMethod"], - }, - ], - PROVIDE_ADDRESS: [ - { - guard: ({ context }) => context.order?.is_delivery === true, - actions: ["setAddress", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"], - target: ConversationStates.IDLE, - }, - { - actions: ["replyShippingAskMethod"], - }, - ], - VIEW_CART: { actions: "replyShippingAskMethod" }, - OTHER: { actions: "replyShippingAskMethod" }, - }, - }, - - // ───────────────────────────────────────────────────────── - [ConversationStates.AWAITING_HUMAN]: { - // Estado terminal hasta que un humano resuelva. No emite reply propio. - }, - }, -}); - -/** - * Map XState state value → legacy state string esperado por pipeline. - */ -export function xstateToLegacyState(value) { - if (typeof value === "string") { - if (value === "idle") return "IDLE"; - if (value === "shipping") return "SHIPPING"; - if (value === "awaiting_human") return "AWAITING_HUMAN"; - } - if (value && typeof value === "object") { - if (value.cart) return "CART"; - if (value.shipping) return "SHIPPING"; - } - return "IDLE"; -} diff --git a/src/modules/3-turn-engine/machine/index.test.js b/src/modules/3-turn-engine/machine/index.test.js deleted file mode 100644 index 61e4392..0000000 --- a/src/modules/3-turn-engine/machine/index.test.js +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createActor } from "xstate"; - -// Mock pool de DB para que aliases / store / qty rules respondan vacío -vi.mock("../../shared/db/pool.js", () => ({ - pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, -})); - -// Mock el rewriter (no usar LLM en tests) -vi.mock("../replyRewriter.js", () => ({ - rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })), -})); - -// Mock catalogRetrieval para evitar dependencia de DB / Woo -vi.mock("../catalogRetrieval.js", () => ({ - retrieveCandidates: vi.fn(async ({ query }) => ({ - candidates: query === "chorizo" - ? [{ woo_product_id: 100, name: "Chorizo Parrillero", price: 1500, sell_unit: "kg", _score: 1.0 }] - : query === "asado" - ? [ - { woo_product_id: 200, name: "Asado de tira", price: 2000, sell_unit: "kg", _score: 0.85 }, - { woo_product_id: 201, name: "Asado banderita", price: 2200, sell_unit: "kg", _score: 0.8 }, - ] - : [], - audit: {}, - })), -})); - -vi.mock("../../0-ui/db/repo.js", () => ({ - getProductQtyRules: vi.fn(async () => []), -})); - -import { machine, xstateToLegacyState } from "./index.js"; - -const TENANT = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; - -function makeActor(input = {}) { - return createActor(machine, { - input: { tenantId: TENANT, chat_id: "t1", storeConfig: {}, ...input }, - }); -} - -async function settle(actor) { - // Espera a que actores invocados terminen (los onDone disparan transiciones). - for (let i = 0; i < 50; i++) { - const snap = actor.getSnapshot(); - const children = Object.values(snap.children || {}); - const running = children.some((c) => { - try { - return c.getSnapshot()?.status === "active"; - } catch { - return false; - } - }); - if (!running) return snap; - await new Promise((r) => setTimeout(r, 5)); - } - return actor.getSnapshot(); -} - -describe("machine — initial state", () => { - it("starts in idle", () => { - const a = makeActor(); - a.start(); - expect(a.getSnapshot().value).toBe("idle"); - a.stop(); - }); - - it("greeting in idle stays in idle and emits idle.greeting reply", () => { - const a = makeActor(); - a.start(); - a.send({ type: "GREETING" }); - const snap = a.getSnapshot(); - expect(snap.value).toBe("idle"); - expect(snap.context.pending_reply).toMatchObject({ templateKey: "idle.greeting" }); - a.stop(); - }); -}); - -describe("machine — universal cart-on-add rule", () => { - it("ADD_TO_CART from idle goes to cart.searching", () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo" }], text: "chorizo" }); - expect(a.getSnapshot().value).toEqual({ cart: "searching" }); - a.stop(); - }); - - it("ADD_TO_CART from shipping returns to cart (universal rule)", async () => { - const a = makeActor(); - a.start(); - // forzar shipping con qty+unit completos para que strong-match resuelva READY - a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg chorizo" }); - await settle(a); - a.send({ type: "CONFIRM_ORDER" }); - expect(a.getSnapshot().value).toBe("shipping"); - // ahora desde shipping pide otro producto - a.send({ type: "ADD_TO_CART", items: [{ product_query: "asado" }], text: "asado" }); - expect(a.getSnapshot().value).toEqual({ cart: "searching" }); - a.stop(); - }); - - it("ADD_TO_CART without real product does NOT redirect", () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [], text: "" }); - // No items reales → guard wantsToAddProduct rechaza, queda en idle - expect(a.getSnapshot().value).toBe("idle"); - a.stop(); - }); -}); - -describe("machine — cart flow", () => { - it("strong-match product goes searching → resolving → askingQuantity", async () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo" }], text: "1 chorizo" }); - const snap = await settle(a); - // chorizo resuelve a 1 candidato (strong) sin qty → askingQuantity (vende por kg) - expect(["askingQuantity", "added"]).toContain(snap.value.cart); - a.stop(); - }); - - it("multi-match product goes searching → resolving → askingClarification", async () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "asado" }], text: "asado" }); - const snap = await settle(a); - expect(snap.value).toEqual({ cart: "askingClarification" }); - expect(snap.context.pending_reply?.rawText).toMatch(/asado/i); - a.stop(); - }); - - it("index selection in askingClarification advances", async () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "asado" }], text: "asado" }); - await settle(a); - a.send({ type: "OTHER", text: "1" }); - const after = await settle(a); - // Después de seleccionar 1 (Asado de tira, kg), debe ir a askingQuantity - expect(["askingQuantity", "added"]).toContain(after.value.cart); - a.stop(); - }); -}); - -describe("machine — checkout flow", () => { - async function buildCartWithItem(actor) { - actor.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" }); - await settle(actor); - } - - it("CONFIRM_ORDER with cart goes to shipping", async () => { - const a = makeActor(); - a.start(); - await buildCartWithItem(a); - a.send({ type: "CONFIRM_ORDER" }); - expect(a.getSnapshot().value).toBe("shipping"); - a.stop(); - }); - - it("CONFIRM_ORDER with empty cart shows empty prompt", () => { - const a = makeActor(); - a.start(); - a.send({ type: "CONFIRM_ORDER" }); - const snap = a.getSnapshot(); - expect(snap.context.pending_reply?.templateKey).toBe("cart.empty_prompt"); - a.stop(); - }); - - it("SELECT_SHIPPING pickup cierra la orden y vuelve a IDLE", async () => { - const a = makeActor(); - a.start(); - await buildCartWithItem(a); - a.send({ type: "CONFIRM_ORDER" }); - a.send({ type: "SELECT_SHIPPING", method: "pickup" }); - const snap = a.getSnapshot(); - expect(snap.value).toBe("idle"); - expect(snap.context.order.is_delivery).toBe(false); - expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); - a.stop(); - }); - - it("SELECT_SHIPPING delivery + PROVIDE_ADDRESS cierra y vuelve a IDLE", async () => { - const a = makeActor(); - a.start(); - await buildCartWithItem(a); - a.send({ type: "CONFIRM_ORDER" }); - a.send({ type: "SELECT_SHIPPING", method: "delivery" }); - expect(a.getSnapshot().value).toBe("shipping"); - a.send({ type: "PROVIDE_ADDRESS", address: "Corrientes 1234" }); - const snap = a.getSnapshot(); - expect(snap.value).toBe("idle"); - expect(snap.context.order.shipping_address).toBe("Corrientes 1234"); - expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); - a.stop(); - }); -}); - -describe("machine — snapshot persistence", () => { - it("rehydrates from getPersistedSnapshot preserving order state", async () => { - const a = makeActor(); - a.start(); - a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" }); - await settle(a); - const persisted = a.getPersistedSnapshot(); - a.stop(); - - // boot another actor from the same snapshot - const b = createActor(machine, { - snapshot: persisted, - input: { tenantId: TENANT, chat_id: "t1", storeConfig: {} }, - }); - b.start(); - const snap = b.getSnapshot(); - expect(snap.context.order.cart.length).toBeGreaterThan(0); - expect(snap.context.order.cart[0].name).toMatch(/Chorizo/i); - b.stop(); - }); -}); - -describe("xstateToLegacyState", () => { - it("maps top-level idle/shipping (sin payment/waiting)", () => { - expect(xstateToLegacyState("idle")).toBe("IDLE"); - expect(xstateToLegacyState("shipping")).toBe("SHIPPING"); - }); - it("maps cart sub-states to CART", () => { - expect(xstateToLegacyState({ cart: "idle" })).toBe("CART"); - expect(xstateToLegacyState({ cart: "askingClarification" })).toBe("CART"); - }); -}); diff --git a/src/modules/3-turn-engine/machine/nluToEvent.js b/src/modules/3-turn-engine/machine/nluToEvent.js deleted file mode 100644 index a06276f..0000000 --- a/src/modules/3-turn-engine/machine/nluToEvent.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * NLU → XState event adapter. - * Cada NLU intent se traduce a un único evento de la máquina. - */ - -import { extractProductQueries } from "../stateHandlers/cartHelpers.js"; - -export function nluToEvent(nlu, text) { - const intent = nlu?.intent || "other"; - const entities = nlu?.entities || {}; - - switch (intent) { - case "greeting": - return { type: "GREETING" }; - - case "add_to_cart": - return { type: "ADD_TO_CART", items: extractProductQueries(nlu) }; - - case "view_cart": - return { type: "VIEW_CART" }; - - case "remove_from_cart": - return { type: "REMOVE_FROM_CART", items: entities.items || [] }; - - case "confirm_order": - return { type: "CONFIRM_ORDER" }; - - case "price_query": - return { type: "PRICE_QUERY", items: extractProductQueries(nlu) }; - - case "recommend": - return { type: "RECOMMEND", text }; - - case "browse": - return { type: "BROWSE", items: extractProductQueries(nlu) }; - - case "select_shipping": - return { type: "SELECT_SHIPPING", method: entities.shipping_method || null }; - - case "provide_address": - return { type: "PROVIDE_ADDRESS", address: entities.address || text }; - - default: - return { type: "OTHER", text }; - } -} diff --git a/src/modules/3-turn-engine/machine/runner.js b/src/modules/3-turn-engine/machine/runner.js deleted file mode 100644 index a62d8a4..0000000 --- a/src/modules/3-turn-engine/machine/runner.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Runner del motor XState. - * - * Reemplaza al dispatcher de turnEngineV3.js. Conserva la API: - * runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) - * → { plan, decision } - * - * Estrategia: - * 1. Boot actor desde prev_context.xstate_snapshot si existe; caer a - * migrateOldContext si no. - * 2. NLU se hace afuera (igual que en runTurnV3 actual). Convertimos a evento - * XState con nluToEvent. - * 3. send(evento). XState settle (incluye actores invocados). - * 4. Después del settle: traducimos context.pending_reply a texto via renderReply - * (NO async dentro de la machine). - * 5. Serializamos getPersistedSnapshot a context.xstate_snapshot. - * 6. Format de salida: plan + decision con shape compatible con pipeline.js. - */ - -import { createActor, waitFor } from "xstate"; -import { llmNluV3 } from "../openai.js"; -import { llmNluModular } from "../nlu/index.js"; -import { migrateOldContext, createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; -import { getStoreConfig } from "../../0-ui/db/settingsRepo.js"; -import { renderReply, pushRecent } from "../replyTemplates.js"; -import { buildStoreContextVars } from "../storeContext.js"; -import { machine, xstateToLegacyState } from "./index.js"; -import { nluToEvent } from "./nluToEvent.js"; - -const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; -const MAX_SETTLE_MS = parseInt(process.env.XSTATE_SETTLE_MS || "10000", 10); - -function shortSummary(history) { - if (!Array.isArray(history) || history.length === 0) return null; - return history.slice(-6).map((m) => `${m.role === "user" ? "U" : "A"}: ${String(m.content || "").slice(0, 80)}`).join("\n"); -} - -/** - * Espera a que la máquina settle: ningún actor invocado pendiente. - */ -async function settleActor(actor) { - // En XState v5, después de send() el snapshot ya refleja la transición sync. - // Si hay invokes pendientes, el actor sigue procesando — esperamos a que - // status sea 'active' Y no haya children pendientes. - const start = Date.now(); - while (Date.now() - start < MAX_SETTLE_MS) { - const snap = actor.getSnapshot(); - const children = Object.values(snap.children || {}); - const stillRunning = children.some((c) => { - try { - const cs = c.getSnapshot?.(); - return cs && cs.status === "active"; - } catch { - return false; - } - }); - if (!stillRunning) return snap; - // Pequeño yield - await new Promise((r) => setTimeout(r, 10)); - } - return actor.getSnapshot(); -} - -/** - * Renderiza el reply final a partir del descriptor pending_reply en context. - * Soporta: - * - { templateKey, vars } → renderReply - * - { templateKey, prefix } → cartDisplay + renderReply - * - { rawText } → texto literal (data-driven) - * - null → "" (estado sin reply) - */ -async function realizeReply(context) { - const desc = context.pending_reply; - if (!desc) return { reply: "", template_id: null }; - - if (desc.rawText) { - return { reply: desc.rawText, template_id: null }; - } - - const storeVars = buildStoreContextVars(context.storeConfig || {}); - const vars = { ...storeVars, ...(desc.vars || {}) }; - - const r = await renderReply({ - tenantId: context.tenantId, - templateKey: desc.templateKey, - vars, - recentReplies: context.recent_replies || [], - conversation_history: context.conversation_history || [], - state: context.fsmState || null, - userText: context.userText || "", - }); - - let reply = r.reply; - if (desc.prefix) reply = `${desc.prefix}\n\n${reply}`; - - return { reply, template_id: r.template_id }; -} - -/** - * Construye decision.context_patch con shape de pipeline existente + - * el nuevo xstate_snapshot. - */ -function buildContextPatch(snapshot, recentReplies, finalTemplateId, persistedSnap) { - const context = snapshot.context; - const order = context.order || createEmptyOrder(); - const nextRecent = finalTemplateId ? pushRecent(recentReplies, finalTemplateId) : recentReplies; - - return { - order, - order_basket: { - items: (order.cart || []).map((item) => ({ - product_id: item.woo_id, - woo_product_id: item.woo_id, - quantity: item.qty, - unit: item.unit, - label: item.name, - name: item.name, - price: item.price, - })), - }, - pending_items: (order.pending || []).map((p) => ({ - id: p.id, - query: p.query, - candidates: p.candidates, - resolved_product: p.selected_woo_id ? { - woo_product_id: p.selected_woo_id, - name: p.selected_name, - price: p.selected_price, - display_unit: p.selected_unit, - } : null, - quantity: p.qty, - unit: p.unit, - status: p.status?.toLowerCase() || "needs_type", - })), - shipping_method: order.is_delivery === true ? "delivery" - : order.is_delivery === false ? "pickup" : null, - delivery_address: order.shipping_address ? { text: order.shipping_address } : null, - woo_order_id: order.woo_order_id, - recent_replies: nextRecent, - failed_searches: context.failed_searches || { count: 0 }, - xstate_snapshot: persistedSnap, - }; -} - -/** - * Punto de entrada. Mismo signature que runTurnV3. - */ -export async function runTurnXState({ - tenantId, - chat_id, - text, - prev_state, - prev_context, - conversation_history, -}) { - const audit = { trace: { tenantId, chat_id, text_preview: String(text || "").slice(0, 50), prev_state, engine: "xstate" } }; - - // 1) Cargar storeConfig - const storeConfig = await getStoreConfig({ tenantId }); - - // 2) NLU (igual que el dispatcher legacy) - const order = migrateOldContext(prev_context); - const recentReplies = Array.isArray(prev_context?.recent_replies) ? prev_context.recent_replies : []; - const failedSearches = (prev_context?.failed_searches && typeof prev_context.failed_searches === "object") - ? prev_context.failed_searches - : { count: 0 }; - - const nluInput = { - last_user_message: text, - conversation_state: prev_state || "IDLE", - memory_summary: shortSummary(conversation_history), - pending_context: { - has_cart_items: (order?.cart?.length || 0) > 0, - has_pending_items: (order?.pending?.length || 0) > 0, - }, - last_shown_options: [], - locale: "es-AR", - }; - - let nluResult; - if (USE_MODULAR_NLU) { - nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig }); - } else { - nluResult = await llmNluV3({ input: nluInput }); - } - const nlu = nluResult.nlu; - audit.nlu = { model: nluResult.model, validation: nluResult.validation, parsed: nlu }; - - // 3) Bootear actor - const snapshotInput = prev_context?.xstate_snapshot || null; - const actor = snapshotInput - ? createActor(machine, { snapshot: snapshotInput, input: { tenantId, chat_id, storeConfig } }) - : createActor(machine, { - input: { - tenantId, - chat_id, - storeConfig, - initialOrder: order, - recentReplies, - failedSearches, - conversation_history, - }, - }); - - actor.start(); - - // 4) Mandar el evento NLU - const evt = nluToEvent(nlu, text); - evt.text = text; - audit.xstate_event = evt.type; - - actor.send(evt); - - // 5) Settle (espera a actores invocados) - const snapshot = await settleActor(actor); - - // 6) Realizar reply via renderReply (async, fuera de la machine) - const { reply, template_id } = await realizeReply(snapshot.context); - audit.template_id = template_id; - - // 7) Serializar snapshot persistente - const persistedSnap = actor.getPersistedSnapshot(); - actor.stop(); - - // 8) Format compatible con pipeline existente - const legacyState = xstateToLegacyState(snapshot.value); - const context_patch = buildContextPatch(snapshot, recentReplies, template_id, persistedSnap); - - return { - plan: { - reply, - next_state: legacyState, - intent: nlu?.intent || "other", - missing_fields: [], - order_action: snapshot.context.pending_actions?.[0]?.type || "none", - basket_resolved: { items: context_patch.order_basket.items }, - }, - decision: { - actions: snapshot.context.pending_actions || [], - context_patch, - audit, - }, - }; -} diff --git a/src/modules/3-turn-engine/nlu/defaults/browse.txt b/src/modules/3-turn-engine/nlu/defaults/browse.txt deleted file mode 100644 index ee6bccc..0000000 --- a/src/modules/3-turn-engine/nlu/defaults/browse.txt +++ /dev/null @@ -1,73 +0,0 @@ -Sos {{bot_name}}, asistente de {{store_name}}. Procesá consultas sobre el catálogo. - -TIPOS DE CONSULTAS: - -1. price_query - Consulta de precios - Señales: "cuánto sale", "precio de", "cuánto cuesta", "a cuánto está" - Extraer: product_query (el producto que pregunta) - -2. browse - Consulta de disponibilidad - Señales: "tenés", "hay", "vendés", "tienen" - Extraer: product_query - -3. recommend - Pedido de recomendación/planificación - Señales: "qué me recomendás", "qué llevo", "para X personas", "para un asado" - Extraer: - - people_count: número de personas si lo menciona - - event_type: tipo de evento (asado, cumple, reunión) - - product_query: producto específico si lo menciona - -EJEMPLOS: - -Input: "cuánto sale el vacío?" -Output: -{ - "intent": "price_query", - "product_query": "vacío", - "people_count": null, - "event_type": null -} - -Input: "tenés chimichurri?" -Output: -{ - "intent": "browse", - "product_query": "chimichurri", - "people_count": null, - "event_type": null -} - -Input: "qué me recomendás para 8 personas?" -Output: -{ - "intent": "recommend", - "product_query": null, - "people_count": 8, - "event_type": "asado" -} - -Input: "para un asado de 6, qué llevo?" -Output: -{ - "intent": "recommend", - "product_query": null, - "people_count": 6, - "event_type": "asado" -} - -Input: "qué vino va bien con carne?" -Output: -{ - "intent": "recommend", - "product_query": "vino", - "people_count": null, - "event_type": null -} - -FORMATO JSON: -{ - "intent": "price_query|browse|recommend", - "product_query": "texto" | null, - "people_count": number | null, - "event_type": "asado|cumple|reunion" | null -} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/greeting.txt b/src/modules/3-turn-engine/nlu/defaults/greeting.txt deleted file mode 100644 index 070e6ae..0000000 --- a/src/modules/3-turn-engine/nlu/defaults/greeting.txt +++ /dev/null @@ -1,23 +0,0 @@ -Sos {{bot_name}}, el asistente virtual de {{store_name}}. - -PERSONALIDAD: -- Carnicero profesional argentino con años de experiencia -- Usás voseo natural (vos, querés, tenés, decime) -- Amable y cálido pero eficiente, no muy formal -- Conocedor de cortes de carne y tradiciones del asado argentino -- Podés hacer algún comentario simpático sobre el asado si viene al caso -- Respuestas concisas, no te extendés demasiado - -CONTEXTO DEL NEGOCIO: -- Horario: {{store_hours}} -- Dirección: {{store_address}} - -INSTRUCCIONES: -El cliente te saluda. Respondé de forma cálida y preguntá en qué podés ayudar. -Si hay alguna promo del día o corte destacado, mencionalo brevemente. - -FORMATO DE RESPUESTA (JSON): -{ - "intent": "greeting", - "reply": "tu respuesta al cliente" -} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/orders.txt b/src/modules/3-turn-engine/nlu/defaults/orders.txt deleted file mode 100644 index bad1b7d..0000000 --- a/src/modules/3-turn-engine/nlu/defaults/orders.txt +++ /dev/null @@ -1,120 +0,0 @@ -Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje del usuario. - -REGLAS CRÍTICAS (seguir estrictamente): - -0. EXTRAER TODOS LOS PRODUCTOS - NUNCA OMITIR NINGUNO - Si el mensaje menciona 5 productos, el array items DEBE tener 5 elementos. - NUNCA omitas productos, incluso si no estás seguro del nombre exacto. - Extraé cada producto mencionado, separado por comas, "y", saltos de línea, etc. - -1. SIEMPRE USAR ARRAY "items" - Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento. - Cada item tiene: product_query, quantity, unit - -2. COPIAR TEXTO EXACTO - El campo "product_query" debe ser el texto EXACTO que usó el cliente. - - Si dice "asado de tira" → product_query: "asado de tira" - - Si dice "vacío" → product_query: "vacío" - - Si dice "carre de cerdo" → product_query: "carre de cerdo" - - Si dice "provoletas wapi" → product_query: "provoletas wapi" - - NUNCA modifiques, combines ni inventes nombres - -3. EXTRAER CANTIDADES (pueden estar antes o después del producto) - - "2kg de X" → quantity: 2, unit: "kg" - - "X 1kg" → quantity: 1, unit: "kg" (cantidad después del producto) - - "3 provoletas" → quantity: 3, unit: "unidad" - - "medio kilo" → quantity: 0.5, unit: "kg" - - Sin cantidad → quantity: null - -4. UNIDADES - - kg: kilos, kilo, kilogramo - - g: gramos, gr - - unidad: unidades, u (para productos que no se pesan) - -5. INTENTS - - add_to_cart: agregar productos (quiero, dame, anotame, poneme, hola quiero) - - remove_from_cart: quitar productos (sacame, quitame) - - view_cart: ver carrito (qué tengo, qué anoté, mi pedido) - - confirm_order: cerrar pedido (listo, eso es todo, cerrar) - -EJEMPLOS: - -Input: "hola, quiero 1kg de asado, vacio, carre de cerdo 1kg, chorizo mixto 1kg y 3 provoletas wapi" -Output: -{ - "intent": "add_to_cart", - "confidence": 0.95, - "items": [ - {"product_query": "asado", "quantity": 1, "unit": "kg"}, - {"product_query": "vacio", "quantity": null, "unit": null}, - {"product_query": "carre de cerdo", "quantity": 1, "unit": "kg"}, - {"product_query": "chorizo mixto", "quantity": 1, "unit": "kg"}, - {"product_query": "provoletas wapi", "quantity": 3, "unit": "unidad"} - ] -} - -Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas" -Output: -{ - "intent": "add_to_cart", - "confidence": 0.95, - "items": [ - {"product_query": "vacío", "quantity": 2, "unit": "kg"}, - {"product_query": "asado de tira", "quantity": 3, "unit": "kg"}, - {"product_query": "chorizos mixtos", "quantity": 1, "unit": "kg"}, - {"product_query": "provoletas", "quantity": 2, "unit": "unidad"} - ] -} - -Input: "dame 1kg de vacío" -Output: -{ - "intent": "add_to_cart", - "confidence": 0.95, - "items": [ - {"product_query": "vacío", "quantity": 1, "unit": "kg"} - ] -} - -Input: "quiero asado" -Output: -{ - "intent": "add_to_cart", - "confidence": 0.9, - "items": [ - {"product_query": "asado", "quantity": null, "unit": null} - ] -} - -Input: "sacame el chorizo" -Output: -{ - "intent": "remove_from_cart", - "confidence": 0.9, - "items": [ - {"product_query": "chorizo", "quantity": null, "unit": null} - ] -} - -Input: "qué tengo anotado?" -Output: -{ - "intent": "view_cart", - "confidence": 0.95, - "items": [] -} - -Input: "listo, eso sería todo" -Output: -{ - "intent": "confirm_order", - "confidence": 0.95, - "items": [] -} - -FORMATO JSON ESTRICTO: -{ - "intent": "add_to_cart|remove_from_cart|view_cart|confirm_order", - "confidence": 0.0-1.0, - "items": [{product_query, quantity, unit}, ...] -} diff --git a/src/modules/3-turn-engine/nlu/defaults/payment.txt b/src/modules/3-turn-engine/nlu/defaults/payment.txt deleted file mode 100644 index 87a82cd..0000000 --- a/src/modules/3-turn-engine/nlu/defaults/payment.txt +++ /dev/null @@ -1,60 +0,0 @@ -Extraé información de pago del mensaje del usuario. - -ENTIDADES A EXTRAER: - -1. payment_method - - "cash": pago en efectivo - Señales: efectivo, cash, plata, en mano - - "link": pago electrónico (tarjeta, transferencia, link de pago) - Señales: tarjeta, link, transferencia, QR, mercadopago, MP - - null: no se puede determinar - -EJEMPLOS: - -Input: "efectivo" -Output: -{ - "intent": "select_payment", - "payment_method": "cash" -} - -Input: "con tarjeta" -Output: -{ - "intent": "select_payment", - "payment_method": "link" -} - -Input: "link de pago" -Output: -{ - "intent": "select_payment", - "payment_method": "link" -} - -Input: "pago cuando llega" -Output: -{ - "intent": "select_payment", - "payment_method": "cash" -} - -Input: "transferencia" -Output: -{ - "intent": "select_payment", - "payment_method": "link" -} - -Input: "1" (si el contexto indica que 1=efectivo) -Output: -{ - "intent": "select_payment", - "payment_method": "cash" -} - -FORMATO JSON: -{ - "intent": "select_payment", - "payment_method": "cash" | "link" | null -} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/router.txt b/src/modules/3-turn-engine/nlu/defaults/router.txt deleted file mode 100644 index afd5857..0000000 --- a/src/modules/3-turn-engine/nlu/defaults/router.txt +++ /dev/null @@ -1,33 +0,0 @@ -Clasificá el dominio del mensaje del usuario. Respondé SOLO JSON válido. - -{"domain":"greeting|orders|shipping|payment|browse|other"} - -REGLAS DE CLASIFICACIÓN: - -1. greeting - Saludos sin mención de productos - - "hola", "buen día", "buenas tardes", "qué tal", "hey" - - NO si menciona productos junto al saludo - -2. orders - Todo relacionado con pedidos y productos - - Agregar productos: "quiero", "dame", "anotame", "poneme", cantidad + producto - - Quitar productos: "sacame", "quitame", "no quiero" - - Ver carrito: "qué tengo", "qué anoté", "mi pedido" - - Confirmar: "listo", "eso es todo", "cerrar pedido" - -3. shipping - Envío y entrega - - Método: "delivery", "envío", "retiro", "buscar", "sucursal" - - Dirección: textos con calle, número, barrio - -4. payment - Métodos de pago - - "efectivo", "tarjeta", "transferencia", "link", "mercadopago" - -5. browse - Consultas de catálogo - - Precios: "cuánto sale", "precio de" - - Disponibilidad: "tenés", "hay", "vendés" - - Recomendaciones: "qué me recomendás", "para X personas" - -6. other - Cualquier otra cosa - -Estado actual: {{state}} - -Mensaje a clasificar: [se provee en el input] \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/shipping.txt b/src/modules/3-turn-engine/nlu/defaults/shipping.txt deleted file mode 100644 index bd54810..0000000 --- a/src/modules/3-turn-engine/nlu/defaults/shipping.txt +++ /dev/null @@ -1,64 +0,0 @@ -Extraé información de envío del mensaje del usuario. - -ENTIDADES A EXTRAER: - -1. shipping_method - - "delivery": el cliente quiere que le lleven el pedido - Señales: delivery, envío, enviar, que me lo traigan, llevar - - "pickup": el cliente pasa a buscar - Señales: retiro, retirar, buscar, paso, sucursal - - null: no se puede determinar - -2. address - - Texto de la dirección de entrega - - Solo extraer si hay datos concretos (calle, número, barrio, etc.) - - null: si no hay dirección - -EJEMPLOS: - -Input: "delivery" -Output: -{ - "intent": "select_shipping", - "shipping_method": "delivery", - "address": null -} - -Input: "paso a buscar" -Output: -{ - "intent": "select_shipping", - "shipping_method": "pickup", - "address": null -} - -Input: "Av. Corrientes 1234, Almagro" -Output: -{ - "intent": "provide_address", - "shipping_method": null, - "address": "Av. Corrientes 1234, Almagro" -} - -Input: "delivery a Palermo, calle Honduras 5000" -Output: -{ - "intent": "select_shipping", - "shipping_method": "delivery", - "address": "Palermo, calle Honduras 5000" -} - -Input: "1" (si el contexto indica que 1=delivery) -Output: -{ - "intent": "select_shipping", - "shipping_method": "delivery", - "address": null -} - -FORMATO JSON: -{ - "intent": "select_shipping|provide_address", - "shipping_method": "delivery" | "pickup" | null, - "address": "texto de dirección" | null -} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/humanFallback.js b/src/modules/3-turn-engine/nlu/humanFallback.js deleted file mode 100644 index 38ab432..0000000 --- a/src/modules/3-turn-engine/nlu/humanFallback.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Human Fallback - Lógica para escalar conversaciones a humanos - * - * Se activa cuando: - * - No se encuentra un producto en el catálogo - * - El NLU tiene baja confianza - * - Casos especiales que requieren atención humana - */ - -import { ConversationState } from "../fsm.js"; -import { createEmptyOrder } from "../orderModel.js"; - -/** - * Crea una respuesta de takeover para cuando no se encuentra un producto - * - * @param {Object} params - * @param {string} params.pendingQuery - La query/producto que no se encontró - * @param {Object} params.order - Estado actual del pedido - * @param {Object} params.context - Contexto adicional para el humano - * @returns {Object} Resultado con plan y decision para el pipeline - */ -export function createHumanTakeoverResponse({ pendingQuery, order, context = {} }) { - const currentOrder = order || createEmptyOrder(); - - // Mensaje amigable para el usuario - const reply = `Dejame consultar con el equipo sobre "${pendingQuery}". Te respondo en un momento.`; - - return { - plan: { - reply, - next_state: ConversationState.AWAITING_HUMAN, - intent: "human_takeover", - missing_fields: ["human_response"], - order_action: "none", - }, - decision: { - actions: [ - { - type: "request_human_takeover", - payload: { - pending_query: pendingQuery, - reason: "product_not_found", - context_snapshot: { - order: currentOrder, - ...context, - }, - }, - }, - ], - order: currentOrder, - audit: { - human_takeover_requested: true, - pending_query: pendingQuery, - }, - }, - }; -} - -/** - * Verifica si debería escalar a humano basado en los resultados del catálogo - * - * @param {Object} params - * @param {Array} params.candidates - Candidatos encontrados en el catálogo - * @param {string} params.query - Query original del usuario - * @param {number} params.confidenceThreshold - Umbral de confianza mínimo - * @returns {boolean} true si debería escalar a humano - */ -export function shouldEscalateToHuman({ candidates = [], query, confidenceThreshold = 0.3 }) { - // Si no hay candidatos, escalar - if (!candidates || candidates.length === 0) { - return true; - } - - // Si el mejor candidato tiene score muy bajo, escalar - const bestScore = candidates[0]?._score || 0; - if (bestScore < confidenceThreshold) { - return true; - } - - // Si la query es muy diferente al nombre del mejor candidato (por nombre) - // Esto es un heurístico simple para detectar confusiones - const bestName = (candidates[0]?.name || "").toLowerCase(); - const queryLower = (query || "").toLowerCase(); - - // Si no hay overlap significativo de palabras, podría ser confusión - const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2); - const nameWords = bestName.split(/\s+/).filter(w => w.length > 2); - - if (queryWords.length > 0 && nameWords.length > 0) { - const overlap = queryWords.filter(qw => - nameWords.some(nw => nw.includes(qw) || qw.includes(nw)) - ); - - // Si hay muy poco overlap y el score no es muy alto, escalar - if (overlap.length === 0 && bestScore < 0.7) { - return true; - } - } - - return false; -} - -/** - * Genera mensaje de respuesta cuando el humano responde al takeover - * - * @param {Object} params - * @param {string} params.humanResponse - Respuesta del humano - * @param {Object} params.order - Estado actual del pedido - * @returns {Object} Resultado para continuar el flujo normal - */ -export function createHumanResponseResult({ humanResponse, order }) { - const currentOrder = order || createEmptyOrder(); - - return { - plan: { - reply: humanResponse, - next_state: ConversationState.CART, // Volver al flujo normal - intent: "human_response", - missing_fields: [], - order_action: "none", - }, - decision: { - actions: [ - { - type: "human_response_sent", - payload: {}, - }, - ], - order: currentOrder, - audit: { - human_response_processed: true, - }, - }, - }; -} - -/** - * Verifica si el estado actual es AWAITING_HUMAN - */ -export function isAwaitingHuman(state) { - return state === ConversationState.AWAITING_HUMAN || state === "AWAITING_HUMAN"; -} - -/** - * Genera respuesta cuando el usuario envía mensaje mientras está en AWAITING_HUMAN - */ -export function createWaitingForHumanResponse({ order }) { - const currentOrder = order || createEmptyOrder(); - - return { - plan: { - reply: "Estoy esperando respuesta del equipo sobre tu consulta. Te aviso apenas tenga novedades.", - next_state: ConversationState.AWAITING_HUMAN, - intent: "other", - missing_fields: ["human_response"], - order_action: "none", - }, - decision: { - actions: [], - order: currentOrder, - audit: { still_waiting_human: true }, - }, - }; -} diff --git a/src/modules/3-turn-engine/nlu/index.js b/src/modules/3-turn-engine/nlu/index.js deleted file mode 100644 index ccf0adf..0000000 --- a/src/modules/3-turn-engine/nlu/index.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * NLU Modular - Punto de entrada principal - * - * Orquesta el Router + Specialists para procesar mensajes de usuario. - * Reemplaza a llmNluV3 con una arquitectura modular y prompts editables. - */ - -import { routerClassify, quickDomainDetect } from "./router.js"; -import { greetingNlu } from "./specialists/greeting.js"; -import { ordersNlu } from "./specialists/orders.js"; -import { shippingNlu } from "./specialists/shipping.js"; -import { browseNlu } from "./specialists/browse.js"; -import { createEmptyNlu } from "./schemas.js"; - -// Re-exportar utilidades útiles -export { loadPrompt, invalidatePromptCache, AVAILABLE_VARIABLES } from "./promptLoader.js"; -export { PROMPT_KEYS, DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js"; - -/** - * Procesa un mensaje con el sistema NLU modular - * - * @param {Object} params - * @param {Object} params.input - Input del NLU - * @param {string} params.input.last_user_message - Mensaje del usuario - * @param {string} params.input.conversation_state - Estado actual de la conversación - * @param {Object} params.input.pending_context - Contexto de items pendientes - * @param {string} params.input.locale - Locale (default: es-AR) - * @param {number} params.tenantId - ID del tenant - * @param {Object} params.storeConfig - Configuración de la tienda (para variables) - * @returns {Object} { nlu, raw_text, model, usage, schema, validation, routing } - */ -export async function llmNluModular({ input, tenantId, storeConfig = {} } = {}) { - const text = input?.last_user_message || ""; - const state = input?.conversation_state || "IDLE"; - const startTime = Date.now(); - - // Tracking para debug - const routing = { - quick_detect: null, - router_result: null, - final_domain: null, - specialist_used: null, - }; - - try { - // 1) Quick detection: si es un caso obvio, evitar llamar al router LLM - const quickDomain = quickDomainDetect(text, state); - routing.quick_detect = quickDomain; - - // Casos donde podemos saltar el router: - // - Saludos simples - // - Números solos (1, 2) en estado SHIPPING - // - Patrones muy claros - const skipRouter = shouldSkipRouter(text, state, quickDomain); - - let domain; - if (skipRouter) { - domain = quickDomain; - routing.router_result = { skipped: true, quick_domain: quickDomain }; - } else { - // 2) Router LLM: clasificar dominio - const routerResult = await routerClassify({ tenantId, text, state, storeConfig }); - domain = routerResult.domain; - routing.router_result = routerResult; - } - - routing.final_domain = domain; - - // 3) Dispatch al specialist correspondiente - let result; - - switch (domain) { - case "greeting": - routing.specialist_used = "greeting"; - result = await greetingNlu({ tenantId, text, storeConfig }); - break; - - case "orders": - routing.specialist_used = "orders"; - result = await ordersNlu({ tenantId, text, storeConfig }); - break; - - case "shipping": - routing.specialist_used = "shipping"; - result = await shippingNlu({ tenantId, text, storeConfig }); - break; - - case "browse": - routing.specialist_used = "browse"; - result = await browseNlu({ tenantId, text, storeConfig }); - break; - - default: - // Fallback: usar orders como default si hay texto con posibles productos - routing.specialist_used = "orders_fallback"; - result = await ordersNlu({ tenantId, text, storeConfig }); - // Pero marcar como "other" si el resultado no es claro - if (result.nlu.confidence < 0.7) { - result.nlu.intent = "other"; - } - } - - // Agregar metadata de routing - result.routing = routing; - result.schema = "modular_v1"; - result.processing_time_ms = Date.now() - startTime; - - return result; - - } catch (error) { - console.error("[nluModular] Error:", error); - - // Fallback completo - const nlu = createEmptyNlu(); - nlu.intent = "other"; - nlu.confidence = 0; - - return { - nlu, - raw_text: "", - model: null, - usage: null, - schema: "modular_v1", - validation: { ok: false, error: error.message }, - routing: { ...routing, error: error.message }, - processing_time_ms: Date.now() - startTime, - }; - } -} - -/** - * Determina si podemos saltar el router LLM y usar quick detection - */ -function shouldSkipRouter(text, state, quickDomain) { - const t = String(text || "").trim(); - - // Saludos simples (sin productos) - if (quickDomain === "greeting" && t.length < 20) { - return true; - } - - // Números solos en estado SHIPPING (selección 1/2) - if (/^[12]$/.test(t) && state === "SHIPPING") { - return true; - } - - // "delivery" o "retiro" solos en estado SHIPPING - if (state === "SHIPPING" && /^(delivery|retiro|buscar|sucursal)$/i.test(t)) { - return true; - } - - // En estado SHIPPING, si quickDomain ya detectó "shipping" (dirección), confiar en eso - // Esto evita que el router LLM clasifique direcciones como productos - if (state === "SHIPPING" && quickDomain === "shipping") { - return true; - } - - return false; -} - -/** - * Versión compatible con la firma de llmNluV3 - * Para usar con el feature flag sin cambiar mucho código - */ -export async function llmNluModularCompat({ input, model } = {}) { - // Extraer tenantId del input si está disponible, o usar 1 como default - // En producción, esto debería pasarse explícitamente - const tenantId = input?.tenantId || 1; - - // Construir storeConfig básico (en producción se cargaría de la DB) - const storeConfig = { - name: input?.store_name || "la carnicería", - botName: input?.bot_name || "Piaf", - hours: input?.store_hours || "", - address: input?.store_address || "", - }; - - return llmNluModular({ input, tenantId, storeConfig }); -} - -// Export default para compatibilidad -export default llmNluModular; diff --git a/src/modules/3-turn-engine/nlu/promptLoader.js b/src/modules/3-turn-engine/nlu/promptLoader.js deleted file mode 100644 index 3b319fc..0000000 --- a/src/modules/3-turn-engine/nlu/promptLoader.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Prompt Loader - Carga prompts de DB con fallback a defaults - * - * Características: - * - Cache en memoria con TTL configurable - * - Fallback a archivos default si no hay prompt custom - * - Reemplazo de variables básicas ({{store_name}}, etc.) - */ - -import { getActivePrompt } from "../../0-ui/db/promptsRepo.js"; -import { DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const DEFAULTS_DIR = path.join(__dirname, "defaults"); - -// Cache en memoria -const cache = new Map(); -const CACHE_TTL = 5 * 60 * 1000; // 5 minutos - -/** - * Variables disponibles para reemplazo en prompts - */ -export const AVAILABLE_VARIABLES = [ - { key: "store_name", description: "Nombre del negocio", example: "Carnicería Don Pedro" }, - { key: "store_hours", description: "Horario de atención", example: "Lun-Sab 8-20hs" }, - { key: "store_address", description: "Dirección del local", example: "Av. Corrientes 1234" }, - { key: "store_phone", description: "Teléfono", example: "+54 11 1234-5678" }, - { key: "bot_name", description: "Nombre del bot", example: "Piaf" }, - { key: "current_date", description: "Fecha actual", example: "25 de enero" }, - { key: "customer_name", description: "Nombre del cliente (si lo tiene)", example: "Juan" }, - { key: "state", description: "Estado actual de la conversación", example: "CART" }, -]; - -/** - * Carga un prompt de la DB o usa el default - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.promptKey - Key del prompt ('router', 'greeting', 'orders', etc.) - * @param {Object} params.variables - Variables para reemplazar en el prompt - * @param {boolean} params.skipCache - Si es true, no usa cache - * @returns {Object} { content: string, model: string, isDefault: boolean, version: number|null } - */ -export async function loadPrompt({ tenantId, promptKey, variables = {}, skipCache = false }) { - const cacheKey = `${tenantId}:${promptKey}`; - - // Verificar cache - if (!skipCache) { - const cached = cache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return applyVariables(cached.content, cached.model, cached.isDefault, cached.version, variables); - } - } - - // Intentar cargar de DB - let content, model, isDefault = false, version = null; - - try { - const dbPrompt = await getActivePrompt({ tenantId, promptKey }); - - if (dbPrompt) { - content = dbPrompt.content; - model = dbPrompt.model; - version = dbPrompt.version; - isDefault = false; - } else { - // Fallback a archivo default - const defaultContent = loadDefaultPrompt(promptKey); - content = defaultContent; - model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo"; - isDefault = true; - } - } catch (error) { - // Si falla la DB, usar default - console.error(`[promptLoader] Error loading prompt from DB: ${error.message}`); - const defaultContent = loadDefaultPrompt(promptKey); - content = defaultContent; - model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo"; - isDefault = true; - } - - // Guardar en cache - cache.set(cacheKey, { content, model, isDefault, version, timestamp: Date.now() }); - - return applyVariables(content, model, isDefault, version, variables); -} - -/** - * Carga el prompt default desde archivo - */ -export function loadDefaultPrompt(promptKey) { - const filePath = path.join(DEFAULTS_DIR, `${promptKey}.txt`); - - if (!fs.existsSync(filePath)) { - throw new Error(`Default prompt file not found: ${filePath}`); - } - - return fs.readFileSync(filePath, "utf-8"); -} - -/** - * Reemplaza variables en el contenido del prompt - */ -function applyVariables(content, model, isDefault, version, variables) { - let result = content; - - // Agregar fecha actual si no está en variables - if (!variables.current_date) { - const now = new Date(); - const months = ["enero", "febrero", "marzo", "abril", "mayo", "junio", - "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]; - variables.current_date = `${now.getDate()} de ${months[now.getMonth()]}`; - } - - // Reemplazar todas las variables - for (const [key, value] of Object.entries(variables)) { - const regex = new RegExp(`{{${key}}}`, "g"); - result = result.replace(regex, value || ""); - } - - // Limpiar variables no reemplazadas (dejar vacío) - result = result.replace(/\{\{[^}]+\}\}/g, ""); - - return { content: result, model, isDefault, version }; -} - -/** - * Invalida el cache de un prompt específico - */ -export function invalidatePromptCache(tenantId, promptKey) { - const cacheKey = `${tenantId}:${promptKey}`; - cache.delete(cacheKey); -} - -/** - * Invalida todo el cache de un tenant - */ -export function invalidateTenantCache(tenantId) { - for (const key of cache.keys()) { - if (key.startsWith(`${tenantId}:`)) { - cache.delete(key); - } - } -} - -/** - * Limpia todo el cache - */ -export function clearAllCache() { - cache.clear(); -} - -/** - * Obtiene estadísticas del cache (para debugging) - */ -export function getCacheStats() { - const entries = []; - const now = Date.now(); - - for (const [key, value] of cache.entries()) { - entries.push({ - key, - age: Math.round((now - value.timestamp) / 1000), - isExpired: now - value.timestamp >= CACHE_TTL, - isDefault: value.isDefault, - version: value.version, - }); - } - - return { - size: cache.size, - ttlSeconds: CACHE_TTL / 1000, - entries, - }; -} - -/** - * Pre-carga todos los prompts de un tenant (útil al inicio) - */ -export async function preloadPrompts({ tenantId, storeConfig = {} }) { - const promptKeys = ["router", "greeting", "orders", "shipping", "browse"]; - const results = {}; - - for (const key of promptKeys) { - try { - results[key] = await loadPrompt({ - tenantId, - promptKey: key, - variables: storeConfig, - skipCache: true - }); - } catch (error) { - console.error(`[promptLoader] Error preloading ${key}: ${error.message}`); - results[key] = { error: error.message }; - } - } - - return results; -} diff --git a/src/modules/3-turn-engine/nlu/router.js b/src/modules/3-turn-engine/nlu/router.js deleted file mode 100644 index 48159c7..0000000 --- a/src/modules/3-turn-engine/nlu/router.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Router NLU - Clasifica el dominio del mensaje - * - * Usa un prompt ligero para clasificar rápidamente el tipo de mensaje - * antes de enviarlo al specialist correspondiente. - */ - -import OpenAI from "openai"; -import { loadPrompt } from "./promptLoader.js"; -import { validateRouter, getValidationErrors } from "./schemas.js"; - -let _client = null; - -function getClient() { - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY is not set"); - } - if (!_client) { - _client = new OpenAI({ apiKey }); - } - return _client; -} - -/** - * Extrae JSON de una respuesta de texto - */ -function extractJson(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) { - try { - return JSON.parse(s.slice(i, j + 1)); - } catch { - return null; - } - } - return null; -} - -/** - * Clasifica el dominio del mensaje - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.text - Mensaje del usuario - * @param {string} params.state - Estado actual de la conversación - * @param {Object} params.storeConfig - Config de la tienda (para variables) - * @returns {Object} { domain: string, raw_text: string, model: string } - */ -export async function routerClassify({ tenantId, text, state, storeConfig = {} }) { - const openai = getClient(); - - // Cargar prompt del router - const { content: systemPrompt, model } = await loadPrompt({ - tenantId, - promptKey: "router", - variables: { - state: state || "IDLE", - ...storeConfig, - }, - }); - - // Hacer la llamada al LLM - const response = await openai.chat.completions.create({ - model: model || "gpt-4o-mini", - temperature: 0.1, - max_tokens: 50, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: text }, - ], - }); - - const rawText = response?.choices?.[0]?.message?.content || ""; - let parsed = extractJson(rawText); - - // Validar respuesta - if (!parsed || !validateRouter(parsed)) { - // Fallback: intentar detectar por patrones simples - parsed = { domain: detectDomainByPatterns(text, state) }; - } - - return { - domain: parsed.domain || "other", - raw_text: rawText, - model: model, - usage: response?.usage || null, - }; -} - -/** - * Detección de dominio por patrones (fallback) - */ -function detectDomainByPatterns(text, state) { - const t = String(text || "").toLowerCase().trim(); - - // Greeting patterns (solo si no menciona productos) - const greetingPatterns = /^(hola|buenas?|buen d[ií]a|buenas tardes|buenas noches|qu[eé] tal|hey|hi|holis)\s*[!?.,]*$/i; - if (greetingPatterns.test(t)) { - return "greeting"; - } - - // Si el estado ya es SHIPPING, priorizar ese dominio - if (state === "SHIPPING") { - if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) { - return "shipping"; - } - // Si parece una dirección (tiene números y palabras) - if (/\d+/.test(t) && /[a-záéíóú]{3,}/i.test(t)) { - return "shipping"; - } - } - - // Orders patterns - const orderPatterns = [ - /\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i, - /\b(sacame|quitame|eliminame)\b/i, - /\b(qu[eé] tengo|qu[eé] anot[eé]|mi pedido|ver carrito)\b/i, - /\b(listo|eso es todo|cerrar|confirmar)\b/i, - /\d+\s*(kg|kilo|gramo|g|unidad)/i, // cantidad + unidad - ]; - if (orderPatterns.some(p => p.test(t))) { - return "orders"; - } - - // Browse patterns - const browsePatterns = [ - /\b(cu[aá]nto (sale|cuesta|est[aá]))\b/i, - /\b(precio de|precios)\b/i, - /\b(ten[eé]s|hay|vend[eé]s|tienen)\b/i, - /\b(qu[eé] me recomend[aá]s|recomendaci[oó]n)\b/i, - /\bpara\s+\d+\s*(personas?|comensales?)\b/i, - ]; - if (browsePatterns.some(p => p.test(t))) { - return "browse"; - } - - // Shipping patterns - if (/\b(delivery|env[ií]o|retiro|buscar|sucursal)\b/i.test(t)) { - return "shipping"; - } - - // Default basado en estado - if (state === "CART") return "orders"; - if (state === "SHIPPING") return "shipping"; - - return "other"; -} - -/** - * Detecta dominio solo por patrones (sin LLM) - * Útil para casos obvios o cuando queremos ahorrar latencia - */ -export function quickDomainDetect(text, state) { - return detectDomainByPatterns(text, state); -} diff --git a/src/modules/3-turn-engine/nlu/schemas.js b/src/modules/3-turn-engine/nlu/schemas.js deleted file mode 100644 index 908a317..0000000 --- a/src/modules/3-turn-engine/nlu/schemas.js +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Schemas JSON para validación de respuestas NLU - */ - -import Ajv from "ajv"; - -const ajv = new Ajv({ allErrors: true, strict: true }); - -// ───────────────────────────────────────────────────────────── -// Schema: Router -// ───────────────────────────────────────────────────────────── - -export const RouterSchema = { - $id: "Router", - type: "object", - additionalProperties: false, - required: ["domain"], - properties: { - domain: { - type: "string", - enum: ["greeting", "orders", "shipping", "browse", "other"], - }, - }, -}; - -export const validateRouter = ajv.compile(RouterSchema); - -// ───────────────────────────────────────────────────────────── -// Schema: Greeting -// ───────────────────────────────────────────────────────────── - -export const GreetingSchema = { - $id: "Greeting", - type: "object", - additionalProperties: false, - required: ["intent", "reply"], - properties: { - intent: { type: "string", enum: ["greeting"] }, - reply: { type: "string", minLength: 1 }, - }, -}; - -export const validateGreeting = ajv.compile(GreetingSchema); - -// ───────────────────────────────────────────────────────────── -// Schema: Orders -// ───────────────────────────────────────────────────────────── - -export const OrdersSchema = { - $id: "Orders", - type: "object", - additionalProperties: false, - required: ["intent", "confidence"], - properties: { - intent: { - type: "string", - enum: ["add_to_cart", "remove_from_cart", "view_cart", "confirm_order"], - }, - confidence: { type: "number", minimum: 0, maximum: 1 }, - items: { - anyOf: [ - { type: "null" }, - { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["product_query"], - properties: { - product_query: { type: "string", minLength: 1 }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - }, - }, - }, - ], - }, - product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - }, -}; - -export const validateOrders = ajv.compile(OrdersSchema); - -// ───────────────────────────────────────────────────────────── -// Schema: Shipping -// ───────────────────────────────────────────────────────────── - -export const ShippingSchema = { - $id: "Shipping", - type: "object", - additionalProperties: false, - required: ["intent"], - properties: { - intent: { - type: "string", - enum: ["select_shipping", "provide_address"], - }, - shipping_method: { - anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }], - }, - address: { anyOf: [{ type: "string" }, { type: "null" }] }, - }, -}; - -export const validateShipping = ajv.compile(ShippingSchema); - -// ───────────────────────────────────────────────────────────── -// Schema: Browse -// ───────────────────────────────────────────────────────────── - -export const BrowseSchema = { - $id: "Browse", - type: "object", - additionalProperties: false, - required: ["intent"], - properties: { - intent: { - type: "string", - enum: ["price_query", "browse", "recommend"], - }, - product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, - people_count: { anyOf: [{ type: "number" }, { type: "null" }] }, - event_type: { anyOf: [{ type: "string" }, { type: "null" }] }, - }, -}; - -export const validateBrowse = ajv.compile(BrowseSchema); - -// ───────────────────────────────────────────────────────────── -// Schema: NLU Unificado (output final) -// ───────────────────────────────────────────────────────────── - -export const UnifiedNluSchema = { - $id: "UnifiedNlu", - type: "object", - additionalProperties: false, - required: ["intent", "confidence", "language", "entities", "needs"], - properties: { - intent: { - type: "string", - enum: [ - "price_query", "browse", "add_to_cart", "remove_from_cart", - "checkout", "confirm_order", "select_shipping", - "provide_address", "greeting", "recommend", "view_cart", "other" - ], - }, - confidence: { type: "number", minimum: 0, maximum: 1 }, - language: { type: "string" }, - entities: { - type: "object", - additionalProperties: false, - required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation", "items"], - properties: { - product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - selection: { - anyOf: [ - { type: "null" }, - { - type: "object", - additionalProperties: false, - required: ["type", "value"], - properties: { - type: { type: "string", enum: ["index", "text", "sku"] }, - value: { type: "string", minLength: 1 }, - }, - }, - ], - }, - attributes: { type: "array", items: { type: "string" } }, - preparation: { type: "array", items: { type: "string" } }, - shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, - address: { anyOf: [{ type: "string" }, { type: "null" }] }, - items: { - anyOf: [ - { type: "null" }, - { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["product_query"], - properties: { - product_query: { type: "string", minLength: 1 }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - }, - }, - }, - ], - }, - // Browse-specific - people_count: { anyOf: [{ type: "number" }, { type: "null" }] }, - event_type: { anyOf: [{ type: "string" }, { type: "null" }] }, - }, - }, - needs: { - type: "object", - additionalProperties: false, - required: ["catalog_lookup", "knowledge_lookup"], - properties: { - catalog_lookup: { type: "boolean" }, - knowledge_lookup: { type: "boolean" }, - }, - }, - // Greeting-specific: reply del LLM - reply: { anyOf: [{ type: "string" }, { type: "null" }] }, - }, -}; - -export const validateUnifiedNlu = ajv.compile(UnifiedNluSchema); - -// ───────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────── - -/** - * Obtiene errores de validación formateados - */ -export function getValidationErrors(validate) { - const errors = validate.errors || []; - return errors.map((e) => ({ - path: e.instancePath, - message: e.message, - params: e.params, - })); -} - -/** - * Crea un NLU unificado vacío (fallback) - */ -export function createEmptyNlu() { - return { - intent: "other", - confidence: 0, - language: "es-AR", - entities: { - product_query: null, - quantity: null, - unit: null, - selection: null, - attributes: [], - preparation: [], - shipping_method: null, - address: null, - items: null, - people_count: null, - event_type: null, - }, - needs: { - catalog_lookup: false, - knowledge_lookup: false, - }, - reply: null, - }; -} diff --git a/src/modules/3-turn-engine/nlu/specialists/browse.js b/src/modules/3-turn-engine/nlu/specialists/browse.js deleted file mode 100644 index c482689..0000000 --- a/src/modules/3-turn-engine/nlu/specialists/browse.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Browse Specialist - Consultas de catálogo, precios y recomendaciones - */ - -import OpenAI from "openai"; -import { loadPrompt } from "../promptLoader.js"; -import { validateBrowse, getValidationErrors, createEmptyNlu } from "../schemas.js"; - -let _client = null; - -function getClient() { - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY is not set"); - } - if (!_client) { - const baseURL = process.env.OPENAI_BASE_URL || undefined; - _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); - } - return _client; -} - -function extractJson(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) { - try { - return JSON.parse(s.slice(i, j + 1)); - } catch { - return null; - } - } - return null; -} - -/** - * Detecta tipo de consulta por patrones simples - */ -function detectBrowseType(text) { - const t = String(text || "").toLowerCase(); - - // Price query - if (/\b(cu[aá]nto (sale|cuesta|est[aá])|precio|precios)\b/i.test(t)) { - return "price_query"; - } - - // Recommend - if (/\b(recomend[aá]|qu[eé] llevo|para \d+ personas?|para un asado)\b/i.test(t)) { - return "recommend"; - } - - // Browse (availability) - if (/\b(ten[eé]s|tienen|hay|vend[eé]s)\b/i.test(t)) { - return "browse"; - } - - return "browse"; -} - -/** - * Extrae número de personas del texto - */ -function extractPeopleCount(text) { - const t = String(text || ""); - - // "para X personas" - let match = /para\s+(\d+)\s*(personas?|comensales?|invitados?)?/i.exec(t); - if (match) return parseInt(match[1], 10); - - // "somos X" - match = /somos\s+(\d+)/i.exec(t); - if (match) return parseInt(match[1], 10); - - // "X personas" - match = /(\d+)\s*(personas?|comensales?)/i.exec(t); - if (match) return parseInt(match[1], 10); - - return null; -} - -/** - * Extrae producto mencionado (simple) - */ -function extractProductMention(text) { - const t = String(text || "").toLowerCase(); - - // Patrones comunes de preguntas - const patterns = [ - /(?:ten[eé]s|hay|vend[eé]s|precio de|cu[aá]nto (?:sale|cuesta) (?:el|la|los|las)?)\s*(.+?)(?:\?|$)/i, - /(.+?)\s*(?:tienen|hay|venden)\?/i, - ]; - - for (const pattern of patterns) { - const match = pattern.exec(t); - if (match && match[1]) { - return match[1].trim(); - } - } - - return null; -} - -/** - * Procesa una consulta de catálogo - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.text - Mensaje del usuario - * @param {Object} params.storeConfig - Config de la tienda - * @returns {Object} NLU unificado - */ -export async function browseNlu({ tenantId, text, storeConfig = {} }) { - const openai = getClient(); - - // Cargar prompt de browse - const { content: systemPrompt, model } = await loadPrompt({ - tenantId, - promptKey: "browse", - variables: { - bot_name: storeConfig.botName || "Piaf", - store_name: storeConfig.name || "la carnicería", - ...storeConfig, - }, - }); - - // Hacer la llamada al LLM - const response = await openai.chat.completions.create({ - model: model || "gpt-4-turbo", - temperature: 0.2, - max_tokens: 200, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: text }, - ], - }); - - const rawText = response?.choices?.[0]?.message?.content || ""; - let parsed = extractJson(rawText); - - // Validar - if (!parsed || !validateBrowse(parsed)) { - // Fallback con detección por patrones - const browseType = detectBrowseType(text); - parsed = { - intent: browseType, - product_query: extractProductMention(text), - people_count: extractPeopleCount(text), - event_type: /asado/i.test(text) ? "asado" : null, - }; - } - - // Convertir a formato NLU unificado - const nlu = createEmptyNlu(); - nlu.intent = parsed.intent || "browse"; - nlu.confidence = 0.85; - nlu.entities.product_query = parsed.product_query || null; - nlu.entities.people_count = parsed.people_count || null; - nlu.entities.event_type = parsed.event_type || null; - nlu.needs.catalog_lookup = true; - nlu.needs.knowledge_lookup = nlu.intent === "recommend"; - - return { - nlu, - raw_text: rawText, - model, - usage: response?.usage || null, - validation: { ok: true }, - }; -} diff --git a/src/modules/3-turn-engine/nlu/specialists/greeting.js b/src/modules/3-turn-engine/nlu/specialists/greeting.js deleted file mode 100644 index c2d8da4..0000000 --- a/src/modules/3-turn-engine/nlu/specialists/greeting.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Greeting Specialist - Maneja saludos con personalidad de carnicero argentino - */ - -import OpenAI from "openai"; -import { loadPrompt } from "../promptLoader.js"; -import { validateGreeting, getValidationErrors, createEmptyNlu } from "../schemas.js"; - -let _client = null; - -function getClient() { - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY is not set"); - } - if (!_client) { - _client = new OpenAI({ apiKey }); - } - return _client; -} - -function extractJson(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) { - try { - return JSON.parse(s.slice(i, j + 1)); - } catch { - return null; - } - } - return null; -} - -/** - * Procesa un saludo y genera respuesta con personalidad - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.text - Mensaje del usuario - * @param {Object} params.storeConfig - Config de la tienda - * @returns {Object} NLU unificado con reply - */ -export async function greetingNlu({ tenantId, text, storeConfig = {} }) { - const openai = getClient(); - - // Cargar prompt de greeting - const { content: systemPrompt, model } = await loadPrompt({ - tenantId, - promptKey: "greeting", - variables: { - bot_name: storeConfig.botName || "Piaf", - store_name: storeConfig.name || "la carnicería", - store_hours: storeConfig.hours || "", - store_address: storeConfig.address || "", - store_phone: storeConfig.phone || "", - }, - }); - - // Hacer la llamada al LLM - const response = await openai.chat.completions.create({ - model: model || "gpt-4-turbo", - temperature: 0.7, // Un poco más de creatividad para saludos - max_tokens: 200, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: text }, - ], - }); - - const rawText = response?.choices?.[0]?.message?.content || ""; - let parsed = extractJson(rawText); - - // Validar respuesta - if (!parsed || !validateGreeting(parsed)) { - // Fallback con respuesta genérica - parsed = { - intent: "greeting", - reply: "¡Hola! ¿En qué te puedo ayudar?", - }; - } - - // Convertir a formato NLU unificado - const nlu = createEmptyNlu(); - nlu.intent = "greeting"; - nlu.confidence = 0.95; - nlu.reply = parsed.reply; - nlu.needs.catalog_lookup = false; - nlu.needs.knowledge_lookup = false; - - return { - nlu, - raw_text: rawText, - model, - usage: response?.usage || null, - validation: { ok: true }, - }; -} diff --git a/src/modules/3-turn-engine/nlu/specialists/orders.js b/src/modules/3-turn-engine/nlu/specialists/orders.js deleted file mode 100644 index 0f1da5d..0000000 --- a/src/modules/3-turn-engine/nlu/specialists/orders.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Orders Specialist - Extracción de productos y cantidades - * - * El specialist más importante: maneja add_to_cart, remove_from_cart, - * view_cart, confirm_order con soporte para multi-items. - */ - -import OpenAI from "openai"; -import { loadPrompt } from "../promptLoader.js"; -import { validateOrders, getValidationErrors, createEmptyNlu } from "../schemas.js"; - -let _client = null; - -function getClient() { - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY is not set"); - } - if (!_client) { - const baseURL = process.env.OPENAI_BASE_URL || undefined; - _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); - } - return _client; -} - -function extractJson(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) { - try { - return JSON.parse(s.slice(i, j + 1)); - } catch { - return null; - } - } - return null; -} - -/** - * Normaliza unidades a formato estándar - */ -function normalizeUnit(unit) { - if (!unit) return null; - const u = String(unit).toLowerCase().trim(); - if (["kg", "kilo", "kilos", "kilogramo", "kilogramos"].includes(u)) return "kg"; - if (["g", "gr", "gramo", "gramos"].includes(u)) return "g"; - if (["unidad", "unidades", "u", "un"].includes(u)) return "unidad"; - return null; -} - -/** - * Normaliza items extraídos - */ -function normalizeItems(items) { - if (!Array.isArray(items) || items.length === 0) return null; - - return items - .filter(item => item && item.product_query) - .map(item => ({ - product_query: String(item.product_query || "").trim(), - quantity: typeof item.quantity === "number" ? item.quantity : null, - unit: normalizeUnit(item.unit), - })) - .filter(item => item.product_query.length > 0); -} - -/** - * Procesa un mensaje de pedido - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.text - Mensaje del usuario - * @param {Object} params.storeConfig - Config de la tienda - * @returns {Object} NLU unificado - */ -export async function ordersNlu({ tenantId, text, storeConfig = {} }) { - const openai = getClient(); - - // Cargar prompt de orders - const { content: systemPrompt, model } = await loadPrompt({ - tenantId, - promptKey: "orders", - variables: storeConfig, - }); - - // Hacer la llamada al LLM - const response = await openai.chat.completions.create({ - model: model || "gpt-4-turbo", - temperature: 0.1, // Baja temperatura para extracción precisa - max_tokens: 500, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: text }, - ], - }); - - const rawText = response?.choices?.[0]?.message?.content || ""; - let parsed = extractJson(rawText); - - // Intentar validar - let validationOk = false; - if (parsed && validateOrders(parsed)) { - validationOk = true; - } else if (parsed) { - // Intentar normalizar respuesta parcialmente válida - parsed = { - intent: parsed.intent || "add_to_cart", - confidence: parsed.confidence || 0.8, - items: parsed.items || null, - product_query: parsed.product_query || null, - quantity: parsed.quantity || null, - unit: parsed.unit || null, - }; - validationOk = true; - } else { - // Fallback total - parsed = { - intent: "add_to_cart", - confidence: 0.5, - items: null, - product_query: text.length < 50 ? text : null, - quantity: null, - unit: null, - }; - } - - // Normalizar items - SIEMPRE convertir a array - let normalizedItems = normalizeItems(parsed.items); - - // Si no hay items pero hay product_query en raíz, convertir a array - if ((!normalizedItems || normalizedItems.length === 0) && parsed.product_query) { - normalizedItems = [{ - product_query: String(parsed.product_query).trim(), - quantity: typeof parsed.quantity === "number" ? parsed.quantity : null, - unit: normalizeUnit(parsed.unit), - }]; - } - - // Convertir a formato NLU unificado - const nlu = createEmptyNlu(); - nlu.intent = parsed.intent || "add_to_cart"; - nlu.confidence = parsed.confidence || 0.8; - - // Entities - siempre usar items[], nunca campos individuales - nlu.entities.items = normalizedItems || []; - nlu.entities.product_query = null; // Deprecado, usar items[] - nlu.entities.quantity = null; - nlu.entities.unit = null; - - // Needs - nlu.needs.catalog_lookup = ["add_to_cart", "remove_from_cart"].includes(nlu.intent); - nlu.needs.knowledge_lookup = false; - - return { - nlu, - raw_text: rawText, - model, - usage: response?.usage || null, - validation: { ok: validationOk, errors: validationOk ? [] : getValidationErrors(validateOrders) }, - }; -} diff --git a/src/modules/3-turn-engine/nlu/specialists/shipping.js b/src/modules/3-turn-engine/nlu/specialists/shipping.js deleted file mode 100644 index 52703e3..0000000 --- a/src/modules/3-turn-engine/nlu/specialists/shipping.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Shipping Specialist - Extracción de método de envío y dirección - */ - -import OpenAI from "openai"; -import { loadPrompt } from "../promptLoader.js"; -import { validateShipping, getValidationErrors, createEmptyNlu } from "../schemas.js"; - -let _client = null; - -function getClient() { - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY is not set"); - } - if (!_client) { - _client = new OpenAI({ apiKey }); - } - return _client; -} - -function extractJson(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) { - try { - return JSON.parse(s.slice(i, j + 1)); - } catch { - return null; - } - } - return null; -} - -/** - * Detecta método de envío por patrones simples - */ -function detectShippingMethod(text) { - const t = String(text || "").toLowerCase(); - - // Números (asumiendo 1=delivery, 2=pickup del contexto) - if (/^1$/.test(t.trim())) return "delivery"; - if (/^2$/.test(t.trim())) return "pickup"; - - // Delivery patterns - if (/\b(delivery|env[ií]o|enviar|traigan|llev|domicilio)\b/i.test(t)) { - return "delivery"; - } - - // Pickup patterns - if (/\b(retiro|retirar|buscar|paso|sucursal|local)\b/i.test(t)) { - return "pickup"; - } - - return null; -} - -/** - * Detecta si el texto parece una dirección - */ -function looksLikeAddress(text) { - const t = String(text || "").trim(); - - // Tiene números y letras, más de 10 caracteres - if (t.length > 10 && /\d/.test(t) && /[a-záéíóú]/i.test(t)) { - return true; - } - - // Menciona calles, avenidas, barrios - if (/\b(calle|av|avenida|entre|esquina|piso|depto|dto|barrio)\b/i.test(t)) { - return true; - } - - return false; -} - -/** - * Procesa un mensaje de shipping - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.text - Mensaje del usuario - * @param {Object} params.storeConfig - Config de la tienda - * @returns {Object} NLU unificado - */ -export async function shippingNlu({ tenantId, text, storeConfig = {} }) { - const openai = getClient(); - - // Intentar detección rápida primero - const quickMethod = detectShippingMethod(text); - const isAddress = looksLikeAddress(text); - - // Si es claramente un número o patrón simple, no llamar al LLM - if (quickMethod && !isAddress && text.trim().length < 20) { - const nlu = createEmptyNlu(); - nlu.intent = "select_shipping"; - nlu.confidence = 0.9; - nlu.entities.shipping_method = quickMethod; - - return { - nlu, - raw_text: "", - model: null, - usage: null, - validation: { ok: true, skipped_llm: true }, - }; - } - - // Cargar prompt de shipping - const { content: systemPrompt, model } = await loadPrompt({ - tenantId, - promptKey: "shipping", - variables: storeConfig, - }); - - // Hacer la llamada al LLM - const response = await openai.chat.completions.create({ - model: model || "gpt-4o-mini", - temperature: 0.1, - max_tokens: 150, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: text }, - ], - }); - - const rawText = response?.choices?.[0]?.message?.content || ""; - let parsed = extractJson(rawText); - - // Validar - if (!parsed || !validateShipping(parsed)) { - // Fallback con detección por patrones - parsed = { - intent: isAddress ? "provide_address" : "select_shipping", - shipping_method: quickMethod, - address: isAddress ? text.trim() : null, - }; - } - - // Convertir a formato NLU unificado - const nlu = createEmptyNlu(); - nlu.intent = parsed.intent || "select_shipping"; - nlu.confidence = 0.85; - nlu.entities.shipping_method = parsed.shipping_method || null; - nlu.entities.address = parsed.address || null; - nlu.needs.catalog_lookup = false; - - return { - nlu, - raw_text: rawText, - model, - usage: response?.usage || null, - validation: { ok: true }, - }; -} diff --git a/src/modules/3-turn-engine/openai.js b/src/modules/3-turn-engine/openai.js deleted file mode 100644 index 90ca3e4..0000000 --- a/src/modules/3-turn-engine/openai.js +++ /dev/null @@ -1,606 +0,0 @@ -import OpenAI from "openai"; -import Ajv from "ajv"; -import { debug as dbg } from "../shared/debug.js"; - -let _client = null; -let _clientKey = null; - -function getApiKey() { - return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null; -} - -function getClient() { - const apiKey = getApiKey(); - 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 extractJsonObject(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) return s.slice(i, j + 1); - return null; -} - -async function jsonCompletion({ system, user, model }) { - const openai = getClient(); - const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini"; - const debug = dbg.llm; - if (debug) console.log("[llm] openai.request", { model: chosenModel }); - - const resp = await openai.chat.completions.create({ - model: chosenModel, - temperature: 0.2, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - }); - - if (debug) - console.log("[llm] openai.response", { - id: resp?.id || null, - model: resp?.model || null, - usage: resp?.usage || null, - }); - - const text = resp?.choices?.[0]?.message?.content || ""; - let parsed; - try { - parsed = JSON.parse(text); - } catch { - const extracted = extractJsonObject(text); - if (!extracted) throw new Error("openai_invalid_json"); - parsed = JSON.parse(extracted); - } - return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || null }; -} - -// --- NLU v3 (single-step, schema-strict) --- - -const NluV3JsonSchema = { - $id: "NluV3", - type: "object", - additionalProperties: false, - required: ["intent", "confidence", "language", "entities", "needs"], - properties: { - intent: { - type: "string", - enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"], - }, - confidence: { type: "number", minimum: 0, maximum: 1 }, - language: { type: "string" }, - entities: { - type: "object", - additionalProperties: false, - required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation", "items"], - properties: { - product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - selection: { - anyOf: [ - { type: "null" }, - { - type: "object", - additionalProperties: false, - required: ["type", "value"], - properties: { - type: { type: "string", enum: ["index", "text", "sku"] }, - value: { type: "string", minLength: 1 }, - }, - }, - ], - }, - attributes: { type: "array", items: { type: "string" } }, - preparation: { type: "array", items: { type: "string" } }, - // Checkout: envío y dirección. (El bot no maneja pagos.) - shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, - address: { anyOf: [{ type: "string" }, { type: "null" }] }, - // Soporte para múltiples productos en un mensaje - items: { - anyOf: [ - { type: "null" }, - { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["product_query"], - properties: { - product_query: { type: "string", minLength: 1 }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - }, - }, - }, - ], - }, - }, - }, - needs: { - type: "object", - additionalProperties: false, - required: ["catalog_lookup", "knowledge_lookup"], - properties: { - catalog_lookup: { type: "boolean" }, - knowledge_lookup: { type: "boolean" }, - }, - }, - }, -}; - -const ajv = new Ajv({ allErrors: true, strict: true }); -const validateNluV3 = ajv.compile(NluV3JsonSchema); - -const RecommendWriterSchema = { - $id: "RecommendWriter", - type: "object", - additionalProperties: false, - required: ["reply"], - properties: { - reply: { type: "string", minLength: 1 }, - suggested_actions: { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["type"], - properties: { - type: { type: "string", enum: ["add_to_cart"] }, - product_id: { anyOf: [{ type: "number" }, { type: "null" }] }, - quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string" }, { type: "null" }] }, - }, - }, - }, - }, -}; - -const validateRecommendWriter = ajv.compile(RecommendWriterSchema); - -function normalizeUnitValue(unit) { - if (!unit) return null; - const u = String(unit).trim().toLowerCase(); - if (["kg", "kilo", "kilos", "kgs", "kg.", "kilogramo", "kilogramos"].includes(u)) return "kg"; - if (["g", "gr", "gr.", "gramo", "gramos"].includes(u)) return "g"; - if (["unidad", "unidades", "unit", "u"].includes(u)) return "unidad"; - return null; -} - -function inferSelectionFromText(text) { - const t = String(text || "").toLowerCase(); - const m = /\b(\d{1,2})\b/.exec(t); - if (m) return { type: "index", value: String(m[1]) }; - if (/\bprimera\b|\bprimero\b/.test(t)) return { type: "index", value: "1" }; - if (/\bsegunda\b|\bsegundo\b/.test(t)) return { type: "index", value: "2" }; - if (/\btercera\b|\btercero\b/.test(t)) return { type: "index", value: "3" }; - if (/\bcuarta\b|\bcuarto\b/.test(t)) return { type: "index", value: "4" }; - if (/\bquinta\b|\bquinto\b/.test(t)) return { type: "index", value: "5" }; - if (/\bsexta\b|\bsexto\b/.test(t)) return { type: "index", value: "6" }; - if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return { type: "index", value: "7" }; - if (/\boctava\b|\boctavo\b/.test(t)) return { type: "index", value: "8" }; - if (/\bnovena\b|\bnoveno\b/.test(t)) return { type: "index", value: "9" }; - if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return { type: "index", value: "10" }; - return null; -} - -function normalizeNluOutput(parsed, input) { - const base = nluV3Fallback(); - const out = { ...base, ...(parsed && typeof parsed === "object" ? parsed : {}) }; - - if (parsed && typeof parsed === "object") { - if (typeof parsed["needs.catalog_lookup"] === "boolean") { - out.needs = { ...(out.needs || {}), catalog_lookup: parsed["needs.catalog_lookup"] }; - } - if (typeof parsed["needs.knowledge_lookup"] === "boolean") { - out.needs = { ...(out.needs || {}), knowledge_lookup: parsed["needs.knowledge_lookup"] }; - } - } - - out.intent = NluV3JsonSchema.properties.intent.enum.includes(out.intent) ? out.intent : "other"; - out.confidence = Number.isFinite(Number(out.confidence)) ? Number(out.confidence) : 0; - out.language = typeof out.language === "string" && out.language ? out.language : "es-AR"; - - const entities = out.entities && typeof out.entities === "object" ? out.entities : {}; - - // Normalizar items si existe - let normalizedItems = null; - if (Array.isArray(entities.items) && entities.items.length > 0) { - normalizedItems = entities.items - .filter((item) => item && typeof item === "object" && item.product_query) - .map((item) => ({ - product_query: String(item.product_query || "").trim(), - quantity: Number.isFinite(Number(item.quantity)) ? Number(item.quantity) : null, - unit: normalizeUnitValue(item.unit), - })) - .filter((item) => item.product_query.length > 0); - if (normalizedItems.length === 0) normalizedItems = null; - } - - out.entities = { - product_query: entities.product_query ?? null, - quantity: Number.isFinite(Number(entities.quantity)) ? Number(entities.quantity) : entities.quantity ?? null, - unit: normalizeUnitValue(entities.unit), - selection: entities.selection ?? null, - attributes: Array.isArray(entities.attributes) ? entities.attributes : [], - preparation: Array.isArray(entities.preparation) ? entities.preparation : [], - // Checkout entities (opcionales). El bot NO maneja pagos. - shipping_method: entities.shipping_method ?? null, - address: entities.address ?? null, - items: normalizedItems, - }; - - const hasPendingItem = Boolean(input?.pending_context?.pending_item); - const hasShownOptions = Array.isArray(input?.last_shown_options) && input.last_shown_options.length > 0; - - // Solo permitir selection si hay opciones mostradas o pending_clarification - if (hasPendingItem || !hasShownOptions) { - out.entities.selection = null; - } - if (out.entities.selection && typeof out.entities.selection === "object") { - const sel = out.entities.selection; - const valueOk = typeof sel.value === "string" && sel.value.trim().length > 0; - const typeOk = typeof sel.type === "string" && ["index", "text", "sku"].includes(sel.type); - if (!valueOk || !typeOk) { - // Solo inferir selección si hay opciones mostradas y no hay pending_item - const canInfer = hasShownOptions && !hasPendingItem; - const inferred = canInfer ? inferSelectionFromText(input?.last_user_message) : null; - out.entities.selection = inferred || null; -} - } - - out.needs = { - catalog_lookup: Boolean(out.needs?.catalog_lookup), - knowledge_lookup: Boolean(out.needs?.knowledge_lookup), - }; - - return out; -} - -function nluV3Fallback() { - return { - intent: "other", - confidence: 0, - language: "es-AR", - entities: { - product_query: null, - quantity: null, - unit: null, - selection: null, - attributes: [], - preparation: [], - shipping_method: null, - address: null, - items: null, - }, - needs: { catalog_lookup: false, knowledge_lookup: false }, - }; -} - -function nluV3Errors() { - const errs = validateNluV3.errors || []; - return errs.map((e) => ({ - instancePath: e.instancePath, - schemaPath: e.schemaPath, - keyword: e.keyword, - message: e.message, - params: e.params, - })); -} - -export async function llmNluV3({ input, model } = {}) { - const systemBase = - "Sos un servicio NLU (es-AR). Extraés intención y entidades del mensaje del usuario.\n" + - "IMPORTANTE:\n" + - "- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" + - "- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" + - "- Incluí SIEMPRE TODAS las keys requeridas, aunque el valor sea null/[]/false.\n" + - "- Si hay opciones mostradas (last_shown_options no vacío) y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" + - "- IMPORTANTE: Si NO hay opciones mostradas (last_shown_options vacío) y el usuario dice un número + producto (ej: '2 provoletas'), eso es quantity+product_query, NO selection.\n" + - "- selection SOLO aplica cuando hay opciones visibles para seleccionar (last_shown_options tiene elementos).\n" + - "- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" + - "- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart|recommend.\n" + - "\n" + - "JERARQUÍA DE DECISIÓN (en orden de prioridad):\n" + - "1. PREGUNTAS DE PLANIFICACIÓN/CONSEJO → recommend\n" + - " Si el usuario PREGUNTA qué comprar/llevar/necesitar para un evento o situación.\n" + - " Señales: 'qué me recomendás', 'qué llevo', 'qué necesito', 'para X personas', 'para un asado/cumple/evento'.\n" + - " El producto mencionado es CONTEXTO, no algo para agregar directamente.\n" + - " Ejemplos → recommend:\n" + - " - 'quiero hacer un asado para 6, qué me recomendás?' (planificación)\n" + - " - 'para una parrillada de 10 personas qué llevo?' (planificación)\n" + - " - 'qué cortes van bien para 6?' (consejo)\n" + - " - 'qué necesito para un asado?' (planificación)\n" + - " - 'qué vino va bien con carne?' (maridaje/consejo)\n" + - "\n" + - "2. PREGUNTAS SOBRE DISPONIBILIDAD → browse\n" + - " Si el usuario pregunta si hay/venden/tienen un producto.\n" + - " Ejemplos → browse: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'\n" + - "\n" + - "3. PEDIDOS DIRECTOS → add_to_cart\n" + - " Si el usuario AFIRMA que quiere/pide/necesita un producto específico con intención de comprarlo.\n" + - " Señales: 'quiero X', 'dame X', 'anotame X', 'poneme X', cantidad + producto.\n" + - " Ejemplos → add_to_cart:\n" + - " - 'quiero 2kg de asado' (pedido directo con cantidad)\n" + - " - 'dame un vino' (pedido directo)\n" + - " - 'anotame 3 provoletas' (pedido directo)\n" + - " - 'necesito chimichurri' (pedido directo)\n" + - "\n" + - "EJEMPLOS CONTRASTIVOS (importante distinguir):\n" + - "- 'quiero asado' → add_to_cart (afirmación directa de compra)\n" + - "- 'quiero hacer un asado, qué llevo?' → recommend (planificación, pregunta)\n" + - "- 'dame vino' → add_to_cart (pedido directo)\n" + - "- 'qué vino me recomendás?' → recommend (pide consejo)\n" + - "- 'tenés vino?' → browse (pregunta disponibilidad)\n" + - "- '2kg de vacío' → add_to_cart (pedido con cantidad)\n" + - "- 'para 6 personas cuánto vacío necesito?' → recommend (pregunta de planificación)\n" + - "\n" + - "- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" + - "- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" + - "- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" + - "- SELECCIONAR ENVÍO: Si el usuario elige envío (ej: 'delivery', 'envío', 'que me lo traigan', 'retiro', 'paso a buscar'), usá intent='select_shipping'. Extraer entities.shipping_method='delivery'|'pickup'.\n" + - "- DAR DIRECCIÓN: Si el usuario da una dirección de entrega, usá intent='provide_address'. Extraer entities.address con el texto de la dirección.\n" + - "- MULTI-ITEMS: Si el usuario menciona MÚLTIPLES productos en un mensaje (ej: '1 chimichurri y 2 provoletas'), usá entities.items con array de objetos.\n" + - " Ejemplo: items:[{product_query:'chimichurri',quantity:1,unit:null},{product_query:'provoletas',quantity:2,unit:null}]\n" + - " En este caso, product_query/quantity/unit del nivel superior quedan null.\n" + - "- Si es UN SOLO producto, usá product_query/quantity/unit normalmente e items=null.\n" + - "FORMATO JSON ESTRICTO (ejemplo, usá null/[]/false si no hay datos):\n" + - "{\n" + - " \"intent\":\"other\",\n" + - " \"confidence\":0,\n" + - " \"language\":\"es-AR\",\n" + - " \"entities\":{\n" + - " \"product_query\":null,\n" + - " \"quantity\":null,\n" + - " \"unit\":null,\n" + - " \"selection\":null,\n" + - " \"attributes\":[],\n" + - " \"preparation\":[],\n" + - " \"items\":null\n" + - " },\n" + - " \"needs\":{\n" + - " \"catalog_lookup\":false,\n" + - " \"knowledge_lookup\":false\n" + - " }\n" + - "}\n"; - - const user = JSON.stringify(input ?? {}); - - // intento 1 - const first = await jsonCompletion({ system: systemBase, user, model }); -const firstNormalized = normalizeNluOutput(first.parsed, input); -const validationResult = validateNluV3(firstNormalized); -if (validationResult) { - return { nlu: firstNormalized, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } }; - } - - const errors1 = nluV3Errors(); -// retry 1 vez - const systemRetry = - systemBase + - "\nTu respuesta anterior no validó el JSON Schema. Corregí el JSON para que cumpla estrictamente.\n" + - `Errores: ${JSON.stringify(errors1).slice(0, 1800)}\n`; - - try { - const second = await jsonCompletion({ system: systemRetry, user, model }); - const secondNormalized = normalizeNluOutput(second.parsed, input); -if (validateNluV3(secondNormalized)) { - return { nlu: secondNormalized, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } }; - } - const errors2 = nluV3Errors(); -return { - nlu: nluV3Fallback(), - raw_text: second.raw_text, - model: second.model, - usage: second.usage, - schema: "v3", - validation: { ok: false, retried: true, errors: errors2 }, - }; - } catch (e) { - return { - nlu: nluV3Fallback(), - raw_text: first.raw_text, - model: first.model, - usage: first.usage, - schema: "v3", - validation: { ok: false, retried: true, error: String(e?.message || e), errors: errors1 }, - }; - } -} - -export async function llmRecommendWriter({ - base_item, - slots = {}, - candidates = [], - locale = "es-AR", - model, -} = {}) { - const system = - "Sos un redactor de recomendaciones (es-AR). Solo podés usar productos de la lista.\n" + - "NO inventes productos ni precios. Devolvé SOLO JSON con este formato:\n" + - "{\n" + - " \"reply\": \"texto final\",\n" + - " \"suggested_actions\": [\n" + - " {\"type\":\"add_to_cart\",\"product_id\":123,\"quantity\":null,\"unit\":null}\n" + - " ]\n" + - "}\n" + - "Si no sugerís acciones, usá suggested_actions: [].\n"; - const user = JSON.stringify({ - locale, - base_item, - slots, - candidates: candidates.map((c) => ({ - woo_product_id: c?.woo_product_id || null, - name: c?.name || null, - price: c?.price ?? null, - categories: c?.categories || [], - })), - }); - const first = await jsonCompletion({ system, user, model }); - if (validateRecommendWriter(first.parsed)) { - return { - reply: first.parsed.reply, - suggested_actions: first.parsed.suggested_actions || [], - raw_text: first.raw_text, - model: first.model, - usage: first.usage, - validation: { ok: true }, - }; - } - return { - reply: null, - suggested_actions: [], - raw_text: first.raw_text, - model: first.model, - usage: first.usage, - validation: { ok: false, errors: validateRecommendWriter.errors || [] }, - }; -} - -// --- Planning Recommendation LLM --- - -const PlanningRecommendSchema = { - $id: "PlanningRecommend", - type: "object", - additionalProperties: false, - required: ["reply", "suggested_items"], - properties: { - reply: { type: "string", minLength: 1 }, - suggested_items: { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["product_query", "suggested_qty", "unit", "reason"], - properties: { - product_query: { type: "string", minLength: 1 }, - suggested_qty: { anyOf: [{ type: "number" }, { type: "null" }] }, - unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, - reason: { type: "string" }, - }, - }, - }, - }, -}; - -const validatePlanningRecommend = ajv.compile(PlanningRecommendSchema); - -/** - * LLM para recomendaciones de planificación (eventos, asados, etc.) - * Genera sugerencias de productos y cantidades basadas en el contexto. - */ -export async function llmPlanningRecommend({ - user_message, - event_type = null, - people_count = null, - cooking_method = null, - mentioned_products = [], - available_categories = [], - locale = "es-AR", - model, -} = {}) { - const system = - "Sos un experto en carnicería y asados argentinos (es-AR). Tu rol es recomendar productos y cantidades.\n\n" + - "CONTEXTO:\n" + - "- Trabajás en una carnicería online.\n" + - "- El cliente te pide ayuda para planificar una comida/evento.\n" + - "- Debés sugerir productos disponibles y cantidades razonables.\n\n" + - "REGLAS DE CANTIDADES (por persona, aproximado):\n" + - "- Asado/Parrilla: 400-500g de carne total por persona\n" + - "- Horno: 300-400g de carne por persona\n" + - "- Mezcla sugerida para asado:\n" + - " * 200g de asado de tira o costilla\n" + - " * 150g de vacío o entraña\n" + - " * 50-100g de chorizo/morcilla (1 unidad c/u cada 2-3 personas)\n" + - " * 1 provoleta cada 3-4 personas\n" + - " * Chimichurri: 1 frasco cada 6-8 personas\n" + - "- Vino: 1 botella cada 2-3 personas\n\n" + - "REGLAS DE RESPUESTA:\n" + - "- Usá product_query con términos genéricos que el catálogo pueda buscar (ej: 'asado', 'vacío', 'chorizo').\n" + - "- NO inventes productos específicos, usá nombres genéricos.\n" + - "- Incluí un 'reason' breve para cada sugerencia.\n" + - "- IMPORTANTE: En 'reply' escribí un mensaje COMPLETO que incluya la lista de productos con cantidades.\n" + - " El reply debe ser autosuficiente, con formato:\n" + - " 'Para [X] personas te recomiendo:\\n- 2kg de asado de tira\\n- 1kg de vacío\\n- 2 chorizos\\n...'\n" + - "- Si el cliente pregunta por PRECIOS, respondé que vas a buscar los productos para mostrarle los precios.\n" + - "- Si pregunta por método de cocción (horno vs parrilla), explicá brevemente y sugerí cortes apropiados.\n\n" + - "FORMATO JSON ESTRICTO:\n" + - "{\n" + - " \"reply\": \"Para 6 personas te recomiendo:\\n- 2kg de asado de tira\\n- 1.5kg de vacío\\n- 3 chorizos\\n- 3 morcillas\\n- 2 provoletas\\n\\n¿Querés que te arme el pedido con esto?\",\n" + - " \"suggested_items\": [\n" + - " {\"product_query\": \"asado\", \"suggested_qty\": 2, \"unit\": \"kg\", \"reason\": \"base del asado\"},\n" + - " {\"product_query\": \"vacío\", \"suggested_qty\": 1.5, \"unit\": \"kg\", \"reason\": \"corte tierno\"}\n" + - " ]\n" + - "}\n" + - "suggested_items se usa para buscar en el catálogo. Si no hay items, usá suggested_items: [].\n"; - - const userPayload = { - locale, - user_message, - context: { - event_type, - people_count, - cooking_method, - mentioned_products, - }, - available_categories: available_categories.slice(0, 30), - }; - - const first = await jsonCompletion({ system, user: JSON.stringify(userPayload), model }); - - if (validatePlanningRecommend(first.parsed)) { - return { - reply: first.parsed.reply, - suggested_items: first.parsed.suggested_items || [], - raw_text: first.raw_text, - model: first.model, - usage: first.usage, - validation: { ok: true }, - }; - } - - // Retry con errores - const errors = validatePlanningRecommend.errors || []; - const systemRetry = - system + - "\nTu respuesta anterior no validó. Corregí el JSON.\n" + - `Errores: ${JSON.stringify(errors).slice(0, 1000)}\n`; - - try { - const second = await jsonCompletion({ system: systemRetry, user: JSON.stringify(userPayload), model }); - if (validatePlanningRecommend(second.parsed)) { - return { - reply: second.parsed.reply, - suggested_items: second.parsed.suggested_items || [], - raw_text: second.raw_text, - model: second.model, - usage: second.usage, - validation: { ok: true, retried: true }, - }; - } - } catch (e) { - // Fallback - } - - // Fallback: usar el reply si existe - const fallbackReply = first.parsed?.reply || "Dejame buscar algunas opciones para vos."; - return { - reply: fallbackReply, - suggested_items: [], - raw_text: first.raw_text, - model: first.model, - usage: first.usage, - validation: { ok: false, errors }, - }; -} diff --git a/src/modules/3-turn-engine/recommendations.js b/src/modules/3-turn-engine/recommendations.js deleted file mode 100644 index cce6036..0000000 --- a/src/modules/3-turn-engine/recommendations.js +++ /dev/null @@ -1,511 +0,0 @@ -import { getRecoRules, getRecoRulesByProductIds, getProductQtyRulesByEvent } from "../2-identity/db/repo.js"; -import { getSnapshotItemsByIds, searchSnapshotItems } from "../shared/wooSnapshot.js"; -import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js"; -import { llmPlanningRecommend } from "./openai.js"; -import { retrieveCandidates } from "./catalogRetrieval.js"; - -/** - * Extrae los IDs de productos del carrito. - */ -function getBasketProductIds(basket_items) { - const items = Array.isArray(basket_items) ? basket_items : []; - return items - .map(item => item.product_id || item.woo_product_id || item.woo_id) - .filter(id => id != null) - .map(Number); -} - -/** - * Obtiene los IDs de productos recomendados de las reglas que matchean. - */ -function collectRecommendedIds(rules, excludeIds = []) { - const excludeSet = new Set(excludeIds); - const ids = new Set(); - for (const rule of rules) { - const recoIds = Array.isArray(rule.recommended_product_ids) ? rule.recommended_product_ids : []; - for (const id of recoIds) { - if (!excludeSet.has(id)) { - ids.add(id); - } - } - } - return [...ids]; -} - -/** - * Detecta si el mensaje es una solicitud de planificación/consejo. - */ -function detectPlanningRequest(text, nlu) { - const t = String(text || "").toLowerCase(); - - // Patrones de planificación - const planningPatterns = [ - /\bpara\s+(\d+)\s*(personas?|comensales?|invitados?)\b/i, - /\bqu[eé]\s+(me\s+)?recomend[aá]s?\b/i, - /\bqu[eé]\s+(necesito|llevo|compro)\b/i, - /\bcu[aá]nto\s+(necesito|llevo|compro)\b/i, - /\bpara\s+(un|una|el|la)\s+(asado|parrilla|parrillada|horno|sangu[ií]?che?s?|reuni[oó]n|evento|juntada|fiesta)\b/i, - /\bqu[eé]\s+cortes?\b/i, - /\bqu[eé]\s+vino?\s+(va|combina|queda)\b/i, - /\bmaridaje\b/i, - /\bqu[eé]\s+(llevo|necesito|compro)\s+para\b/i, - /\bcomo\s+para\s+\d+/i, - ]; - - for (const pattern of planningPatterns) { - if (pattern.test(t)) return true; - } - - // Si el NLU es recommend y no hay productos específicos en el carrito, es planificación - if (nlu?.intent === "recommend") { - return true; - } - - return false; -} - -/** - * Extrae información de planificación del texto. - */ -function extractPlanningInfo(text) { - const t = String(text || "").toLowerCase(); - const info = { - people_count: null, - adults_count: null, - children_count: null, - event_type: null, - cooking_method: null, - mentioned_products: [], - }; - - // Cantidad de adultos - const adultsMatch = t.match(/\b(\d+)\s*(adultos?|grandes?|mayores?)\b/i); - if (adultsMatch) { - info.adults_count = parseInt(adultsMatch[1], 10); - } - - // Cantidad de niños - const childrenMatch = t.match(/\b(\d+)\s*(ni[nñ]os?|chicos?|menores?|peques?|hijos?)\b/i); - if (childrenMatch) { - info.children_count = parseInt(childrenMatch[1], 10); - } - - // Cantidad total de personas (si no especificó adultos/niños) - const peopleMatch = t.match(/\b(\d+)\s*(personas?|comensales?|invitados?)\b/i) || - t.match(/\bpara\s+(\d+)\b/) || - t.match(/\bcomo\s+para\s+(\d+)\b/i); - if (peopleMatch) { - info.people_count = parseInt(peopleMatch[1], 10); - } - - // Si especificó adultos y niños pero no total, calcularlo - if (info.adults_count !== null || info.children_count !== null) { - info.people_count = (info.adults_count || 0) + (info.children_count || 0); - } - - // Si solo especificó total, asumir todos adultos - if (info.people_count && info.adults_count === null && info.children_count === null) { - info.adults_count = info.people_count; - info.children_count = 0; - } - - // Tipo de evento (asado, horno, sanguches) - if (/\basado\b|\bparrilla(da)?\b/i.test(t)) info.event_type = "asado"; - else if (/\bhorno\b/i.test(t)) info.event_type = "horno"; - else if (/\bsangu[ií]?che?s?\b|\bsandwich(es)?\b/i.test(t)) info.event_type = "sanguches"; - - // Método de cocción - if (/\bparrilla\b|\bbrasa\b|\bcarbón\b/i.test(t)) info.cooking_method = "parrilla"; - else if (/\bhorno\b/i.test(t)) info.cooking_method = "horno"; - else if (/\bplancha\b/i.test(t)) info.cooking_method = "plancha"; - - // Productos mencionados (keywords comunes) - const productKeywords = ["asado", "vacío", "vacio", "entraña", "entrania", "chorizo", "morcilla", - "provoleta", "chimichurri", "vino", "tira", "costilla", "bife", "lomo", "matambre", - "pollo", "cerdo", "bondiola", "carne"]; - for (const kw of productKeywords) { - if (t.includes(kw)) { - info.mentioned_products.push(kw); - } - } - - return info; -} - -/** - * Maneja recomendaciones de planificación usando reglas de BD o LLM como fallback. - */ -async function handlePlanningRecommend({ tenantId, text, nlu, order, audit }) { - const planningInfo = extractPlanningInfo(text); - audit.planning_info = planningInfo; - - const adultsCount = planningInfo.adults_count || planningInfo.people_count || 1; - const childrenCount = planningInfo.children_count || 0; - const totalPeople = adultsCount + childrenCount; - const eventType = planningInfo.event_type || "asado"; // Default asado - - // 1) Buscar reglas de cantidad desde la nueva tabla product_qty_rules - const qtyRules = await getProductQtyRulesByEvent({ tenant_id: tenantId, event_type: eventType }); - audit.qty_rules_found = qtyRules.length; - - // Si hay reglas configuradas, usarlas en lugar del LLM - if (qtyRules.length > 0) { - audit.using_rules = { event: eventType, count: qtyRules.length }; - - // Agrupar por producto y calcular cantidades según tipo de persona - const productQtyMap = new Map(); // woo_product_id -> { qty, unit, product } - - for (const rule of qtyRules) { - const qtyPerPerson = Number(rule.qty_per_person) || 0; - const personType = rule.person_type || "adult"; - - // Calcular cantidad según tipo de persona - let calculatedQty = 0; - if (personType === "adult") { - calculatedQty = qtyPerPerson * adultsCount; - } else if (personType === "child") { - calculatedQty = qtyPerPerson * childrenCount; - } - - if (calculatedQty <= 0) continue; - - const key = rule.woo_product_id; - - if (productQtyMap.has(key)) { - // Sumar cantidad al existente - const existing = productQtyMap.get(key); - existing.qty += calculatedQty; - } else { - productQtyMap.set(key, { - woo_product_id: rule.woo_product_id, - qty: calculatedQty, - unit: rule.unit || "kg", - }); - } - } - - // Obtener info de productos del catálogo - const productIds = [...productQtyMap.keys()]; - const { items: products } = await getSnapshotItemsByIds({ - tenantId, - wooProductIds: productIds, - }); - - const productMap = new Map(); - for (const p of products) { - productMap.set(p.woo_product_id, p); - } - - // Convertir a pendingItems - const pendingItems = []; - for (const [wooId, data] of productQtyMap) { - const product = productMap.get(wooId); - if (!product) continue; - - const roundedQty = Math.round(data.qty * 100) / 100; // Redondear a 2 decimales - pendingItems.push({ - query: product.name, - suggested_qty: roundedQty, - suggested_unit: data.unit, - reason: "", - candidates: [{ - woo_id: product.woo_product_id, - name: product.name, - price: product.price, - }], - }); - } - - audit.pending_items_created = pendingItems.length; - - // Construir respuesta con detalle de adultos/niños si aplica - let headerLine = ""; - if (childrenCount > 0) { - headerLine = `Para ${adultsCount} adulto${adultsCount > 1 ? "s" : ""} y ${childrenCount} niño${childrenCount > 1 ? "s" : ""}, te recomiendo:`; - } else { - headerLine = `Para ${totalPeople} persona${totalPeople > 1 ? "s" : ""}, te recomiendo:`; - } - - let reply = headerLine + "\n\n"; - - const lines = pendingItems.map(item => { - const qtyStr = item.suggested_unit === "unidad" - ? `${item.suggested_qty} unidad${item.suggested_qty > 1 ? "es" : ""}` - : `${item.suggested_qty}${item.suggested_unit}`; - return `• ${item.candidates[0]?.name}: ${qtyStr}`; - }); - reply += lines.join("\n"); - - // Agregar precios si están disponibles - const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null); - if (itemsWithPrices.length > 0) { - reply += "\n\n¿Querés que te arme el pedido?"; - - const priceLines = itemsWithPrices.map(item => { - const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit}`; - return `• ${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`; - }).join("\n"); - - reply += "\n\n📋 *Precios actuales:*\n" + priceLines; - } - - return { - plan: { - reply, - next_state: null, - intent: "recommend", - missing_fields: [], - order_action: "none", - }, - decision: { - actions: [], - order, - audit, - context_patch: { - planning_suggestions: pendingItems, - }, - }, - }; - } - - // 2) Fallback: usar LLM si no hay reglas configuradas - audit.fallback_to_llm = true; - - // Obtener categorías disponibles para contexto - const categoryResult = await searchSnapshotItems({ tenantId, q: "", limit: 50 }); - const categories = new Set(); - for (const item of (categoryResult?.items || [])) { - for (const cat of (item.categories || [])) { - if (cat?.name) categories.add(cat.name); - } - } - - // Llamar al LLM de planificación - const llmResult = await llmPlanningRecommend({ - user_message: text, - event_type: planningInfo.event_type, - people_count: planningInfo.people_count, - cooking_method: planningInfo.cooking_method, - mentioned_products: planningInfo.mentioned_products, - available_categories: [...categories], - }); - - audit.planning_llm = { - model: llmResult.model, - usage: llmResult.usage, - suggested_count: llmResult.suggested_items?.length || 0, - validation: llmResult.validation, - }; - - // Si hay items sugeridos, buscar en el catálogo y crear pending items - const suggestedItems = llmResult.suggested_items || []; - const pendingItems = []; - - for (const suggestion of suggestedItems.slice(0, 8)) { - const searchResult = await retrieveCandidates({ - tenantId, - query: suggestion.product_query, - limit: 5 - }); - const candidates = searchResult?.candidates || []; - - if (candidates.length > 0) { - pendingItems.push({ - query: suggestion.product_query, - suggested_qty: suggestion.suggested_qty, - suggested_unit: suggestion.unit, - reason: suggestion.reason, - candidates: candidates.slice(0, 5).map(c => ({ - woo_id: c.woo_product_id, - name: c.name, - price: c.price, - })), - }); - } - } - - audit.pending_items_created = pendingItems.length; - - // Usar el reply del LLM directamente (ya incluye la lista de productos) - let reply = llmResult.reply || "Te ayudo con eso."; - - // Si encontramos items en el catálogo, agregar precios reales - if (pendingItems.length > 0) { - // Solo agregar info de precios si el catálogo tiene datos - const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null); - - if (itemsWithPrices.length > 0) { - // Si el reply NO termina con pregunta de si quiere agregar, añadirla - if (!/\?[\s]*$/.test(reply)) { - reply += "\n\n¿Querés que te arme el pedido?"; - } - - // Agregar precios reales del catálogo - const priceLines = itemsWithPrices.map(item => { - const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit || "kg"}`; - return `• ${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`; - }).join("\n"); - - reply += "\n\n📋 *Precios actuales:*\n" + priceLines; - } - } - - return { - plan: { - reply, - next_state: null, // Se determinará por el caller - intent: "recommend", - missing_fields: [], - order_action: "none", - }, - decision: { - actions: [], - order, - audit, - context_patch: { - planning_suggestions: pendingItems, - }, - }, - }; -} - -/** - * Maneja recomendaciones de cross-sell basadas en el carrito. - */ -async function handleCrossSellRecommend({ tenantId, text, order, basket_items, limit, audit }) { - const context_patch = {}; - - // 1. Obtener IDs de productos en el carrito - const basketProductIds = getBasketProductIds(basket_items); - audit.basket_product_ids = basketProductIds; - - if (!basketProductIds.length) { - // No hay items, delegar a planificación - return null; - } - - // 2. Buscar reglas que matcheen con los productos del carrito - const rules = await getRecoRulesByProductIds({ tenant_id: tenantId, product_ids: basketProductIds }); - audit.rules_used = rules.map((r) => ({ id: r.id, rule_key: r.rule_key, priority: r.priority })); - - if (!rules.length) { - // Fallback: no hay reglas configuradas para estos productos - const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", "); - return { - plan: { - reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`, - next_state: null, - intent: "recommend", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit, context_patch }, - }; - } - - // 3. Obtener IDs de productos recomendados (excluyendo los que ya están en el carrito) - const recommendedIds = collectRecommendedIds(rules, basketProductIds); - audit.recommended_ids = recommendedIds; - - if (!recommendedIds.length) { - return { - plan: { - reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?", - next_state: null, - intent: "recommend", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit, context_patch }, - }; - } - - // 4. Obtener detalles de los productos recomendados - const recommendedResult = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) }); - const recommendedProducts = recommendedResult?.items || []; - - if (!recommendedProducts.length) { - return { - plan: { - reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?", - next_state: null, - intent: "recommend", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit, context_patch }, - }; - } - - // 5. Construir respuesta con opciones - const { question, pending } = buildPagedOptions({ candidates: recommendedProducts, pageSize: Math.min(9, limit) }); - - // Personalizar el mensaje según lo que tiene en el carrito - const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 2).join(" y "); - const intro = basketNames - ? `Para acompañar ${basketNames}, te recomiendo:` - : "Te recomiendo estos productos:"; - - const reply = `${intro}\n\n${question}`; - - context_patch.pending_clarification = pending; - context_patch.pending_item = null; - - return { - plan: { - reply, - next_state: null, - intent: "recommend", - missing_fields: [], - order_action: "none", - }, - decision: { - actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }], - order, - audit, - context_patch, - }, - }; -} - -/** - * Handler principal de recomendaciones. - * Detecta si es planificación o cross-sell y delega al handler apropiado. - */ -export async function handleRecommend({ - tenantId, - text, - nlu, - order, - prevContext = {}, - basket_items = [], - limit = 9, - audit = {}, -} = {}) { - audit.recommendation_type = null; - - // Extraer basket_items del order si no se pasan explícitamente - const cartItems = basket_items.length > 0 - ? basket_items - : (order?.cart || []).map(item => ({ - woo_product_id: item.woo_id, - name: item.name, - label: item.name, - })); - - // Detectar si es planificación - const isPlanningRequest = detectPlanningRequest(text, nlu); - - // Si hay items en el carrito y no es claramente planificación, intentar cross-sell primero - if (cartItems.length > 0 && !isPlanningRequest) { - audit.recommendation_type = "cross_sell"; - const crossSellResult = await handleCrossSellRecommend({ - tenantId, text, order, basket_items: cartItems, limit, audit - }); - if (crossSellResult) return crossSellResult; - } - - // Planificación (carrito vacío o solicitud explícita de consejo) - audit.recommendation_type = "planning"; - return handlePlanningRecommend({ tenantId, text, nlu, order, audit }); -} diff --git a/src/modules/3-turn-engine/replyRewriter.js b/src/modules/3-turn-engine/replyRewriter.js deleted file mode 100644 index 64b82c5..0000000 --- a/src/modules/3-turn-engine/replyRewriter.js +++ /dev/null @@ -1,229 +0,0 @@ -/** - * 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; - -// Métricas exportadas para observabilidad. Se logean cada N rewrites. -const _metrics = { ok: 0, fallback: 0, timeouts: 0, totalMs: 0 }; - -export function getRewriterMetrics() { - const total = _metrics.ok + _metrics.fallback; - return { - rewrites_ok: _metrics.ok, - rewrites_fallback: _metrics.fallback, - rewrites_timeout: _metrics.timeouts, - fallback_rate: total ? _metrics.fallback / total : 0, - avg_ms: _metrics.ok ? Math.round(_metrics.totalMs / _metrics.ok) : 0, - }; -} - -export function resetRewriterMetrics() { - _metrics.ok = 0; - _metrics.fallback = 0; - _metrics.timeouts = 0; - _metrics.totalMs = 0; -} - -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() }); - _metrics.ok++; - _metrics.totalMs += (Date.now() - t0); - return result; - } catch (err) { - const msg = String(err?.message || err); - _metrics.fallback++; - if (msg.includes("timeout")) _metrics.timeouts++; - 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 deleted file mode 100644 index 5dde675..0000000 --- a/src/modules/3-turn-engine/replyTemplates.js +++ /dev/null @@ -1,311 +0,0 @@ -/** - * 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"; -import { getTenantId } from "../shared/tenant.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.address_out_of_zone": [ - "Esa dirección queda fuera de la zona de delivery. Hacemos entregas en {{delivery_zones_summary}}. ¿Probás otra dirección o pasás a buscar?", - "No llegamos hasta ahí. Cubrimos {{delivery_zones_summary}}. ¿Querés cambiar la dirección o retiro en sucursal?", - ], - // ---------------- ORDER CLOSE ---------------- - "order.confirmed": [ - "¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.", - "Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.", - "Genial, anotado. Cualquier ajuste avisame por acá.", - ], -}; - -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 tid = tenantId || getTenantId(); - const cacheKey = `${tid}:${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: tid, 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, -} = {}) { - // tenantId opcional: defaultea al cacheado al boot (mono-tenant). - const tid = tenantId || getTenantId(); - const variants = await loadReplyVariants({ tenantId: tid, 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", - "order.confirmed", -]); - -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 deleted file mode 100644 index ea3005f..0000000 --- a/src/modules/3-turn-engine/replyTemplates.test.js +++ /dev/null @@ -1,136 +0,0 @@ -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.js b/src/modules/3-turn-engine/stateHandlers.js deleted file mode 100644 index 36322a6..0000000 --- a/src/modules/3-turn-engine/stateHandlers.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * State Handlers - Re-export desde módulo refactorizado. - * - * Estructura: - * - stateHandlers/utils.js - Utilidades de parseo y detección de texto - * - stateHandlers/cartHelpers.js - Helpers para manejo del carrito - * - stateHandlers/idle.js - Handler estado IDLE - * - stateHandlers/cart.js - Handler estado CART - * - stateHandlers/shipping.js - Handler estado SHIPPING (cierra orden) - */ - -export { - handleIdleState, - handleCartState, - handleShippingState, - - inferDefaultUnit, - parseIndexSelection, - isShowMoreRequest, - isShowOptionsRequest, - findMatchingCandidate, - isEscapeRequest, - normalizeUnit, - unitAskFor, - - extractProductQueries, - createPendingItemFromSearch, - processPendingClarification, -} from "./stateHandlers/index.js"; diff --git a/src/modules/3-turn-engine/stateHandlers/cart.js b/src/modules/3-turn-engine/stateHandlers/cart.js deleted file mode 100644 index e1be74d..0000000 --- a/src/modules/3-turn-engine/stateHandlers/cart.js +++ /dev/null @@ -1,710 +0,0 @@ -/** - * Handler para el estado CART - * Maneja: view_cart, remove_from_cart, confirm_order, recommend, price_query, add_to_cart - */ - -import { retrieveCandidates } from "../catalogRetrieval.js"; -import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "../fsm.js"; -import { - createEmptyOrder, - PendingStatus, - moveReadyToCart, - getNextPendingItem, - updatePendingItem, - addPendingItem, - formatCartForDisplay, - formatOptionsForDisplay, - removeCartItem, -} from "../orderModel.js"; -import { handleRecommend } from "../recommendations.js"; -import { getProductQtyRules } from "../../0-ui/db/repo.js"; -import { inferDefaultUnit, unitAskFor } from "./utils.js"; -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, 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"]; - const isPriorityIntent = priorityIntents.includes(intent); - - // Detectar si el usuario quiere cancelar/saltar el pending item actual - const pendingItem = getNextPendingItem(currentOrder); - const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i; - const wantsToSkipPending = pendingItem && cancelPhrases.test(text || ""); - - // Si quiere saltar el pending - PERO solo si NO es un intent prioritario - if (wantsToSkipPending && pendingItem && !isPriorityIntent) { - 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, recentReplies, failedSearches, rewriteCtx }); - if (result) return result; - } - - // 2) view_cart: mostrar carrito actual - if (intent === "view_cart") { - 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, recentReplies, rewriteCtx }); - } - - // 3) confirm_order: ir a SHIPPING si hay items - if (intent === "confirm_order") { - 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, 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, recentReplies, failedSearches, rewriteCtx }); - } - - // Default - const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.CART, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; -} - -/** - * Maneja el skip de un pending item - */ -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: `${skipAck.reply} ${question}`, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["product_selection"], - order_action: "none", - }, - 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: `${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, - template_ids_used: [skipAck.template_id, askMore.template_id], - }, - }; -} - -/** - * Maneja view_cart - */ -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)`; - } - const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); - reply += `\n\n${askMore.reply}`; - - return { - plan: { - reply, - next_state: ConversationState.CART, - intent: "view_cart", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit: {}, template_ids_used: [askMore.template_id] }, - }; -} - -/** - * Maneja remove_from_cart - */ -async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx }) { - const items = nlu?.entities?.items || []; - const removedItems = []; - const addedItems = []; - const notFoundItems = []; - let updatedOrder = currentOrder; - - for (const item of items) { - if (!item.product_query) continue; - - const { order: orderAfterRemove, removed } = removeCartItem(updatedOrder, item.product_query); - - if (removed) { - removedItems.push(removed.name || item.product_query); - updatedOrder = orderAfterRemove; - } else { - notFoundItems.push(item.product_query); - } - - if (item.quantity && item.quantity > 0) { - addedItems.push({ query: item.product_query, qty: item.quantity, unit: item.unit }); - } - } - - // Si hay items para agregar - if (addedItems.length > 0) { - for (const addItem of addedItems) { - const searchResult = await retrieveCandidates({ tenantId, query: addItem.query, limit: 20 }); - const candidates = searchResult?.candidates || []; - - const pendingItem = createPendingItemFromSearch({ - query: addItem.query, - quantity: addItem.qty, - unit: addItem.unit, - candidates, - }); - - updatedOrder = addPendingItem(updatedOrder, pendingItem); - } - updatedOrder = moveReadyToCart(updatedOrder); - } - - // Generar respuesta - let reply = ""; - if (removedItems.length > 0) { - reply += `Listo, saqué: ${removedItems.join(", ")}. `; - } - if (notFoundItems.length > 0 && removedItems.length === 0) { - reply += `No encontré "${notFoundItems.join(", ")}" en tu carrito. `; - } - - const nextPending = getNextPendingItem(updatedOrder); - if (nextPending && nextPending.status === PendingStatus.NEEDS_TYPE) { - const { question } = formatOptionsForDisplay(nextPending); - reply += question; - return { - plan: { - reply: reply.trim(), - next_state: ConversationState.CART, - intent: "remove_from_cart", - missing_fields: ["product_selection"], - order_action: removedItems.length > 0 ? "remove_from_cart" : "none", - }, - decision: { - actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], - order: updatedOrder, - audit - }, - }; - } - - const cartDisplay = formatCartForDisplay(updatedOrder); - const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); - reply += `\n\n${cartDisplay}\n\n${askMore.reply}`; - - return { - plan: { - reply: reply.trim(), - next_state: ConversationState.CART, - intent: "remove_from_cart", - 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, - template_ids_used: [askMore.template_id], - }, - }; -} - -/** - * Maneja confirm_order - */ -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: r.reply, - next_state: ConversationState.CART, - intent: "confirm_order", - missing_fields: ["cart_items"], - order_action: "none", - }, - 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: `${r.reply}\n\n${question}`, - next_state: ConversationState.CART, - intent: "confirm_order", - missing_fields: ["pending_items"], - order_action: "none", - }, - 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: `${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, template_ids_used: [r.template_id] }, - }; -} - -/** - * Maneja recommend - */ -async function handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit }) { - try { - const recoResult = await handleRecommend({ - tenantId, - text, - nlu, - order: currentOrder, - prevContext: { order: currentOrder }, - audit - }); - if (recoResult?.plan?.reply) { - const newOrder = recoResult.decision?.order || currentOrder; - const contextPatch = recoResult.decision?.context_patch || {}; - return { - plan: { - ...recoResult.plan, - next_state: ConversationState.CART, - }, - decision: { - actions: recoResult.decision?.actions || [], - order: newOrder, - audit, - context_patch: contextPatch, - }, - }; - } - } catch (e) { - audit.recommend_error = String(e?.message || e); - } - return null; -} - -/** - * Detecta si el usuario pregunta por el total del carrito actual - */ -function isCartTotalQuery(nlu) { - const query = nlu?.entities?.product_query || ""; - const q = query.trim().toLowerCase(); - - // Patrones que indican consulta sobre el carrito actual - const cartKeywords = [ - "todo", "el total", "total", "mi pedido", "el pedido", "precio total", - "lo que tengo", "lo que llevo", "lo que estoy pidiendo", "lo que pedí", - "en el carrito", "del carrito", "mi carrito", "el carrito", - "lo que voy", "hasta ahora", "hasta el momento", - ]; - - // Si la query contiene alguna de estas frases, es consulta del carrito - if (cartKeywords.some(kw => q.includes(kw))) { - return true; - } - - // Patrones regex adicionales - const patterns = [ - /^cu[aá]nto (es|sale|cuesta|est[aá])/i, - /^precio/i, - ]; - return patterns.some(p => p.test(q)); -} - -/** - * Maneja price_query - */ -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: r.reply, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - - // Calcular y mostrar el total - let total = 0; - const lines = cartItems.map(item => { - const itemTotal = (item.price || 0) * (item.qty || 0); - total += itemTotal; - const unitStr = item.unit === "unit" ? "" : " kg"; - return `• ${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`; - }); - - 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, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: [], - order_action: "none", - }, - 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 || []; - if (cartItems.length > 0) { - let total = 0; - const lines = cartItems.map(item => { - const itemTotal = (item.price || 0) * (item.qty || 0); - total += itemTotal; - const unitStr = item.unit === "unit" ? "" : " kg"; - 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¿Querés saber el precio de algún producto específico?`; - return { - plan: { - reply, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - const r = await renderReply({ tenantId, templateKey: "cart.price_no_query", recentReplies }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: ["product_query"], - order_action: "none", - }, - 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 }); - const candidates = searchResult?.candidates || []; - audit.price_search = audit.price_search || []; - audit.price_search.push({ query: pq.query, count: candidates.length }); - - for (const c of candidates.slice(0, 2)) { - const unit = inferDefaultUnit({ name: c.name, categories: c.categories }); - const priceStr = c.price != null ? `$${c.price}` : "consultar"; - const unitStr = unit === "unit" ? "/unidad" : "/kg"; - priceResults.push(`• ${c.name}: ${priceStr}${unitStr}`); - } - } - - 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: r.reply, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: ["product_query"], - order_action: "none", - }, - 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 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, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: [], - order_action: "none", - }, - 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, recentReplies }) { - const productQueries = extractProductQueries(nlu); - - if (productQueries.length === 0) { - const r = await renderReply({ tenantId, templateKey: "cart.ask_what_product", recentReplies }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.CART, - intent, - missing_fields: ["product_query"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - - let order = currentOrder; - - // Buscar candidatos para cada query - for (const pq of productQueries) { - const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 }); - const candidates = searchResult?.candidates || []; - audit.catalog_search = audit.catalog_search || []; - audit.catalog_search.push({ query: pq.query, count: candidates.length }); - - const pendingItem = createPendingItemFromSearch({ - query: pq.query, - quantity: pq.quantity, - unit: pq.unit, - candidates, - }); - - order = addPendingItem(order, pendingItem); - } - - order = moveReadyToCart(order); - - // Si hay pending items, pedir clarificación del primero - const nextPending = getNextPendingItem(order); - if (nextPending) { - if (nextPending.status === PendingStatus.NEEDS_TYPE) { - const { question } = formatOptionsForDisplay(nextPending); - return { - plan: { - reply: question, - next_state: ConversationState.CART, - intent, - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; - } - if (nextPending.status === PendingStatus.NEEDS_QUANTITY) { - return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx }); - } - } - - // Todo resuelto, confirmar agregado - 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: `${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, - template_ids_used: [added.template_id], - }, - }; - } - - const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.CART, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, - }; -} - -/** - * Maneja cuando se necesita cantidad - */ -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 || "") || - /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); - - if (personasMatch && nextPending.selected_woo_id) { - const peopleCount = parseInt(personasMatch[1], 10); - - if (peopleCount > 0 && peopleCount <= 100) { - let qtyRules = []; - try { - qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id }); - } catch (e) { - audit.qty_rules_error = e?.message; - } - - let calculatedQty; - let calculatedUnit = nextPending.selected_unit || "kg"; - const rule = qtyRules[0]; - - if (rule && rule.qty_per_person > 0) { - calculatedQty = rule.qty_per_person * peopleCount; - calculatedUnit = rule.unit || calculatedUnit; - audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount }; - } else { - calculatedQty = 0.3 * peopleCount; - audit.qty_fallback = { default_per_person: 0.3, people: peopleCount }; - } - - const updatedOrder = updatePendingItem(order, nextPending.id, { - qty: calculatedQty, - unit: calculatedUnit, - status: PendingStatus.READY, - }); - - 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${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, template_ids_used: [askMore.template_id] }, - }; - } - } - - // Si no hay "para X personas", preguntar cantidad normalmente - const unitQuestion = unitAskFor(nextPending.selected_unit || "kg"); - return { - plan: { - reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`, - next_state: ConversationState.CART, - intent, - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; -} diff --git a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js deleted file mode 100644 index cfebd1f..0000000 --- a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js +++ /dev/null @@ -1,611 +0,0 @@ -/** - * Helpers específicos para el manejo del carrito - * - Extracción de queries de productos - * - Creación de pending items - * - Procesamiento de clarificaciones - */ - -import { retrieveCandidates } from "../catalogRetrieval.js"; -import { ConversationState } from "../fsm.js"; -import { - createPendingItem, - PendingStatus, - moveReadyToCart, - updatePendingItem, - formatCartForDisplay, - formatOptionsForDisplay, -} from "../orderModel.js"; -import { getProductQtyRules } from "../../0-ui/db/repo.js"; -import { createHumanTakeoverResponse } from "../nlu/humanFallback.js"; -import { - inferDefaultUnit, - parseIndexSelection, - isShowMoreRequest, - isShowOptionsRequest, - findMatchingCandidate, - isEscapeRequest, - 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) { - if (item.product_query) { - queries.push({ - query: item.product_query, - quantity: item.quantity, - unit: item.unit, - }); - } - } - return queries; - } - - // Single item - if (nlu?.entities?.product_query) { - queries.push({ - query: nlu.entities.product_query, - quantity: nlu.entities.quantity, - unit: nlu.entities.unit, - }); - } - - return queries; -} - -/** - * Crea un pending item a partir de los resultados de búsqueda - */ -export function createPendingItemFromSearch({ query, quantity, unit, candidates }) { - const cands = (candidates || []).filter(c => c && c.woo_product_id); - - if (cands.length === 0) { - return createPendingItem({ - query, - candidates: [], - status: PendingStatus.NEEDS_TYPE, - }); - } - - // Check for strong match - const best = cands[0]; - const second = cands[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; - const sellsByWeight = displayUnit !== "unit"; - const hasExplicitUnit = unit != null && unit !== ""; - const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit; - const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric); - - return createPendingItem({ - query, - candidates: [], - selected_woo_id: best.woo_product_id, - selected_name: best.name, - selected_price: best.price, - selected_unit: displayUnit, - qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1), - unit: normalizeUnit(unit) || displayUnit, - status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - }); - } - - // Multiple candidates, needs selection - const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; - return createPendingItem({ - query, - candidates: cands.slice(0, 20).map(c => ({ - woo_id: c.woo_product_id, - name: c.name, - price: c.price, - sell_unit: c.sell_unit || null, - display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), - })), - status: PendingStatus.NEEDS_TYPE, - requested_qty: hasQty ? Number(quantity) : null, - requested_unit: normalizeUnit(unit) || null, - }); -} - -/** - * Helper interno: arma la respuesta "se agregó X al carrito" con template rotativo. - */ -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, recentReplies, failedSearches, rewriteCtx }); - } - - // Si necesita cantidad - if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) { - 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, recentReplies, failedSearches, rewriteCtx }) { - const idx = parseIndexSelection(text); - - // Show more o mostrar opciones - if (isShowMoreRequest(text) || isShowOptionsRequest(text)) { - const { question } = formatOptionsForDisplay(pendingItem); - return { - plan: { - reply: question, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["product_selection"], - order_action: "none", - }, - 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) - : 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({ 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, recentReplies, failedSearches, rewriteCtx }); - } - - // No entendió, volver a preguntar - const { question } = formatOptionsForDisplay(pendingItem); - const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${r.reply} ${question}`, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, - }; -} - -/** - * Procesa selección por índice - */ -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, - selected_price: selected.price, - selected_unit: displayUnit, - candidates: [], - status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - qty: needsQuantity ? null : finalQty, - unit: finalUnit, - }); - - if (needsQuantity) { - const unitQuestion = unitAskFor(displayUnit); - return { - plan: { - reply: `Para ${selected.name}, ${unitQuestion}`, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order: updatedOrder, audit }, - }; - } - - const finalOrder = moveReadyToCart(updatedOrder); - const qtyDisplay = displayUnit === "unit" - ? `${finalQty} ${finalQty === 1 ? "unidad" : "unidades"}` - : `${finalQty}${displayUnit}`; - const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: selected.name, finalOrder, rewriteCtx }); - - return { - plan: { - 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, 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, 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, - query: newQuery, - candidates: newCandidates.slice(0, 20).map(c => ({ - woo_id: c.woo_product_id, - name: c.name, - price: c.price, - sell_unit: c.sell_unit || null, - 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({ tenantId, updatedOrder, pendingItem, best: newCandidates[0], audit, recentReplies, rewriteCtx }); - } - - // Múltiples candidatos, mostrar opciones - const { question } = formatOptionsForDisplay(updatedPending); - return { - plan: { - reply: question, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["product_selection"], - order_action: "none", - }, - 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; - - const escalated = createHumanTakeoverResponse({ - pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`, - order: orderWithoutPending, - context: { - original_query: pendingItem.query, - user_clarification: newQuery, - 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: `${r.reply} ${question}`, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["product_selection"], - order_action: "none", - }, - 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) - */ -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; - - const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, { - selected_woo_id: best.woo_product_id, - selected_name: best.name, - selected_price: best.price, - selected_unit: displayUnit, - candidates: [], - status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1), - unit: pendingItem.requested_unit || displayUnit, - }); - - if (needsQuantity) { - const unitQuestion = unitAskFor(displayUnit); - return { - plan: { - reply: `Para ${best.name}, ${unitQuestion}`, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order: autoSelectedOrder, audit }, - }; - } - - const finalOrder = moveReadyToCart(autoSelectedOrder); - const qty = hasQty ? pendingItem.requested_qty : 1; - const qtyDisplay = displayUnit === "unit" - ? `${qty} ${qty === 1 ? "unidad" : "unidades"}` - : `${qty}${displayUnit}`; - const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: best.name, finalOrder, rewriteCtx }); - - return { - plan: { - 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, 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, recentReplies, rewriteCtx }) { - const qty = nlu?.entities?.quantity; - const unit = nlu?.entities?.unit; - - // Try to parse quantity from text - let parsedQty = qty; - if (parsedQty == null) { - const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || ""); - if (m) { - 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, { - qty: parsedQty, - unit: finalUnit, - status: PendingStatus.READY, - }); - - const finalOrder = moveReadyToCart(updatedOrder); - const qtyDisplay = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`; - const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: pendingItem.selected_name, finalOrder, rewriteCtx }); - - return { - plan: { - 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, 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, 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: `${r.reply} ${unitQuestion}`, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, - }; -} - -/** - * Procesa cantidad para X personas - */ -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: `${r.reply} ${unitQuestion}`, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, - }; - } - - // Buscar reglas de cantidad por persona para este producto - let qtyRules = []; - try { - qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id }); - } 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; - audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit }; - } else { - const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; - calculatedQty = fallbackPerPerson * peopleCount; - audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit }; - } - - 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 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: `${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, 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 deleted file mode 100644 index a51ce17..0000000 --- a/src/modules/3-turn-engine/stateHandlers/idle.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Handler para el estado IDLE - */ - -import { ConversationState } from "../fsm.js"; -import { handleCartState } from "./cart.js"; -import { renderReply } from "../replyTemplates.js"; -import { buildStoreContextVars } from "../storeContext.js"; - -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: r.reply, - next_state: ConversationState.IDLE, - intent: "greeting", - missing_fields: [], - order_action: "none", - }, - 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, 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: r.reply, - next_state: ConversationState.IDLE, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit, template_ids_used: [r.template_id] }, - }; -} diff --git a/src/modules/3-turn-engine/stateHandlers/index.js b/src/modules/3-turn-engine/stateHandlers/index.js deleted file mode 100644 index 7781229..0000000 --- a/src/modules/3-turn-engine/stateHandlers/index.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * State Handlers - Punto de entrada - * - * Re-exporta todos los handlers y utilidades para mantener - * compatibilidad con imports existentes. - */ - -// Handlers por estado -export { handleIdleState } from "./idle.js"; -export { handleCartState } from "./cart.js"; -export { handleShippingState } from "./shipping.js"; - -// Utilidades (para uso interno principalmente) -export { - inferDefaultUnit, - parseIndexSelection, - isShowMoreRequest, - isShowOptionsRequest, - findMatchingCandidate, - isEscapeRequest, - normalizeUnit, - unitAskFor, -} from "./utils.js"; - -// Helpers de carrito (para uso interno principalmente) -export { - extractProductQueries, - createPendingItemFromSearch, - processPendingClarification, -} from "./cartHelpers.js"; diff --git a/src/modules/3-turn-engine/stateHandlers/shipping.js b/src/modules/3-turn-engine/stateHandlers/shipping.js deleted file mode 100644 index 37ca675..0000000 --- a/src/modules/3-turn-engine/stateHandlers/shipping.js +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Handler para el estado SHIPPING. - * - * Pide modo de entrega (delivery / pickup) y, si es delivery, la dirección. - * Cuando completa, emite acción `create_order` y vuelve a IDLE. - * El bot NO maneja pago — el cobro se gestiona offline. - */ - -import { ConversationState } from "../fsm.js"; -import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; -import { parseIndexSelection } from "./utils.js"; -import { renderReply } from "../replyTemplates.js"; -import { buildStoreContextVars, checkAddressInZone } from "../storeContext.js"; - -const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal"; - -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; - - if (!shippingMethod) { - const t = String(text || "").toLowerCase(); - const idx = parseIndexSelection(text); - if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) { - shippingMethod = "delivery"; - } else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) { - shippingMethod = "pickup"; - } - } - - if (shippingMethod) { - currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; - - if (shippingMethod === "pickup") { - // Pickup: orden lista, cerrarla. - return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx }); - } - - // 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: r.reply, - next_state: ConversationState.SHIPPING, - intent: "select_shipping", - missing_fields: ["address"], - order_action: "none", - }, - 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) { - // Validar zona si hay zonas cargadas. Si no hay datos cargados, accept-by-default. - const zoneCheck = checkAddressInZone({ address, storeConfig }); - audit.address_zone_check = zoneCheck; - if (!zoneCheck.inZone) { - const r = await renderReply({ - tenantId, - templateKey: "shipping.address_out_of_zone", - vars: storeVars, - recentReplies, - ...rewriteCtx, - }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.SHIPPING, - intent: "provide_address", - missing_fields: ["address"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - currentOrder = { ...currentOrder, shipping_address: address }; - return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true }); - } - - const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.SHIPPING, - intent: "other", - missing_fields: ["address"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - - // 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${r.reply}${SHIPPING_OPTIONS_TAIL}`, - next_state: ConversationState.SHIPPING, - intent: "view_cart", - missing_fields: ["shipping_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - - // Default - const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${r.reply}${SHIPPING_OPTIONS_TAIL}`, - next_state: ConversationState.SHIPPING, - intent: "other", - missing_fields: ["shipping_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; -} - -/** - * Cierra la orden: emite acción create_order y vuelve a IDLE. - */ -async function finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded = false }) { - const cartDisplay = formatCartForDisplay(currentOrder); - const confirmed = await renderReply({ tenantId, templateKey: "order.confirmed", vars: storeVars, recentReplies, ...rewriteCtx }); - const addressEcho = addressJustRecorded - ? await renderReply({ tenantId, templateKey: "shipping.address_recorded", vars: { ...storeVars, address: currentOrder.shipping_address }, recentReplies }) - : null; - - const reply = [ - addressEcho?.reply, - cartDisplay, - confirmed.reply, - ].filter(Boolean).join("\n\n"); - - return { - plan: { - reply, - next_state: ConversationState.IDLE, - intent: "confirm_order", - missing_fields: [], - order_action: "create_order", - }, - decision: { - actions: [{ type: "create_order", payload: { source: "wa_bot" } }], - order: currentOrder, - audit, - template_ids_used: [ - addressEcho?.template_id, - confirmed.template_id, - ].filter(Boolean), - }, - }; -} diff --git a/src/modules/3-turn-engine/stateHandlers/utils.js b/src/modules/3-turn-engine/stateHandlers/utils.js deleted file mode 100644 index b3bc60b..0000000 --- a/src/modules/3-turn-engine/stateHandlers/utils.js +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Utilidades compartidas para los state handlers - */ - -/** - * Infiere la unidad por defecto basándose en el nombre y categorías del producto - */ -export function inferDefaultUnit({ name, categories }) { - const n = String(name || "").toLowerCase(); - const cats = Array.isArray(categories) ? categories : []; - const hay = (re) => - cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); - if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) { - return "unit"; - } - if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) { - return "unit"; - } - if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { - return "unit"; - } - return "kg"; -} - -/** - * Parsea selección por índice del texto (números o palabras como "primero", "segundo") - */ -export function parseIndexSelection(text) { - const t = String(text || "").toLowerCase(); - const m = /\b(\d{1,2})\b/.exec(t); - if (m) return parseInt(m[1], 10); - if (/\bprimera\b|\bprimero\b/.test(t)) return 1; - if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2; - if (/\btercera\b|\btercero\b/.test(t)) return 3; - if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4; - if (/\bquinta\b|\bquinto\b/.test(t)) return 5; - if (/\bsexta\b|\bsexto\b/.test(t)) return 6; - if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7; - if (/\boctava\b|\boctavo\b/.test(t)) return 8; - if (/\bnovena\b|\bnoveno\b/.test(t)) return 9; - if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10; - return null; -} - -/** - * Detecta si el usuario pide ver más opciones - */ -export function isShowMoreRequest(text) { - const t = String(text || "").toLowerCase(); - return ( - /\bmostr(a|ame)\s+m[aá]s\b/.test(t) || - /\bmas\s+opciones\b/.test(t) || - (/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) || - /\bsiguiente(s)?\b/.test(t) || - /\b(no\s+)?hay\s+(otras?|m[aá]s)\b/.test(t) || - /\botras?\s+opciones\b/.test(t) || - /\bqu[eé]\s+m[aá]s\s+hay\b/.test(t) || - /\bver\s+m[aá]s\b/.test(t) || - /\btodas?\s+las?\s+opciones\b/.test(t) - ); -} - -/** - * Detecta si el usuario pide ver las opciones disponibles - */ -export function isShowOptionsRequest(text) { - const t = String(text || "").toLowerCase(); - return ( - /\bqu[eé]\s+opciones\b/.test(t) || - /\bcu[aá]les\s+(son|hay|ten[eé]s)\b/.test(t) || - /\bmostr(a|ame)\s+(las\s+)?opciones\b/.test(t) || - /\bver\s+(las\s+)?opciones\b/.test(t) || - /\bqu[eé]\s+hay\b/.test(t) || - /\bqu[eé]\s+ten[eé]s\b/.test(t) - ); -} - -/** - * Busca un candidato que coincida con el texto del usuario (fuzzy match) - */ -export function findMatchingCandidate(candidates, text) { - if (!candidates?.length || !text) return null; - - const t = String(text).toLowerCase().trim(); - const words = t.split(/\s+/).filter(w => w.length > 2); - - let bestMatch = null; - let bestScore = 0; - - for (let i = 0; i < candidates.length; i++) { - const candidate = candidates[i]; - const name = String(candidate.name || "").toLowerCase(); - - let score = 0; - for (const word of words) { - if (name.includes(word)) score += 1; - } - // Bonus si el texto completo está en el nombre - if (name.includes(t)) score += 2; - - if (score > bestScore) { - bestScore = score; - bestMatch = { index: i, candidate, score }; - } - } - - // Requiere al menos una palabra que coincida - return bestScore > 0 ? bestMatch : null; -} - -/** - * Detecta si el texto indica un intent de escape (ver carrito, confirmar, etc.) - */ -export function isEscapeRequest(text) { - const t = String(text || "").toLowerCase(); - return ( - /\b(que|qué)\s+tengo\b/.test(t) || - /\bmi\s+(carrito|pedido)\b/.test(t) || - /\bver\s+(carrito|pedido)\b/.test(t) || - /\bmostrar\s+(carrito|pedido)\b/.test(t) || - /\blisto\b/.test(t) || - /\bconfirmar?\b/.test(t) || - /\bcancelar?\b/.test(t) || - /\beso\s+(es\s+)?todo\b/.test(t) - ); -} - -/** - * Normaliza unidades a formato estándar - */ -export function normalizeUnit(unit) { - if (!unit) return null; - const u = String(unit).toLowerCase(); - if (u === "kg" || u === "kilo" || u === "kilos") return "kg"; - if (u === "g" || u === "gramo" || u === "gramos") return "g"; - if (u === "unidad" || u === "unidades" || u === "unit") return "unit"; - return null; -} - -/** - * Genera la pregunta para pedir cantidad según la unidad - */ -export function unitAskFor(displayUnit) { - if (displayUnit === "unit") return "¿Cuántas unidades querés?"; - if (displayUnit === "g") return "¿Cuántos gramos querés?"; - return "¿Cuántos kilos querés?"; -} diff --git a/src/modules/3-turn-engine/stateHandlers/utils.test.js b/src/modules/3-turn-engine/stateHandlers/utils.test.js deleted file mode 100644 index 09c0878..0000000 --- a/src/modules/3-turn-engine/stateHandlers/utils.test.js +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Tests para utils.js - */ -import { describe, it, expect } from 'vitest'; -import { - inferDefaultUnit, - parseIndexSelection, - isShowMoreRequest, - isShowOptionsRequest, - findMatchingCandidate, - isEscapeRequest, - normalizeUnit, - unitAskFor, -} from './utils.js'; - -// ───────────────────────────────────────────────────────────── -// parseIndexSelection -// ───────────────────────────────────────────────────────────── - -describe('parseIndexSelection', () => { - describe('números directos', () => { - it('parsea número simple', () => { - expect(parseIndexSelection('2')).toBe(2); - expect(parseIndexSelection('5')).toBe(5); - expect(parseIndexSelection('10')).toBe(10); - }); - - it('parsea número en frase', () => { - expect(parseIndexSelection('quiero el 2')).toBe(2); - expect(parseIndexSelection('dame la opción 3')).toBe(3); - expect(parseIndexSelection('el número 7 por favor')).toBe(7); - }); - - it('parsea números de dos dígitos', () => { - expect(parseIndexSelection('el 12')).toBe(12); - expect(parseIndexSelection('opción 15')).toBe(15); - }); - }); - - describe('ordinales en español', () => { - it('parsea ordinales masculinos', () => { - expect(parseIndexSelection('el primero')).toBe(1); - expect(parseIndexSelection('segundo')).toBe(2); - expect(parseIndexSelection('tercero')).toBe(3); - expect(parseIndexSelection('cuarto')).toBe(4); - expect(parseIndexSelection('quinto')).toBe(5); - expect(parseIndexSelection('sexto')).toBe(6); - expect(parseIndexSelection('séptimo')).toBe(7); - expect(parseIndexSelection('octavo')).toBe(8); - expect(parseIndexSelection('noveno')).toBe(9); - expect(parseIndexSelection('décimo')).toBe(10); - }); - - it('parsea ordinales femeninos', () => { - expect(parseIndexSelection('la primera')).toBe(1); - expect(parseIndexSelection('segunda')).toBe(2); - expect(parseIndexSelection('tercera')).toBe(3); - expect(parseIndexSelection('cuarta')).toBe(4); - expect(parseIndexSelection('quinta')).toBe(5); - expect(parseIndexSelection('sexta')).toBe(6); - expect(parseIndexSelection('séptima')).toBe(7); - expect(parseIndexSelection('octava')).toBe(8); - expect(parseIndexSelection('novena')).toBe(9); - expect(parseIndexSelection('décima')).toBe(10); - }); - - it('parsea ordinales sin tilde', () => { - expect(parseIndexSelection('septimo')).toBe(7); - expect(parseIndexSelection('decimo')).toBe(10); - }); - }); - - describe('casos sin selección', () => { - it('retorna null para texto sin número ni ordinal', () => { - expect(parseIndexSelection('hola')).toBeNull(); - expect(parseIndexSelection('quiero provoleta')).toBeNull(); - expect(parseIndexSelection('no sé')).toBeNull(); - }); - - it('retorna null para valores vacíos', () => { - expect(parseIndexSelection('')).toBeNull(); - expect(parseIndexSelection(null)).toBeNull(); - expect(parseIndexSelection(undefined)).toBeNull(); - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// normalizeUnit -// ───────────────────────────────────────────────────────────── - -describe('normalizeUnit', () => { - describe('kilogramos', () => { - it('normaliza kg', () => { - expect(normalizeUnit('kg')).toBe('kg'); - expect(normalizeUnit('KG')).toBe('kg'); - }); - - it('normaliza kilo/kilos', () => { - expect(normalizeUnit('kilo')).toBe('kg'); - expect(normalizeUnit('kilos')).toBe('kg'); - expect(normalizeUnit('KILOS')).toBe('kg'); - }); - }); - - describe('gramos', () => { - it('normaliza g', () => { - expect(normalizeUnit('g')).toBe('g'); - expect(normalizeUnit('G')).toBe('g'); - }); - - it('normaliza gramo/gramos', () => { - expect(normalizeUnit('gramo')).toBe('g'); - expect(normalizeUnit('gramos')).toBe('g'); - expect(normalizeUnit('GRAMOS')).toBe('g'); - }); - }); - - describe('unidades', () => { - it('normaliza unit', () => { - expect(normalizeUnit('unit')).toBe('unit'); - }); - - it('normaliza unidad/unidades', () => { - expect(normalizeUnit('unidad')).toBe('unit'); - expect(normalizeUnit('unidades')).toBe('unit'); - expect(normalizeUnit('UNIDADES')).toBe('unit'); - }); - }); - - describe('valores inválidos', () => { - it('retorna null para unidades desconocidas', () => { - expect(normalizeUnit('litro')).toBeNull(); - expect(normalizeUnit('docena')).toBeNull(); - expect(normalizeUnit('xyz')).toBeNull(); - }); - - it('retorna null para valores vacíos', () => { - expect(normalizeUnit('')).toBeNull(); - expect(normalizeUnit(null)).toBeNull(); - expect(normalizeUnit(undefined)).toBeNull(); - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// inferDefaultUnit -// ───────────────────────────────────────────────────────────── - -describe('inferDefaultUnit', () => { - describe('productos que se venden por unidad', () => { - it('detecta provoleta/queso por nombre', () => { - expect(inferDefaultUnit({ name: 'Provoleta clásica', categories: [] })).toBe('unit'); - expect(inferDefaultUnit({ name: 'Queso provolone', categories: [] })).toBe('unit'); - expect(inferDefaultUnit({ name: 'Pan de campo', categories: [] })).toBe('unit'); - }); - - it('detecta bebidas por nombre', () => { - expect(inferDefaultUnit({ name: 'Vino Malbec', categories: [] })).toBe('unit'); - expect(inferDefaultUnit({ name: 'Cerveza artesanal', categories: [] })).toBe('unit'); - expect(inferDefaultUnit({ name: 'Fernet Branca', categories: [] })).toBe('unit'); - }); - - it('detecta condimentos', () => { - expect(inferDefaultUnit({ name: 'Chimichurri casero', categories: [] })).toBe('unit'); - expect(inferDefaultUnit({ name: 'Salsa criolla', categories: [] })).toBe('unit'); - }); - - it('detecta por categoría', () => { - expect(inferDefaultUnit({ - name: 'Producto X', - categories: [{ name: 'Vinos', slug: 'vinos' }] - })).toBe('unit'); - expect(inferDefaultUnit({ - name: 'Producto Y', - categories: [{ name: 'Proveeduría', slug: 'proveeduria' }] - })).toBe('unit'); - }); - }); - - describe('productos que se venden por kg', () => { - it('retorna kg para carnes', () => { - expect(inferDefaultUnit({ name: 'Bife de chorizo', categories: [] })).toBe('kg'); - expect(inferDefaultUnit({ name: 'Vacío', categories: [] })).toBe('kg'); - expect(inferDefaultUnit({ name: 'Asado de tira', categories: [] })).toBe('kg'); - }); - - it('retorna kg por defecto', () => { - expect(inferDefaultUnit({ name: 'Producto genérico', categories: [] })).toBe('kg'); - expect(inferDefaultUnit({ name: '', categories: [] })).toBe('kg'); - }); - }); - - describe('edge cases', () => { - it('maneja categories no array', () => { - expect(inferDefaultUnit({ name: 'Vino', categories: null })).toBe('unit'); - expect(inferDefaultUnit({ name: 'Vino', categories: undefined })).toBe('unit'); - }); - - it('maneja name vacío o null', () => { - expect(inferDefaultUnit({ name: null, categories: [] })).toBe('kg'); - expect(inferDefaultUnit({ name: undefined, categories: [] })).toBe('kg'); - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// isShowMoreRequest -// ───────────────────────────────────────────────────────────── - -describe('isShowMoreRequest', () => { - describe('detecta pedidos de más opciones', () => { - it('detecta "mostrame más"', () => { - expect(isShowMoreRequest('mostrame más')).toBe(true); - expect(isShowMoreRequest('mostrame mas')).toBe(true); - expect(isShowMoreRequest('mostra más')).toBe(true); - }); - - it('detecta "más opciones"', () => { - expect(isShowMoreRequest('más opciones')).toBe(true); - expect(isShowMoreRequest('mas opciones')).toBe(true); - expect(isShowMoreRequest('quiero más opciones')).toBe(true); - }); - - it('detecta "siguientes"', () => { - expect(isShowMoreRequest('siguientes')).toBe(true); - expect(isShowMoreRequest('siguiente')).toBe(true); - }); - - it('detecta "otras opciones"', () => { - expect(isShowMoreRequest('otras opciones')).toBe(true); - expect(isShowMoreRequest('hay otras?')).toBe(true); - }); - - it('detecta "ver más"', () => { - expect(isShowMoreRequest('ver más')).toBe(true); - expect(isShowMoreRequest('ver mas')).toBe(true); - }); - - it('detecta "qué más hay"', () => { - expect(isShowMoreRequest('qué más hay')).toBe(true); - expect(isShowMoreRequest('que mas hay')).toBe(true); - }); - }); - - describe('no detecta falsos positivos', () => { - it('no detecta frases normales', () => { - expect(isShowMoreRequest('quiero el primero')).toBe(false); - expect(isShowMoreRequest('dame provoleta')).toBe(false); - expect(isShowMoreRequest('hola')).toBe(false); - }); - - it('no detecta valores vacíos', () => { - expect(isShowMoreRequest('')).toBe(false); - expect(isShowMoreRequest(null)).toBe(false); - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// isShowOptionsRequest -// ───────────────────────────────────────────────────────────── - -describe('isShowOptionsRequest', () => { - describe('detecta pedidos de ver opciones', () => { - it('detecta "qué opciones"', () => { - expect(isShowOptionsRequest('qué opciones tenés?')).toBe(true); - expect(isShowOptionsRequest('que opciones hay')).toBe(true); - }); - - it('detecta "cuáles tenés"', () => { - expect(isShowOptionsRequest('cuáles tenés')).toBe(true); - expect(isShowOptionsRequest('cuales son')).toBe(true); - expect(isShowOptionsRequest('cuáles hay')).toBe(true); - }); - - it('detecta "qué hay"', () => { - expect(isShowOptionsRequest('qué hay')).toBe(true); - expect(isShowOptionsRequest('que hay')).toBe(true); - }); - - it('detecta "qué tenés"', () => { - expect(isShowOptionsRequest('qué tenés')).toBe(true); - expect(isShowOptionsRequest('que tenes')).toBe(true); - }); - - it('detecta "ver opciones"', () => { - expect(isShowOptionsRequest('ver opciones')).toBe(true); - expect(isShowOptionsRequest('ver las opciones')).toBe(true); - }); - }); - - describe('no detecta falsos positivos', () => { - it('no detecta frases normales', () => { - expect(isShowOptionsRequest('quiero el 2')).toBe(false); - expect(isShowOptionsRequest('dame provoleta')).toBe(false); - }); - - it('no detecta valores vacíos', () => { - expect(isShowOptionsRequest('')).toBe(false); - expect(isShowOptionsRequest(null)).toBe(false); - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// isEscapeRequest -// ───────────────────────────────────────────────────────────── - -describe('isEscapeRequest', () => { - describe('detecta pedidos de escape', () => { - it('detecta "qué tengo"', () => { - expect(isEscapeRequest('qué tengo')).toBe(true); - expect(isEscapeRequest('que tengo en el carrito')).toBe(true); - }); - - it('detecta "mi carrito"', () => { - expect(isEscapeRequest('mi carrito')).toBe(true); - expect(isEscapeRequest('mi pedido')).toBe(true); - }); - - it('detecta "ver carrito"', () => { - expect(isEscapeRequest('ver carrito')).toBe(true); - expect(isEscapeRequest('ver pedido')).toBe(true); - }); - - it('detecta "listo"', () => { - expect(isEscapeRequest('listo')).toBe(true); - expect(isEscapeRequest('ya está listo')).toBe(true); - }); - - it('detecta "confirmar"', () => { - expect(isEscapeRequest('confirmar')).toBe(true); - expect(isEscapeRequest('quiero confirmar')).toBe(true); - }); - - it('detecta "cancelar"', () => { - expect(isEscapeRequest('cancelar')).toBe(true); - expect(isEscapeRequest('quiero cancelar')).toBe(true); - }); - - it('detecta "eso es todo"', () => { - expect(isEscapeRequest('eso es todo')).toBe(true); - expect(isEscapeRequest('eso todo')).toBe(true); - }); - }); - - describe('no detecta falsos positivos', () => { - it('no detecta productos', () => { - expect(isEscapeRequest('quiero provoleta')).toBe(false); - expect(isEscapeRequest('dame 2kg de vacío')).toBe(false); - }); - - it('no detecta valores vacíos', () => { - expect(isEscapeRequest('')).toBe(false); - expect(isEscapeRequest(null)).toBe(false); - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// findMatchingCandidate -// ───────────────────────────────────────────────────────────── - -describe('findMatchingCandidate', () => { - const candidates = [ - { name: 'Provoleta de bufala' }, - { name: 'Provoleta clasica' }, - { name: 'Queso provolone' }, - { name: 'Chimichurri casero' }, - ]; - - describe('encuentra matches', () => { - it('encuentra match exacto de palabra', () => { - const match = findMatchingCandidate(candidates, 'bufala'); - expect(match).not.toBeNull(); - expect(match.index).toBe(0); - expect(match.candidate.name).toBe('Provoleta de bufala'); - }); - - it('encuentra match parcial', () => { - const match = findMatchingCandidate(candidates, 'clasica'); - expect(match).not.toBeNull(); - expect(match.index).toBe(1); - }); - - it('da bonus por match completo', () => { - const match = findMatchingCandidate(candidates, 'provoleta clasica'); - expect(match).not.toBeNull(); - expect(match.index).toBe(1); - expect(match.score).toBeGreaterThan(1); - }); - - it('encuentra mejor match entre varios', () => { - const match = findMatchingCandidate(candidates, 'provoleta'); - expect(match).not.toBeNull(); - // Ambos tienen "provoleta", debe elegir uno - expect([0, 1]).toContain(match.index); - }); - }); - - describe('no encuentra match', () => { - it('retorna null sin coincidencia', () => { - expect(findMatchingCandidate(candidates, 'chorizo')).toBeNull(); - expect(findMatchingCandidate(candidates, 'vino')).toBeNull(); - }); - - it('retorna null para candidates vacío', () => { - expect(findMatchingCandidate([], 'provoleta')).toBeNull(); - expect(findMatchingCandidate(null, 'provoleta')).toBeNull(); - }); - - it('retorna null para texto vacío', () => { - expect(findMatchingCandidate(candidates, '')).toBeNull(); - expect(findMatchingCandidate(candidates, null)).toBeNull(); - }); - - it('texto corto puede matchear por inclusión completa', () => { - // "de" tiene 2 caracteres, se filtra como palabra pero si el texto completo - // está en el nombre, da bonus y matchea - const match = findMatchingCandidate(candidates, 'de'); - // La función da bonus si el texto completo está en el nombre - expect(match).not.toBeNull(); - expect(match.score).toBe(2); // bonus por match completo - }); - }); -}); - -// ───────────────────────────────────────────────────────────── -// unitAskFor -// ───────────────────────────────────────────────────────────── - -describe('unitAskFor', () => { - it('genera pregunta para unidades', () => { - expect(unitAskFor('unit')).toBe('¿Cuántas unidades querés?'); - }); - - it('genera pregunta para gramos', () => { - expect(unitAskFor('g')).toBe('¿Cuántos gramos querés?'); - }); - - it('genera pregunta para kilos (default)', () => { - expect(unitAskFor('kg')).toBe('¿Cuántos kilos querés?'); - expect(unitAskFor(null)).toBe('¿Cuántos kilos querés?'); - expect(unitAskFor(undefined)).toBe('¿Cuántos kilos querés?'); - expect(unitAskFor('otro')).toBe('¿Cuántos kilos querés?'); - }); -}); diff --git a/src/modules/3-turn-engine/turnEngineV3.helpers.js b/src/modules/3-turn-engine/turnEngineV3.helpers.js deleted file mode 100644 index c1e5eb9..0000000 --- a/src/modules/3-turn-engine/turnEngineV3.helpers.js +++ /dev/null @@ -1,16 +0,0 @@ -export function askClarificationReply() { - return "Dale, ¿qué producto querés exactamente?"; -} - -export function shortSummary(history) { - if (!Array.isArray(history)) return ""; - return history - .slice(-5) - .map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`) - .join(" | "); -} - -export function hasAddress(ctx) { - return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text); -} - diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index 013c9fc..1b49af3 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -1,394 +1,15 @@ /** - * Turn Engine V3 - Dispatcher basado en estados - * - * Flujo: IDLE → CART → SHIPPING → IDLE (orden creada offline) - * Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado. - * - * Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular. + * Turn Engine — Thin wrapper sobre el agente tool-calling. + * + * Toda la lógica vive en src/modules/3-turn-engine/agent/. Este módulo + * existe para mantener compatibilidad de imports (`runTurnV3` y + * `safeNextState`) con `pipeline.js` y otros call sites históricos. */ -import { llmNluV3 } from "./openai.js"; -import { llmNluModular } from "./nlu/index.js"; -import { ConversationState, shouldReturnToCart, safeNextState } from "./fsm.js"; -import { migrateOldContext, createEmptyOrder } from "./orderModel.js"; -import { - handleIdleState, - handleCartState, - handleShippingState, -} from "./stateHandlers.js"; -import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; -import { pushRecent } from "./replyTemplates.js"; -import { runTurnXState } from "./machine/runner.js"; import { runTurnAgent } from "./agent/runTurn.js"; -import { insertAuditLog } from "../0-ui/db/repo.js"; -// Feature flag para NLU modular -const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; -// Feature flags -function useXState() { - const v = String(process.env.USE_XSTATE || "").toLowerCase(); - return v === "1" || v === "true" || v === "yes"; -} -function shadowXState() { - const v = String(process.env.XSTATE_SHADOW || "").toLowerCase(); - return v === "1" || v === "true" || v === "yes"; -} -function useAgent() { - const v = String(process.env.AGENT_TURN_ENGINE || "").toLowerCase(); - return v === "1" || v === "true" || v === "yes"; -} -function shadowAgent() { - const v = String(process.env.AGENT_TURN_ENGINE_SHADOW || "").toLowerCase(); - return v === "1" || v === "true" || v === "yes"; +export async function runTurnV3(args) { + return runTurnAgent(args); } -/** - * Compara plan/decision entre legacy y XState para shadow mode. - * No hace assertions; solo loguea diferencias estructurales. - */ -function diffResults(legacy, xstate) { - const diffs = []; - if (legacy?.plan?.next_state !== xstate?.plan?.next_state) { - diffs.push({ key: "next_state", legacy: legacy?.plan?.next_state, xstate: xstate?.plan?.next_state }); - } - const lActions = (legacy?.decision?.actions || []).map((a) => a.type).sort().join(","); - const xActions = (xstate?.decision?.actions || []).map((a) => a.type).sort().join(","); - if (lActions !== xActions) { - diffs.push({ key: "action_types", legacy: lActions, xstate: xActions }); - } - const lCart = (legacy?.decision?.context_patch?.order?.cart || []).map((c) => `${c.woo_id}:${c.qty}${c.unit}`).sort().join(","); - const xCart = (xstate?.decision?.context_patch?.order?.cart || []).map((c) => `${c.woo_id}:${c.qty}${c.unit}`).sort().join(","); - if (lCart !== xCart) { - diffs.push({ key: "cart", legacy: lCart, xstate: xCart }); - } - return diffs; -} - -/** - * Genera un resumen corto del historial para el NLU - */ -function shortSummary(history) { - if (!Array.isArray(history) || history.length === 0) return null; - const last = history.slice(-6); - return last - .map((m) => { - const role = m.role === "user" ? "U" : "A"; - const txt = String(m.content || "").slice(0, 80); - return `${role}: ${txt}`; - }) - .join("\n"); -} - -/** - * Punto de entrada principal del turn engine. - */ -export async function runTurnV3({ - tenantId, - chat_id, - text, - prev_state, - prev_context, - conversation_history, -}) { - // Branch: agente tool-calling (AGENT_TURN_ENGINE=1) - if (useAgent() && !shadowAgent()) { - return runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }); - } - - // Branch: XState completo (USE_XSTATE=1) - if (useXState() && !shadowXState()) { - return runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }); - } - - const audit = { - trace: { - tenantId, - chat_id, - text_preview: String(text || "").slice(0, 50), - prev_state, - }, - }; - - // 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) - // ───────────────────────────────────────────────────────────── - - const nluInput = { - last_user_message: text, - conversation_state: normalizedState, - memory_summary: shortSummary(conversation_history), - pending_context: { - has_cart_items: (order?.cart?.length || 0) > 0, - has_pending_items: (order?.pending?.length || 0) > 0, - }, - last_shown_options: [], // Ya no usamos este campo - locale: "es-AR", - }; - - // Cargar configuración del tenant (se usa en NLU y handlers) - const storeConfig = await getStoreConfig({ tenantId }); - - let nluResult; - - if (USE_MODULAR_NLU) { - // Nuevo sistema NLU modular con prompts editables - nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig }); - audit.nlu = { - raw_text: nluResult.raw_text, - model: nluResult.model, - usage: nluResult.usage, - validation: nluResult.validation, - parsed: nluResult.nlu, - routing: nluResult.routing, - schema: "modular_v1", - }; - } else { - // Sistema NLU clásico - nluResult = await llmNluV3({ input: nluInput }); - audit.nlu = { - raw_text: nluResult.raw_text, - model: nluResult.model, - usage: nluResult.usage, - validation: nluResult.validation, - parsed: nluResult.nlu, - schema: "v3", - }; - } - - const nlu = nluResult.nlu; - - // ───────────────────────────────────────────────────────────── - // Dispatcher por estado - // ───────────────────────────────────────────────────────────── - - const handlerParams = { - tenantId, - chat_id, - text, - nlu, - 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, recentReplies, failedSearches); - } - - // Dispatch por estado actual - let result; - - switch (normalizedState) { - case ConversationState.IDLE: - result = await handleIdleState(handlerParams); - break; - - case ConversationState.CART: - result = await handleCartState(handlerParams); - break; - - case ConversationState.SHIPPING: - result = await handleShippingState(handlerParams); - break; - - default: - // Estado desconocido, tratar como IDLE - result = await handleIdleState(handlerParams); - } - - const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches); - - // Shadow mode XState: corre en paralelo, devuelve legacy, loguea diffs. - if (shadowXState()) { - runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) - .then(async (xstateResult) => { - const diffs = diffResults(legacyResult, xstateResult); - if (!diffs.length) return; - try { - await insertAuditLog({ - tenantId, - entityType: "xstate_shadow", - entityId: chat_id, - action: "diff", - changes: { diffs, prev_state, text_preview: String(text || "").slice(0, 80) }, - actor: "system", - }); - } catch (err) { - console.error("[xstate-shadow] audit_log failed", err?.message || err); - } - }) - .catch((err) => console.error("[xstate-shadow] error", err?.message || err)); - } - - // Shadow mode AGENT: corre el agente nuevo en paralelo, devuelve legacy, - // loguea diffs estructurales en audit_log para validar paridad antes - // de flippar AGENT_TURN_ENGINE=1. - if (shadowAgent()) { - runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) - .then(async (agentResult) => { - const diffs = diffResults(legacyResult, agentResult); - try { - await insertAuditLog({ - tenantId, - entityType: "agent_shadow", - entityId: chat_id, - action: "compare", - changes: { - diffs, - prev_state, - text_preview: String(text || "").slice(0, 80), - legacy_reply: legacyResult?.plan?.reply?.slice(0, 200), - agent_reply: agentResult?.plan?.reply?.slice(0, 200), - agent_tools: agentResult?.decision?.audit?.tool_calls?.map((t) => t.name) || [], - agent_duration_ms: agentResult?.decision?.audit?.duration_ms, - }, - actor: "system", - }); - } catch (err) { - console.error("[agent-shadow] audit_log failed", err?.message || err); - } - }) - .catch((err) => console.error("[agent-shadow] error", err?.message || err)); - } - - return legacyResult; -} - -/** - * Normaliza estados viejos al nuevo modelo - */ -function normalizeState(state) { - if (!state) return ConversationState.IDLE; - - const s = String(state).toUpperCase(); - - // Mapeo directo - if (s === "IDLE") return ConversationState.IDLE; - if (s === "CART") return ConversationState.CART; - if (s === "SHIPPING") return ConversationState.SHIPPING; - - // Estados viejos / payment-flow legacy → mapeos seguros - if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) { - return ConversationState.CART; - } - if (s === "CLARIFYING_SHIPPING" || s === "AWAITING_ADDRESS") return ConversationState.SHIPPING; - // Estados que ya no existen (payment / waiting / completed) vuelven a IDLE - if (["PAYMENT", "WAITING_WEBHOOKS", "CLARIFYING_PAYMENT", "AWAITING_PAYMENT", "COMPLETED"].includes(s)) { - return ConversationState.IDLE; - } - - return ConversationState.IDLE; -} - -/** - * Formatea el resultado para compatibilidad con el sistema existente - */ -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 = { - // Nueva estructura - order, - - // Compatibilidad: también guardar en formato viejo para UI/pipeline existente - order_basket: { - items: (order.cart || []).map(item => ({ - product_id: item.woo_id, - woo_product_id: item.woo_id, - quantity: item.qty, - unit: item.unit, - label: item.name, - name: item.name, - price: item.price, - })), - }, - pending_items: (order.pending || []).map(p => ({ - id: p.id, - query: p.query, - candidates: p.candidates, - resolved_product: p.selected_woo_id ? { - woo_product_id: p.selected_woo_id, - name: p.selected_name, - price: p.selected_price, - display_unit: p.selected_unit, - } : null, - quantity: p.qty, - unit: p.unit, - status: p.status?.toLowerCase() || "needs_type", - })), - shipping_method: order.is_delivery === true ? "delivery" : - 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 - const basket_resolved = { - items: (order.cart || []).map(item => ({ - product_id: item.woo_id, - woo_product_id: item.woo_id, - quantity: item.qty, - unit: item.unit, - label: item.name, - name: item.name, - price: item.price, - })), - }; - - return { - plan: { - ...plan, - basket_resolved, - }, - decision: { - actions: decision?.actions || [], - context_patch, - audit: decision?.audit || {}, - }, - }; -} - -// Re-exportar safeNextState para compatibilidad export { safeNextState } from "./fsm.js"; diff --git a/src/modules/3-turn-engine/turnEngineV3.pendingSelection.js b/src/modules/3-turn-engine/turnEngineV3.pendingSelection.js deleted file mode 100644 index a80233d..0000000 --- a/src/modules/3-turn-engine/turnEngineV3.pendingSelection.js +++ /dev/null @@ -1,112 +0,0 @@ -function parseIndexSelection(text) { - const t = String(text || "").toLowerCase(); - const m = /\b(\d{1,2})\b/.exec(t); - if (m) return parseInt(m[1], 10); - if (/\bprimera\b|\bprimero\b/.test(t)) return 1; - if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2; - if (/\btercera\b|\btercero\b/.test(t)) return 3; - if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4; - if (/\bquinta\b|\bquinto\b/.test(t)) return 5; - if (/\bsexta\b|\bsexto\b/.test(t)) return 6; - if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7; - if (/\boctava\b|\boctavo\b/.test(t)) return 8; - if (/\bnovena\b|\bnoveno\b/.test(t)) return 9; - if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10; - return null; -} - -function isShowMoreRequest(text) { - const t = String(text || "").toLowerCase(); - return ( - /\bmostr(a|ame)\s+m[aá]s\b/.test(t) || - /\bmas\s+opciones\b/.test(t) || - (/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) || - /\bsiguiente(s)?\b/.test(t) - ); -} - -function normalizeText(s) { - return String(s || "") - .toLowerCase() - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function scoreTextMatch(query, candidateName) { - const qt = new Set(normalizeText(query).split(" ").filter(Boolean)); - const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean)); - let hits = 0; - for (const w of qt) if (nt.has(w)) hits++; - return hits / Math.max(qt.size, 1); -} - -export function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) { - const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name); - const off = Math.max(0, parseInt(candidateOffset, 10) || 0); - const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9)); - const slice = cands.slice(off, off + size); - const options = slice.map((c, i) => ({ - idx: baseIdx + i, - type: "product", - woo_product_id: c.woo_product_id, - name: c.name, - })); - const hasMore = off + size < cands.length; - if (hasMore) options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" }); - const list = options - .map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}`)) - .join("\n"); - const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`; - const pending = { - candidates: cands, - options, - candidate_offset: off, - page_size: size, - base_idx: baseIdx, - has_more: hasMore, - next_candidate_offset: off + size, - next_base_idx: baseIdx + size + (hasMore ? 1 : 0), - }; - return { question, pending, options, hasMore }; -} - -export function resolvePendingSelection({ text, nlu, pending }) { - if (!pending?.candidates?.length) return { kind: "none" }; - - if (isShowMoreRequest(text)) { - const { question, pending: nextPending } = buildPagedOptions({ - candidates: pending.candidates, - candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)), - baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1), - pageSize: pending.page_size || 9, - }); - return { kind: "more", question, pending: nextPending }; - } - - const idx = - (nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ?? - parseIndexSelection(text); - if (idx && Array.isArray(pending.options)) { - const opt = pending.options.find((o) => o.idx === idx); - if (opt?.type === "more") return { kind: "more", question: null, pending }; - if (opt?.woo_product_id) { - const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null; - if (chosen) return { kind: "chosen", chosen }; - } - } - - const selText = nlu?.entities?.selection?.type === "text" ? String(nlu.entities.selection.value || "").trim() : null; - const q = selText || nlu?.entities?.product_query || null; - if (q) { - const scored = pending.candidates - .map((c) => ({ c, s: scoreTextMatch(q, c?.name) })) - .sort((a, b) => b.s - a.s); - if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) { - return { kind: "chosen", chosen: scored[0].c }; - } - } - - return { kind: "ask" }; -} - diff --git a/src/modules/3-turn-engine/turnEngineV3.units.js b/src/modules/3-turn-engine/turnEngineV3.units.js deleted file mode 100644 index 95feff2..0000000 --- a/src/modules/3-turn-engine/turnEngineV3.units.js +++ /dev/null @@ -1,51 +0,0 @@ -export function unitAskFor(displayUnit) { - if (displayUnit === "unit") return "¿Cuántas unidades querés?"; - if (displayUnit === "g") return "¿Cuántos gramos querés?"; - return "¿Cuántos kilos querés?"; -} - -export function unitDisplay(unit) { - if (unit === "unit") return "unidades"; - if (unit === "g") return "gramos"; - return "kilos"; -} - -export function inferDefaultUnit({ name, categories }) { - const n = String(name || "").toLowerCase(); - const cats = Array.isArray(categories) ? categories : []; - const hay = (re) => - cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); - if ( - hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i) - ) { - return "unit"; - } - return "kg"; -} - -export function normalizeUnit(unit) { - if (!unit) return null; - const u = String(unit).toLowerCase(); - if (u === "kg" || u === "kilo" || u === "kilos") return "kg"; - if (u === "g" || u === "gramo" || u === "gramos") return "g"; - if (u === "unidad" || u === "unidades" || u === "unit") return "unit"; - return null; -} - -export function resolveQuantity({ quantity, unit, displayUnit }) { - if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null; - const q = Number(quantity); - const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg"); - if (u === "unit") { - return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" }; - } - if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" }; - // kg -> gramos enteros - return { - quantity: Math.round(q * 1000), - unit: "g", - display_unit: "kg", - display_quantity: q, - }; -} -