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:
58
CLAUDE.md
58
CLAUDE.md
@@ -47,36 +47,51 @@ The DB schema retains `tenant_id` columns (it was originally multi-tenant) but t
|
||||
### Request flow
|
||||
|
||||
```
|
||||
WhatsApp → Evolution API webhook → /webhook/evolution
|
||||
WhatsApp → Evolution API webhook → /webhook/evolution (or /sim/send)
|
||||
↓
|
||||
1-intake: route & normalize message
|
||||
↓
|
||||
3-turn-engine: NLU → FSM → state handler
|
||||
2-identity/pipeline.processMessage (idempotency, history, side effects)
|
||||
↓
|
||||
3-turn-engine/agent: tool-calling LLM loop
|
||||
↓
|
||||
Response persisted to DB + sent back via Evolution API
|
||||
```
|
||||
|
||||
### Turn engine: tool-calling agent (DeepSeek)
|
||||
|
||||
`src/modules/3-turn-engine/agent/` es el único motor. Cada turno arma un **WorkingMemory** (cart, pending, last_shown_options, store, history truncado, customer_profile, preparsed quantity) y se lo pasa al LLM como user message. El LLM decide qué tools llamar:
|
||||
|
||||
- `search_catalog`, `add_to_cart`, `set_quantity`, `select_candidate`, `remove_from_cart`
|
||||
- `set_shipping`, `set_address`, `confirm_order`
|
||||
- `pause`, `escalate_to_human`
|
||||
- `say` (último siempre — es el reply al usuario)
|
||||
|
||||
El system prompt es **estático** (en `agent/systemPrompt.js` como `SYSTEM_PROMPT` const) para que DeepSeek lo cachée prefix-cache automáticamente. Cache hit ratio típico ≥70% después de 2 turnos. El parser de cantidades (`agent/quantityParser.js`) preprocesa el texto y se pasa como `working_memory.preparsed` (fracciones, "media docena", "cuarto kilo", etc.).
|
||||
|
||||
La FSM (`fsm.js`) sigue siendo guardrail: estados `IDLE / CART / SHIPPING / PAUSED / AWAITING_HUMAN` con transiciones validadas. PAUSED tiene TTL 7d (cart preservado para "después te digo").
|
||||
|
||||
### Module structure (numbered layers)
|
||||
|
||||
- **`src/modules/0-UI/`** — Admin dashboard: REST controllers for products, conversations, settings, prompts, takeovers, recommendations, aliases. Each controller has a `db/` sub-layer for persistence.
|
||||
- **`src/modules/0-UI/`** — Admin dashboard: REST controllers para products, conversations, settings, takeovers, recommendations, aliases.
|
||||
|
||||
- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp). Normalizes incoming messages before passing to turn engine.
|
||||
- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp).
|
||||
|
||||
- **`src/modules/2-identity/`** — Tenant and user management. Maps WhatsApp numbers to WooCommerce customers. Stores encrypted WooCommerce credentials per tenant in `tenant_ecommerce_config`. Routes WooCommerce webhooks.
|
||||
- **`src/modules/2-identity/`** — User mapping (WhatsApp ↔ WooCommerce customer), encrypted WooCommerce credentials, pipeline orchestrator.
|
||||
|
||||
- **`src/modules/3-turn-engine/`** — Core logic. NLU classifies intents; FSM transitions states (`IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS`). Two NLU versions controlled by `USE_MODULAR_NLU` env flag. Two turn engine versions controlled by `TURN_ENGINE` env flag. State handlers map to FSM states.
|
||||
- **`src/modules/3-turn-engine/`** — Agente tool-calling (`agent/`), FSM (`fsm.js`), order model (`orderModel.js`), catalog retrieval (`catalogRetrieval.js`), store context (`storeContext.js`).
|
||||
|
||||
- **`src/modules/4-woo-orders/`** — WooCommerce order sync. Fetches and caches customer order history for conversation context.
|
||||
- **`src/modules/4-woo-orders/`** — WooCommerce order sync (lectura). El bot crea orders nuevas vía `wooOrders.createOrder` desde `pipeline.js` cuando emite la action `create_order`.
|
||||
|
||||
- **`src/modules/shared/`** — DB pool (PostgreSQL via `pg`), SSE for real-time admin UI updates, WooSnapshot (product catalog cache), debug utilities.
|
||||
- **`src/modules/shared/`** — DB pool, SSE, WooSnapshot, tenant resolver (`getTenantId()`), debug.
|
||||
|
||||
### Key integrations
|
||||
|
||||
| System | Purpose | Config |
|
||||
|--------|---------|--------|
|
||||
| OpenAI | NLU intent classification & response generation | `OPENAI_API_KEY`, `OPENAI_MODEL` |
|
||||
| Evolution API | WhatsApp send/receive | `EVOLUTION_API_URL`, `EVOLUTION_API_KEY`, `EVOLUTION_INSTANCE_NAME`, `EVOLUTION_SEND_ENABLED` |
|
||||
| WooCommerce REST API | Products, orders, customers | `WOO_*` env vars or per-tenant in DB |
|
||||
| LLM (DeepSeek) | Agente tool-calling — único motor | `OPENAI_API_KEY`, `OPENAI_BASE_URL=https://api.deepseek.com/v1`, `OPENAI_MODEL=deepseek-chat` |
|
||||
| Evolution API | WhatsApp send/receive | `EVOLUTION_*`, `EVOLUTION_SEND_ENABLED` |
|
||||
| WooCommerce REST API | Products, orders, customers | `WOO_BASE_URL`, `WOO_CONSUMER_KEY`, `WOO_CONSUMER_SECRET` |
|
||||
| PostgreSQL | Primary database | `DATABASE_URL` |
|
||||
|
||||
### Database
|
||||
@@ -84,18 +99,23 @@ WhatsApp → Evolution API webhook → /webhook/evolution
|
||||
Migrations live in `db/migrations/` as timestamped SQL files managed by `dbmate`. Key tables:
|
||||
- `tenants`, `tenant_config`, `tenant_settings`, `tenant_ecommerce_config`, `tenant_channels`
|
||||
- `wa_identity_map` — WhatsApp ↔ WooCommerce customer mapping
|
||||
- `wa_conversation_state` — FSM state + context per conversation
|
||||
- `wa_messages` — Message history
|
||||
- `woo_products_snapshot` — Cached product catalog
|
||||
- `prompt_templates` — Versioned LLM prompts
|
||||
- `wa_conversation_state` — FSM state + context (cart, pending, last_shown_options, paused_until) en JSONB
|
||||
- `wa_messages` — Message history (idempotencia por message_id)
|
||||
- `woo_products_snapshot` — Cached product catalog (con índices pg_trgm en aliases)
|
||||
- `product_aliases`, `alias_product_mappings` — fuzzy alias resolution
|
||||
- `woo_orders_cache` + `woo_order_items` — orders sync para customer_profile / stats
|
||||
- `human_takeovers`, `audit_log`, `conversation_runs`
|
||||
|
||||
### Feature flags (env vars)
|
||||
|
||||
- `TURN_ENGINE=v1|v2` — Which turn engine version to use
|
||||
- `USE_MODULAR_NLU=1` — Use modular NLU (prompt templates from DB) vs. v3 hardcoded
|
||||
- `EVOLUTION_SEND_ENABLED=1` — Actually send messages to WhatsApp (disable in dev/test)
|
||||
- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — Granular debug logging
|
||||
- `AGENT_MAX_TOOL_CALLS=10` — cap de tool calls por turno
|
||||
- `AGENT_TURN_TIMEOUT_MS=25000` — timeout total del turno
|
||||
- `EVOLUTION_SEND_ENABLED=1` — enviar a WhatsApp real (off en dev)
|
||||
- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — debug logs granular
|
||||
|
||||
### Métricas
|
||||
|
||||
- `GET /api/metrics/agent` — turns, avg tool calls, fallback rate, escalations, **cache_hit_ratio** (prompt caching de DeepSeek)
|
||||
|
||||
### Local development
|
||||
|
||||
|
||||
34
env.example
34
env.example
@@ -13,24 +13,17 @@ PG_CONN_TIMEOUT_MS=5000
|
||||
APP_ENCRYPTION_KEY=your-32-char-encryption-key-here
|
||||
|
||||
# ===================
|
||||
# OpenAI
|
||||
# LLM (OpenAI-compatible: DeepSeek, OpenAI, Anthropic via gateway, etc.)
|
||||
# ===================
|
||||
# Default actual: DeepSeek V3.x con tool-calling nativo + prompt caching automático.
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
OPENAI_MODEL=deepseek-chat
|
||||
|
||||
# ===================
|
||||
# Turn Engine
|
||||
# WooCommerce
|
||||
# ===================
|
||||
# v1 = pipeline actual (heurísticas + guardrails + LLM plan final)
|
||||
# v2 = LLM-first NLU, deterministic core (nuevo motor)
|
||||
TURN_ENGINE=v1
|
||||
|
||||
# ===================
|
||||
# WooCommerce (usado por seed y fallback)
|
||||
# Estas variables son leídas por scripts/seed-tenant.mjs para crear
|
||||
# la configuración inicial del tenant en la base de datos.
|
||||
# ===================
|
||||
WOO_BASE_URL=https://tu-tienda.com
|
||||
WOO_BASE_URL=https://tu-tienda.com/wp-json/wc/v3
|
||||
WOO_CONSUMER_KEY=ck_xxx
|
||||
WOO_CONSUMER_SECRET=cs_xxx
|
||||
|
||||
@@ -49,19 +42,10 @@ LIMIT_CONVERSATIONS=100
|
||||
MAX_CHARS_PER_MESSAGE=4000
|
||||
|
||||
# ===================
|
||||
# Reply Rewriter (LLM adapta templates al contexto)
|
||||
# Agent (tool-calling) — único motor de turno
|
||||
# ===================
|
||||
REPLY_REWRITER=0
|
||||
REPLY_REWRITER_TIMEOUT_MS=1500
|
||||
|
||||
# ===================
|
||||
# XState (Turn engine v2)
|
||||
# ===================
|
||||
# USE_XSTATE=1 → reemplaza dispatcher legacy con statechart formal
|
||||
# XSTATE_SHADOW=1 → corre ambos paths, devuelve legacy, loguea diffs en logs
|
||||
USE_XSTATE=0
|
||||
XSTATE_SHADOW=0
|
||||
XSTATE_SETTLE_MS=10000
|
||||
AGENT_MAX_TOOL_CALLS=10
|
||||
AGENT_TURN_TIMEOUT_MS=25000
|
||||
|
||||
# ===================
|
||||
# Debug Flags (1/true/yes/on para activar)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> </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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
insertVariable(varName) {
|
||||
const textarea = this.shadowRoot.getElementById("contentInput");
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const insertion = `{{${varName}}}`;
|
||||
textarea.value = text.slice(0, start) + insertion + text.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + insertion.length;
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
toggleTestSection() {
|
||||
const section = this.shadowRoot.getElementById("testSection");
|
||||
section.style.display = section.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
async save() {
|
||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
||||
const model = this.shadowRoot.getElementById("modelSelect").value;
|
||||
|
||||
if (!content.trim()) {
|
||||
modal.warn("El contenido no puede estar vacío");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.savePrompt(this.selected.prompt_key, { content, model });
|
||||
modal.success("Prompt guardado correctamente");
|
||||
await this.load();
|
||||
// Re-seleccionar el prompt actual
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error saving prompt:", e);
|
||||
modal.error("Error guardando: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async reset() {
|
||||
const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?");
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.resetPrompt(this.selected.prompt_key);
|
||||
modal.success("Prompt reseteado a default");
|
||||
await this.load();
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error resetting prompt:", e);
|
||||
modal.error("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(version) {
|
||||
const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.rollbackPrompt(this.selected.prompt_key, version);
|
||||
modal.success("Versión restaurada");
|
||||
await this.load();
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error rolling back:", e);
|
||||
modal.error("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async runTest() {
|
||||
const testMessage = this.shadowRoot.getElementById("testMessage").value;
|
||||
if (!testMessage.trim()) {
|
||||
modal.warn("Ingresa un mensaje de prueba");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
||||
const container = this.shadowRoot.getElementById("testResultContainer");
|
||||
container.innerHTML = `<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);
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 || [] });
|
||||
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}, ...]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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) },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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] },
|
||||
};
|
||||
}
|
||||
@@ -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] },
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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?";
|
||||
}
|
||||
@@ -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?');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,394 +1,15 @@
|
||||
/**
|
||||
* Turn Engine V3 - Dispatcher basado en estados
|
||||
* Turn Engine — Thin wrapper sobre el agente tool-calling.
|
||||
*
|
||||
* 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.
|
||||
* 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";
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user