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