D9 cleanup: borrar NLU/handlers/machine/replyTemplates legacy + activar agente + prompt caching

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 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-02 13:14:59 -03:00
parent 03621f16f4
commit 675a449ce8
51 changed files with 106 additions and 9206 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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";

View File

@@ -67,7 +67,6 @@ class OpsShell extends HTMLElement {
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
<a class="nav-btn" href="/config-prompts" data-view="prompts">Prompts</a>
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
<a class="nav-btn" href="/test" data-view="test">Test</a>
</nav>
@@ -141,12 +140,6 @@ class OpsShell extends HTMLElement {
</div>
</div>
<div id="viewPrompts" class="view">
<div class="layout-crud">
<prompts-crud></prompts-crud>
</div>
</div>
<div id="viewTakeovers" class="view">
<div class="layout-crud">
<takeovers-crud></takeovers-crud>

View File

@@ -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 = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:280px 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
textarea { resize:vertical; min-height:200px; font-family:monospace; font-size:12px; line-height:1.5; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.small { padding:4px 8px; font-size:11px; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:14px; }
.item-meta { font-size:11px; color:#8aa0b5; }
.item-meta .default { color:#2ecc71; }
.item-meta .custom { color:#f39c12; }
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.actions { display:flex; gap:8px; flex-wrap:wrap; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.variables-list { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.var-item { display:inline-flex; align-items:center; gap:4px; background:#0f1520; border:1px solid #253245; border-radius:4px; padding:2px 4px 2px 2px; }
.var-btn { background:#253245; border:none; color:#8aa0b5; padding:4px 8px; border-radius:3px; font-size:11px; cursor:pointer; font-family:monospace; }
.var-btn:hover { background:#1f6feb; color:#fff; }
.var-value { font-size:10px; color:#6c7a89; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.versions-list { max-height:150px; overflow-y:auto; margin-top:8px; }
.version-item { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; background:#0f1520; border-radius:4px; margin-bottom:4px; font-size:12px; }
.version-item.active { border-left:3px solid #2ecc71; }
.version-item .ver { color:#e7eef7; }
.version-item .date { color:#8aa0b5; font-size:10px; }
.test-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:16px; }
.test-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
.test-result { background:#0a0e14; border:1px solid #1e2a3a; border-radius:6px; padding:12px; margin-top:12px; font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
.test-result.error { border-color:#e74c3c; color:#e74c3c; }
.test-result.success { border-color:#2ecc71; }
.test-meta { font-size:10px; color:#8aa0b5; margin-top:8px; }
.row { display:flex; gap:12px; align-items:flex-end; }
.row .field { flex:1; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Prompts del Sistema</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="formTitle">Editor de Prompt</div>
<div class="form" id="form">
<div class="form-empty">Selecciona un prompt para editarlo</div>
</div>
</div>
</div>
`;
}
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 = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron prompts</div>`;
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 = `
<div class="item-name">${label}</div>
<div class="item-meta">
<span class="${statusClass}">${statusText}</span>
${item.model ? ` | ${item.model}` : ""}
</div>
`;
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 = `<div class="form-empty">Selecciona un prompt para editarlo</div>`;
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 = `
<div class="row">
<div class="field">
<label>Modelo LLM</label>
<select id="modelSelect">
${this.availableModels.map(m => `<option value="${m}" ${m === model ? "selected" : ""}>${m}</option>`).join("")}
</select>
</div>
<div class="field" style="flex:0">
<label>&nbsp;</label>
<button id="resetBtn" class="secondary">Reset a Default</button>
</div>
</div>
<div class="field" style="flex:1; display:flex; flex-direction:column;">
<label>Contenido del Prompt</label>
<textarea id="contentInput" style="flex:1; min-height:250px;">${this.escapeHtml(content)}</textarea>
<div class="field-hint">
Variables disponibles (click para insertar):
<div class="variables-list" id="variablesList">
${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 `<span class="var-item">
<button class="var-btn" data-var="${key}" title="${desc}">{{${key}}}</button>
<span class="var-value" title="${this.escapeHtml(value)}">${this.escapeHtml(displayValue)}</span>
</span>`;
}).join("")}
</div>
</div>
</div>
${this.versions.length > 0 ? `
<div class="field">
<label>Historial de Versiones</label>
<div class="versions-list" id="versionsList">
${this.versions.map(v => `
<div class="version-item ${v.is_active ? "active" : ""}">
<span class="ver">v${v.version} ${v.is_active ? "(activa)" : ""}</span>
<span class="date">${this.formatDate(v.created_at)}</span>
${!v.is_active ? `<button class="small secondary" data-version="${v.version}">Restaurar</button>` : ""}
</div>
`).join("")}
</div>
</div>
` : ""}
<div class="actions">
<button id="saveBtn">Guardar Cambios</button>
<button id="testBtn" class="secondary">Probar Prompt</button>
</div>
<div class="test-section" id="testSection" style="display:none;">
<h4>Probar Prompt</h4>
<div class="field">
<label>Mensaje de prueba</label>
<input type="text" id="testMessage" placeholder="Ej: Hola, quiero 2kg de asado" />
</div>
<button id="runTestBtn" style="margin-top:8px;">Ejecutar Prueba</button>
<div id="testResultContainer"></div>
</div>
`;
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 = `<div class="test-result">Ejecutando prueba...</div>`;
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 = `
<div class="test-result success">${this.escapeHtml(parsed)}</div>
<div class="test-meta">
Modelo: ${result.model} | Latencia: ${result.latency_ms}ms |
Tokens: ${result.usage?.total_tokens || "?"}
</div>
`;
} else {
container.innerHTML = `<div class="test-result error">Error: ${result.error || "Unknown"}</div>`;
}
} catch (e) {
console.error("Error testing prompt:", e);
container.innerHTML = `<div class="test-result error">Error: ${e.message || e}</div>`;
}
}
}
customElements.define("prompts-crud", PromptsCrud);

View File

@@ -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 });
}
};

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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));

View File

@@ -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 || [] });

View File

@@ -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.`;
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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();
});
});

View File

@@ -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);
},
};

View File

@@ -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";
}

View File

@@ -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");
});
});

View File

@@ -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 };
}
}

View File

@@ -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,
},
};
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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}, ...]
}

View File

@@ -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
}

View File

@@ -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]

View File

@@ -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
}

View File

@@ -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 },
},
};
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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 },
};
}

View File

@@ -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 },
};
}

View File

@@ -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) },
};
}

View File

@@ -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 },
};
}

View File

@@ -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 },
};
}

View File

@@ -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 });
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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("");
});
});

View File

@@ -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";

View File

@@ -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 },
};
}

View File

@@ -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] },
};
}

View File

@@ -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] },
};
}

View File

@@ -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";

View File

@@ -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),
},
};
}

View File

@@ -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?";
}

View File

@@ -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?');
});
});

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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" };
}

View File

@@ -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,
};
}