diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index b76adff..1dc2c35 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -16,6 +16,7 @@ import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settin 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() { return new Date().toISOString(); @@ -52,6 +53,7 @@ export function createSimulatorRouter({ tenantId }) { */ 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)); router.get("/conversations/state", makeGetConversationState(getTenantId)); diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index 201ae0c..638fe6f 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -128,12 +128,15 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha mark("start"); const stageDebug = dbg.perf; mark("after_touchConversationState"); - // Detectar conversación nueva (más de 24 horas sin actividad) - const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas + // TTL stale: 24h general, 7d si la conversación quedó PAUSED, sin TTL si AWAITING_HUMAN. + const stateNow = prev?.state || "IDLE"; + let staleThresholdMs = 24 * 60 * 60 * 1000; + if (stateNow === "PAUSED") staleThresholdMs = 7 * 24 * 60 * 60 * 1000; + if (stateNow === "AWAITING_HUMAN") staleThresholdMs = Infinity; const isStale = prev?.state_updated_at && Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs; - const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; + const prev_state = isStale ? "IDLE" : stateNow; let externalCustomerId = await getExternalCustomerIdByChat({ tenant_id: tenantId, wa_chat_id: chat_id, diff --git a/src/modules/3-turn-engine/agent/customerProfile.js b/src/modules/3-turn-engine/agent/customerProfile.js new file mode 100644 index 0000000..186b6ae --- /dev/null +++ b/src/modules/3-turn-engine/agent/customerProfile.js @@ -0,0 +1,112 @@ +/** + * customerProfile — perfil del cliente para "lo de siempre". + * + * Lookup por teléfono (extraído del chat_id WhatsApp) en woo_orders_cache. + * Agrupa items por woo_product_id en los últimos 6 meses, top 5 frequent_items. + * + * Cache 10 min por chat_id. + */ + +import { pool } from "../../shared/db/pool.js"; + +const CACHE_TTL_MS = 10 * 60 * 1000; +const _cache = new Map(); + +function phoneFromChatId(chatId) { + if (!chatId) return null; + // chat_id típico: "5491133230322@s.whatsapp.net" + const m = /^(\d+)/.exec(String(chatId)); + return m ? m[1] : null; +} + +function normalizePhone(p) { + return String(p || "").replace(/[^\d]/g, ""); +} + +/** + * Devuelve perfil del cliente o null si no hay datos. + */ +export async function getCustomerProfile({ tenantId, chat_id }) { + if (!tenantId || !chat_id) return null; + const cacheKey = `${tenantId}:${chat_id}`; + const cached = _cache.get(cacheKey); + if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.value; + + const phone = phoneFromChatId(chat_id); + if (!phone) { + _cache.set(cacheKey, { value: null, t: Date.now() }); + return null; + } + + try { + const profile = await fetchProfile({ tenantId, phone }); + _cache.set(cacheKey, { value: profile, t: Date.now() }); + return profile; + } catch (err) { + console.error("[customerProfile] error:", err?.message || err); + return null; + } +} + +async function fetchProfile({ tenantId, phone }) { + const phoneClean = normalizePhone(phone); + if (!phoneClean) return null; + + // Match phones que terminen igual (los Woo a veces vienen con +54 o sin) + const phoneSuffix = phoneClean.slice(-8); + + const orderSql = ` + SELECT id, woo_order_id, total, date_created, + shipping_address_1, shipping_address_2, shipping_city, customer_name + FROM woo_orders_cache + WHERE tenant_id = $1 + AND regexp_replace(coalesce(customer_phone,''), '\\D', '', 'g') LIKE '%' || $2 + AND date_created > NOW() - INTERVAL '6 months' + ORDER BY date_created DESC + LIMIT 30 + `; + const { rows: orders } = await pool.query(orderSql, [tenantId, phoneSuffix]); + if (orders.length === 0) { + return { is_returning: false, last_order_at: null, frequent_items: [], preferred_address: null }; + } + + const orderIds = orders.map((o) => o.woo_order_id); + const itemsSql = ` + SELECT woo_product_id, product_name, sell_unit, + COUNT(*) AS times, + SUM(quantity) AS total_qty, + AVG(quantity) AS avg_qty + FROM woo_order_items + WHERE tenant_id = $1 AND woo_order_id = ANY($2::bigint[]) AND woo_product_id IS NOT NULL + GROUP BY woo_product_id, product_name, sell_unit + ORDER BY times DESC, total_qty DESC + LIMIT 5 + `; + const { rows: items } = await pool.query(itemsSql, [tenantId, orderIds]); + + const frequent_items = items.map((it) => ({ + woo_id: Number(it.woo_product_id), + name: it.product_name, + times_ordered: Number(it.times), + avg_qty: Number(it.avg_qty), + avg_unit: it.sell_unit || null, + })); + + const lastOrder = orders[0]; + const preferredAddress = [lastOrder.shipping_address_1, lastOrder.shipping_address_2, lastOrder.shipping_city] + .filter(Boolean) + .join(", "); + + return { + is_returning: true, + last_order_at: lastOrder.date_created, + customer_name: lastOrder.customer_name || null, + frequent_items, + preferred_address: preferredAddress || null, + total_orders_last_6m: orders.length, + }; +} + +export function invalidateCustomerProfileCache(chat_id) { + for (const k of _cache.keys()) if (k.endsWith(`:${chat_id}`)) _cache.delete(k); +} diff --git a/src/modules/3-turn-engine/agent/runTurn.js b/src/modules/3-turn-engine/agent/runTurn.js new file mode 100644 index 0000000..4590c87 --- /dev/null +++ b/src/modules/3-turn-engine/agent/runTurn.js @@ -0,0 +1,319 @@ +/** + * runTurn — Punto de entrada del agente tool-calling. + * + * Reemplaza turnEngineV3 cuando AGENT_TURN_ENGINE=1. + * Mantiene la firma compatible con pipeline.js: + * runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) + * → { plan, decision } + */ + +import OpenAI from "openai"; +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 { TOOL_SCHEMAS } from "./tools/schemas.js"; +import { executeToolCall } from "./tools/executor.js"; +import { getCustomerProfile } from "./customerProfile.js"; +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. +const _metrics = { + turns: 0, + total_tool_calls: 0, + total_llm_calls: 0, + total_duration_ms: 0, + fallback_used: 0, + llm_errors: 0, + escalations: 0, + pauses: 0, + orders_confirmed: 0, +}; + +export function getAgentMetrics() { + const t = _metrics.turns; + return { + turns: t, + avg_tool_calls_per_turn: t ? +(_metrics.total_tool_calls / t).toFixed(2) : 0, + avg_llm_calls_per_turn: t ? +(_metrics.total_llm_calls / t).toFixed(2) : 0, + avg_duration_ms: t ? Math.round(_metrics.total_duration_ms / t) : 0, + fallback_rate: t ? +(_metrics.fallback_used / t).toFixed(3) : 0, + error_rate: t ? +(_metrics.llm_errors / t).toFixed(3) : 0, + escalations: _metrics.escalations, + pauses: _metrics.pauses, + orders_confirmed: _metrics.orders_confirmed, + }; +} + +export function resetAgentMetrics() { + for (const k of Object.keys(_metrics)) _metrics[k] = 0; +} + +let _client = null; +function getClient() { + if (_client) return _client; + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY not set"); + const baseURL = process.env.OPENAI_BASE_URL || undefined; + _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); + return _client; +} + +function getModel() { + return process.env.OPENAI_MODEL || "deepseek-chat"; +} + +function withTimeout(promise, ms, label) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms) + ), + ]); +} + +/** + * Punto de entrada principal. Mismo signature que runTurnV3. + */ +export async function runTurnAgent({ + tenantId, + chat_id, + text, + prev_state, + prev_context, + conversation_history, +}) { + const t0 = Date.now(); + const audit = { + trace: { tenantId, chat_id, text_preview: String(text || "").slice(0, 50), prev_state, engine: "agent" }, + tool_calls: [], + llm_calls: 0, + }; + + // Cargar order, store, last_shown_options, customer_profile + const order = migrateOldContext(prev_context); + const storeConfig = await getStoreConfig({ tenantId }); + const lastShownOptions = Array.isArray(prev_context?.last_shown_options) + ? prev_context.last_shown_options + : []; + const customerProfile = await getCustomerProfile({ tenantId, chat_id }).catch((err) => { + audit.customer_profile_error = String(err?.message || err); + return null; + }); + + // Construir working memory + const wm = buildWorkingMemory({ + text, + order, + prev_state: prev_state || "IDLE", + conversation_history, + storeConfig, + customerProfile, + lastShownOptions, + }); + + // Estado mutable que los tools mutan + const ctx = { + tenantId, + chat_id, + order: { ...order, last_shown_options: lastShownOptions }, + pending_actions: [], + last_shown_options: [...lastShownOptions], + storeConfig, + say_text: null, + paused: false, + paused_until: order.paused_until ?? null, + awaiting_human: false, + awaiting_human_reason: null, + fsm_state: prev_state || "IDLE", + }; + + // Mensajes para el LLM + const systemPrompt = buildSystemPrompt({ storeName: storeConfig?.name }); + const messages = [ + { role: "system", content: systemPrompt }, + { role: "user", content: JSON.stringify({ working_memory: wm }) }, + ]; + + // Loop tool-calling + const client = getClient(); + const model = getModel(); + let turnDone = false; + let llmError = null; + + try { + for (let i = 0; i < MAX_TOOL_CALLS && !turnDone; i++) { + audit.llm_calls++; + const elapsed = Date.now() - t0; + const remaining = Math.max(2000, TURN_TIMEOUT_MS - elapsed); + + if (dbg.llm) console.log("[agent] llm.request", { model, iteration: i, remaining_ms: remaining }); + + const resp = await withTimeout( + client.chat.completions.create({ + model, + temperature: 0.4, + max_tokens: 600, + tools: TOOL_SCHEMAS, + tool_choice: "required", + messages, + }), + remaining, + "agent_llm" + ); + + const msg = resp?.choices?.[0]?.message || {}; + messages.push({ role: "assistant", content: msg.content || "", tool_calls: msg.tool_calls || [] }); + + const calls = msg.tool_calls || []; + if (!calls.length) { + // Sin tool calls → forzar say con fallback y salir + audit.no_tool_calls = true; + ctx.say_text = ctx.say_text || msg.content || "Disculpame, no te entendí. ¿Me lo decís de otra forma?"; + turnDone = true; + break; + } + + for (const call of calls) { + const obs = await executeToolCall(call, ctx); + audit.tool_calls.push({ + name: call.function?.name, + ok: obs.ok !== false, + error: obs.error || null, + duration_ms: obs.duration_ms || null, + }); + messages.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify(obs), + }); + if (obs.terminal) { + turnDone = true; + } + } + } + } catch (err) { + llmError = String(err?.message || err); + audit.llm_error = llmError; + if (dbg.llm) console.error("[agent] error", llmError); + } + + // Si no hay say, fallback determinista + if (!ctx.say_text) { + ctx.say_text = pickFallbackReply(ctx, llmError); + audit.fallback_used = true; + } + + audit.duration_ms = Date.now() - t0; + + // Actualizar métricas + _metrics.turns++; + _metrics.total_tool_calls += audit.tool_calls.length; + _metrics.total_llm_calls += audit.llm_calls; + _metrics.total_duration_ms += audit.duration_ms; + if (audit.fallback_used) _metrics.fallback_used++; + if (audit.llm_error) _metrics.llm_errors++; + if (ctx.awaiting_human) _metrics.escalations++; + if (ctx.paused) _metrics.pauses++; + if (ctx.pending_actions.some((a) => a.type === "create_order")) _metrics.orders_confirmed++; + + // Derivar nextState desde el order resultante + const signals = { + confirm_order: ctx.pending_actions.some((a) => a.type === "create_order"), + shipping_completed: ctx.pending_actions.some((a) => a.type === "create_order"), + return_to_cart: false, + }; + // Si el agente pausó la conversación, mantenemos el order pero el next_state + // queda guardado en ctx.fsm_state ("PAUSED") para que pipeline lo persista. + let nextState; + if (ctx.awaiting_human) { + nextState = ConversationState.AWAITING_HUMAN; + } else if (ctx.paused) { + nextState = "PAUSED"; // estado nuevo, fsm.js lo va a permitir tras D7 + } else { + nextState = safeNextState(prev_state, ctx.order, signals).next_state; + } + + return { + plan: { + reply: ctx.say_text, + next_state: nextState, + intent: detectIntent(audit.tool_calls), + missing_fields: [], + order_action: ctx.pending_actions[0]?.type || "none", + basket_resolved: { items: (ctx.order.cart || []).map(toBasketItem) }, + }, + decision: { + actions: ctx.pending_actions, + context_patch: buildContextPatch(ctx), + audit, + }, + }; +} + +function pickFallbackReply(ctx, err) { + if (ctx.awaiting_human) return "Te paso con un humano que pueda ayudarte."; + if (ctx.paused) return "Dale, cuando quieras seguimos."; + if (err) return "Disculpame, tuve un problema. ¿Lo intentás de nuevo?"; + return "No te seguí, ¿me lo decís de otra forma?"; +} + +function detectIntent(toolCalls = []) { + const names = toolCalls.map((c) => c.name); + if (names.includes("confirm_order")) return "confirm_order"; + if (names.includes("set_address") || names.includes("set_shipping")) return "select_shipping"; + if (names.includes("escalate_to_human")) return "escalate"; + if (names.includes("pause")) return "pause"; + if (names.includes("add_to_cart") || names.includes("set_quantity") || names.includes("select_candidate")) return "add_to_cart"; + if (names.includes("remove_from_cart")) return "remove_from_cart"; + if (names.includes("search_catalog")) return "browse"; + return "other"; +} + +function toBasketItem(item) { + return { + 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, + }; +} + +function buildContextPatch(ctx) { + const order = ctx.order || createEmptyOrder(); + return { + order, + order_basket: { items: (order.cart || []).map(toBasketItem) }, + 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, + last_shown_options: ctx.last_shown_options || [], + paused_until: ctx.paused_until || null, + awaiting_human: ctx.awaiting_human || false, + awaiting_human_reason: ctx.awaiting_human_reason || null, + }; +} diff --git a/src/modules/3-turn-engine/agent/systemPrompt.js b/src/modules/3-turn-engine/agent/systemPrompt.js new file mode 100644 index 0000000..959ceca --- /dev/null +++ b/src/modules/3-turn-engine/agent/systemPrompt.js @@ -0,0 +1,76 @@ +/** + * 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. + */ + +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. + +TU TRABAJO ES UNO SOLO: tomar pedidos por WhatsApp. +1. Entendés lo que pide el cliente y lo anotás en el carrito. +2. Coordinás envío (delivery o retiro) y dirección si corresponde. +3. Cerrás el pedido. NO cobrás — el pago lo coordina el comercio aparte. + +REGLAS DURAS: +- NUNCA inventes productos ni precios. Para CUALQUIER producto que el cliente + mencione, llamá primero a search_catalog. Si la lista viene vacía, decilo. +- NUNCA pidas datos que ya están en el contexto (cart, dirección, método). +- NO ofrezcas promociones, métodos de pago, ni info que no esté en store. +- Si el cliente mezcla pedido + duda + cambio de tema, resolvé con tools y + aclarás lo que falte en say. +- Si te pide algo fuera de tomar pedidos (queja, cambio, factura, otro idioma), + llamá escalate_to_human. + +CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory): +- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until. +- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg" + → 0.25 kg). Confiá en eso si su confidence ≥ 0.85. +- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé + contra last_shown_options. Si está vacío, pedí que aclare. +- Si user da SOLO una cantidad ("300 gramos", "media docena") y hay un pending + con status=NEEDS_QUANTITY, asumí que es para ese producto y llamá set_quantity. +- Si user dice "lo de siempre" / "lo mismo de la otra vez", mirá + customer_profile.frequent_items. Si hay 1-2 items, ofrecelos con + search_catalog para confirmar y un say preguntando si va eso. +- Si menciona producto genérico ("asado") y el catálogo tiene varios, + mostrá top 3-5 con say (numerados). El sistema guarda last_shown_options + automáticamente. +- Si dice "después te digo" / "más tarde" / "ahora no puedo", llamá pause + con reason="user_paused" y un say corto tipo "Dale, cuando quieras seguimos". + +CÓMO ESCRIBÍS EL say: +- 1-2 oraciones máximo. Concreto. Sin emojis. +- Confirmá lo anotado con cantidad y producto: "Va, anoté 500g de Vacío. + ¿Algo más?" +- Cuando preguntás cantidad, mencioná el producto: "¿Cuántas botellas de + Chimichurri querés?" — NUNCA "¿cuántas?" pelado. +- Si no encontraste el producto, sugerí 1-2 alternativas concretas que + vinieron del catálogo: "No tengo Chinchulín, pero tengo Mollejas y + Riñones. ¿Te sirve alguno?" +- Cuando cerrás el pedido, listá items y pasás a shipping. + +ORDEN DE TOOLS EN UN TURNO TÍPICO: +1. (opcional) search_catalog si hay producto sin resolver. Llamala UNA VEZ por + producto distinto. NO la llames de nuevo con la misma query si ya devolvió + resultados — usá los que tenés. +2. Si search_catalog devolvió 1 candidato fuerte → add_to_cart con qty/unit. +3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que + elija entre los top 3-5 (numerados). El sistema guarda last_shown_options. +4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping / + set_address / confirm_order / remove_from_cart / pause / escalate_to_human. +5. say SIEMPRE como último tool del turno. Sin say no hay respuesta. + +LIMITES TÉCNICOS: +- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas + redundantes — si una query ya devolvió X candidatos, NO la repitas. +- Si después de 2 búsquedas no encontrás nada útil, llamá say pidiendo que el + cliente reformule ("no te encontré X, ¿lo decís de otra forma?"). + +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/agent/tools/addToCart.js b/src/modules/3-turn-engine/agent/tools/addToCart.js new file mode 100644 index 0000000..1633cc6 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/addToCart.js @@ -0,0 +1,66 @@ +/** + * add_to_cart — agrega un producto resuelto al carrito. + * + * Valida woo_id contra el snapshot (anti-halucinación). Si no existe, + * devuelve error obligando re-search. + */ + +import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js"; +import { createCartItem } from "../../orderModel.js"; + +export async function addToCartTool(args, ctx) { + const { woo_id, qty, unit } = args; + + // Validar que el producto existe en el snapshot + const lookup = await getSnapshotItemsByIds({ + tenantId: ctx.tenantId, + wooProductIds: [woo_id], + }); + const found = (lookup?.items || [])[0]; + if (!found) { + return { + ok: false, + error: "woo_id_unknown", + hint: "Volvé a llamar search_catalog para obtener un woo_id válido.", + }; + } + + // Crear item de carrito + const newItem = createCartItem({ + woo_id, + qty, + unit, + name: found.name, + price: found.price ?? null, + }); + + // Si ya existe el woo_id en el cart, sumamos cantidad + const cart = ctx.order.cart || []; + const existingIdx = cart.findIndex((c) => Number(c.woo_id) === Number(woo_id)); + let nextCart; + if (existingIdx >= 0) { + nextCart = cart.map((c, i) => + i === existingIdx ? { ...c, qty: (c.qty || 0) + qty, unit: c.unit || unit } : c + ); + } else { + nextCart = [...cart, newItem]; + } + ctx.order = { ...ctx.order, cart: nextCart }; + + // Enqueue add_to_cart action para SSE/UI (reutiliza shape existente) + ctx.pending_actions.push({ type: "add_to_cart", payload: newItem }); + + // Reset failed_searches si hubiera + if (ctx.order.failed_searches) { + ctx.order = { ...ctx.order, failed_searches: { count: 0 } }; + } + + // Limpiar last_shown_options — ya resolvió la elección + ctx.last_shown_options = []; + + return { + ok: true, + cart_size: nextCart.length, + added: { woo_id, name: found.name, qty, unit }, + }; +} diff --git a/src/modules/3-turn-engine/agent/tools/confirmOrder.js b/src/modules/3-turn-engine/agent/tools/confirmOrder.js new file mode 100644 index 0000000..a6fcc25 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/confirmOrder.js @@ -0,0 +1,32 @@ +/** + * confirm_order — emite create_order si hay cart + shipping completo. + */ + +import { hasCartItems, hasShippingInfo } from "../../fsm.js"; + +export async function confirmOrderTool(_args, ctx) { + if (!hasCartItems(ctx.order)) { + return { ok: false, error: "empty_cart", hint: "Pedile al cliente que agregue productos antes de confirmar." }; + } + if (!hasShippingInfo(ctx.order)) { + return { + ok: false, + error: "shipping_missing", + hint: + ctx.order.is_delivery == null + ? "Falta saber si es delivery o pickup. Llamá set_shipping." + : "Falta dirección. Llamá set_address.", + }; + } + // Idempotencia: si ya existe create_order encolado, no duplicar + const already = ctx.pending_actions.some((a) => a.type === "create_order"); + if (!already) { + ctx.pending_actions.push({ type: "create_order", payload: { source: "wa_bot" } }); + } + return { + ok: true, + cart_size: (ctx.order.cart || []).length, + is_delivery: !!ctx.order.is_delivery, + address: ctx.order.shipping_address || null, + }; +} diff --git a/src/modules/3-turn-engine/agent/tools/escalateToHuman.js b/src/modules/3-turn-engine/agent/tools/escalateToHuman.js new file mode 100644 index 0000000..fa75d74 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/escalateToHuman.js @@ -0,0 +1,16 @@ +/** + * escalate_to_human — pasa la conversación a awaiting_human. + * El registro real en human_takeovers se crea downstream en pipeline.js + * vía la action "request_human_takeover". + */ + +export async function escalateToHumanTool(args, ctx) { + const { reason } = args; + ctx.awaiting_human = true; + ctx.awaiting_human_reason = String(reason || "unspecified"); + ctx.pending_actions.push({ + type: "request_human_takeover", + payload: { reason: ctx.awaiting_human_reason, source: "agent" }, + }); + return { ok: true, reason: ctx.awaiting_human_reason }; +} diff --git a/src/modules/3-turn-engine/agent/tools/executor.js b/src/modules/3-turn-engine/agent/tools/executor.js new file mode 100644 index 0000000..07fcb48 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/executor.js @@ -0,0 +1,102 @@ +/** + * Executor: parsea + valida + ejecuta tool calls del agente. + * Devuelve `obs` (objeto serializable) que se pushea como `tool` message. + * + * Convenciones: + * - obs.ok: true|false + * - obs.error: string si !ok + * - obs.terminal: true si esta tool finaliza el turno (say, pause, escalate) + */ + +import Ajv from "ajv"; +import { TOOL_SCHEMAS } from "./schemas.js"; +import { searchCatalogTool } from "./searchCatalog.js"; +import { addToCartTool } from "./addToCart.js"; +import { setQuantityTool } from "./setQuantity.js"; +import { selectCandidateTool } from "./selectCandidate.js"; +import { removeFromCartTool } from "./removeFromCart.js"; +import { setShippingTool } from "./setShipping.js"; +import { setAddressTool } from "./setAddress.js"; +import { confirmOrderTool } from "./confirmOrder.js"; +import { pauseTool } from "./pause.js"; +import { escalateToHumanTool } from "./escalateToHuman.js"; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +// Compilar validators una vez +const VALIDATORS = {}; +for (const t of TOOL_SCHEMAS) { + VALIDATORS[t.function.name] = ajv.compile(t.function.parameters); +} + +const TOOLS = { + search_catalog: searchCatalogTool, + add_to_cart: addToCartTool, + set_quantity: setQuantityTool, + select_candidate: selectCandidateTool, + remove_from_cart: removeFromCartTool, + set_shipping: setShippingTool, + set_address: setAddressTool, + confirm_order: confirmOrderTool, + pause: pauseTool, + escalate_to_human: escalateToHumanTool, + // `say` se maneja inline (asigna ctx.say_text y termina el turno) +}; + +export async function executeToolCall(call, ctx) { + const t0 = Date.now(); + const name = call?.function?.name; + const argsRaw = call?.function?.arguments || "{}"; + + let args; + try { + args = typeof argsRaw === "string" ? JSON.parse(argsRaw) : argsRaw; + } catch (e) { + return { + ok: false, + error: `invalid_json_args: ${String(e?.message || e)}`, + duration_ms: Date.now() - t0, + }; + } + + // `say` es especial: termina el turno + if (name === "say") { + const validator = VALIDATORS.say; + if (!validator(args)) { + return { ok: false, error: "say_args_invalid", details: validator.errors, duration_ms: Date.now() - t0 }; + } + ctx.say_text = args.text; + return { ok: true, terminal: true, duration_ms: Date.now() - t0 }; + } + + const validator = VALIDATORS[name]; + if (!validator) { + return { ok: false, error: `unknown_tool: ${name}`, duration_ms: Date.now() - t0 }; + } + if (!validator(args)) { + return { + ok: false, + error: "args_schema_invalid", + details: validator.errors, + tool: name, + duration_ms: Date.now() - t0, + }; + } + + const handler = TOOLS[name]; + if (!handler) { + return { ok: false, error: `tool_not_implemented: ${name}`, duration_ms: Date.now() - t0 }; + } + + try { + const result = await handler(args, ctx); + return { ...result, duration_ms: Date.now() - t0 }; + } catch (err) { + return { + ok: false, + error: `tool_error: ${String(err?.message || err)}`, + tool: name, + duration_ms: Date.now() - t0, + }; + } +} diff --git a/src/modules/3-turn-engine/agent/tools/pause.js b/src/modules/3-turn-engine/agent/tools/pause.js new file mode 100644 index 0000000..c72bbe7 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/pause.js @@ -0,0 +1,14 @@ +/** + * pause — marca la conversación como pausada (TTL 7d). + * + * El cart NO se limpia. Cuando el cliente vuelva, sale de paused y sigue. + */ + +const PAUSE_TTL_MS = 7 * 24 * 3600 * 1000; + +export async function pauseTool(args, ctx) { + const { reason = "user_paused" } = args; + ctx.paused = true; + ctx.paused_until = new Date(Date.now() + PAUSE_TTL_MS).toISOString(); + return { ok: true, paused_until: ctx.paused_until, reason, terminal: false }; +} diff --git a/src/modules/3-turn-engine/agent/tools/removeFromCart.js b/src/modules/3-turn-engine/agent/tools/removeFromCart.js new file mode 100644 index 0000000..2bcfce0 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/removeFromCart.js @@ -0,0 +1,43 @@ +/** + * remove_from_cart — quita un producto por woo_id (string numérico) o por + * substring del nombre. + */ + +import { removeCartItem } from "../../orderModel.js"; + +export async function removeFromCartTool(args, ctx) { + const { target } = args; + const t = String(target || "").trim(); + if (!t) return { ok: false, error: "empty_target" }; + + // Si es un número puro, intentar match por woo_id directo + const asNumber = /^\d+$/.test(t) ? Number(t) : null; + let removed = null; + let nextOrder = ctx.order; + + if (asNumber != null) { + const cart = ctx.order.cart || []; + const idx = cart.findIndex((c) => Number(c.woo_id) === asNumber); + if (idx >= 0) { + removed = cart[idx]; + nextOrder = { ...ctx.order, cart: cart.filter((_, i) => i !== idx) }; + } + } + + // Match por nombre + if (!removed) { + const result = removeCartItem(ctx.order, t); + if (result?.removed) { + removed = result.removed; + nextOrder = result.order; + } + } + + if (!removed) { + return { ok: false, error: "not_found_in_cart", target: t }; + } + + ctx.order = nextOrder; + ctx.pending_actions.push({ type: "remove_from_cart", payload: { removed } }); + return { ok: true, removed: { woo_id: removed.woo_id, name: removed.name }, cart_size: (nextOrder.cart || []).length }; +} diff --git a/src/modules/3-turn-engine/agent/tools/schemas.js b/src/modules/3-turn-engine/agent/tools/schemas.js new file mode 100644 index 0000000..7c14f36 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/schemas.js @@ -0,0 +1,176 @@ +/** + * JSON Schemas de los tools que el agente puede invocar. Formato + * OpenAI/DeepSeek (function calling). + * + * El executor valida los args con Ajv y devuelve error obligando re-llamada + * si falla. + */ + +export const TOOL_SCHEMAS = [ + { + type: "function", + function: { + name: "search_catalog", + description: + "Busca productos en el catálogo. Devuelve top candidatos con woo_id, nombre, precio, unidad de venta. Llamala SIEMPRE antes de cualquier add_to_cart si no tenés el woo_id.", + parameters: { + type: "object", + additionalProperties: false, + required: ["query"], + properties: { + query: { type: "string", minLength: 2, description: "Término de búsqueda libre, lo que dijo el cliente." }, + hint_category: { type: "string", description: "Categoría heurística para fallback (ej. 'parrilla', 'embutidos')." }, + limit: { type: "integer", minimum: 1, maximum: 10 }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "add_to_cart", + description: "Agrega un producto resuelto al carrito.", + parameters: { + type: "object", + additionalProperties: false, + required: ["woo_id", "qty", "unit"], + properties: { + woo_id: { type: "integer", description: "Woo product ID exacto del producto." }, + qty: { type: "number", exclusiveMinimum: 0 }, + unit: { type: "string", enum: ["kg", "g", "unit"] }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "set_quantity", + description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.", + parameters: { + type: "object", + additionalProperties: false, + required: ["pending_id", "qty", "unit"], + properties: { + pending_id: { type: "string" }, + qty: { type: "number", exclusiveMinimum: 0 }, + unit: { type: "string", enum: ["kg", "g", "unit"] }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "select_candidate", + description: "Resuelve un pending NEEDS_TYPE eligiendo uno de los candidatos mostrados (last_shown_options).", + parameters: { + type: "object", + additionalProperties: false, + required: ["pending_id", "woo_id"], + properties: { + pending_id: { type: "string" }, + woo_id: { type: "integer" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "remove_from_cart", + description: "Quita un producto del carrito por woo_id o nombre/query.", + parameters: { + type: "object", + additionalProperties: false, + required: ["target"], + properties: { + target: { type: "string", description: "woo_id como string o nombre del producto a quitar." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "set_shipping", + description: "Setea el método de envío (delivery o pickup).", + parameters: { + type: "object", + additionalProperties: false, + required: ["method"], + properties: { + method: { type: "string", enum: ["delivery", "pickup"] }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "set_address", + description: "Setea la dirección de entrega y valida zona. Si está fuera de zona devuelve error.", + parameters: { + type: "object", + additionalProperties: false, + required: ["text"], + properties: { + text: { type: "string", minLength: 5 }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "confirm_order", + description: "Confirma el pedido. Requiere cart no vacío y shipping completo (pickup o delivery+address). Emite acción create_order.", + parameters: { type: "object", additionalProperties: false, properties: {} }, + }, + }, + { + type: "function", + function: { + name: "pause", + description: "Pausa la conversación cuando el cliente dice 'después te digo' o equivalente. Mantiene el cart 7 días.", + parameters: { + type: "object", + additionalProperties: false, + required: ["reason"], + properties: { + reason: { type: "string", enum: ["user_paused", "user_busy", "needs_to_check"] }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "escalate_to_human", + description: "Escala a un humano (quejas, dudas de pago/factura, urgencias, no podemos resolver).", + parameters: { + type: "object", + additionalProperties: false, + required: ["reason"], + properties: { + reason: { type: "string", minLength: 3 }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "say", + description: "Texto final que se envía al cliente. ÚLTIMO tool del turno SIEMPRE.", + parameters: { + type: "object", + additionalProperties: false, + required: ["text"], + properties: { + text: { type: "string", minLength: 1, maxLength: 600 }, + }, + }, + }, + }, +]; diff --git a/src/modules/3-turn-engine/agent/tools/searchCatalog.js b/src/modules/3-turn-engine/agent/tools/searchCatalog.js new file mode 100644 index 0000000..00a6a00 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/searchCatalog.js @@ -0,0 +1,106 @@ +/** + * search_catalog tool — wrappea retrieveCandidates con fallback por categoría. + * + * Side effects: muta `ctx.last_shown_options` con el top de candidatos para + * que select_candidate pueda resolver "el segundo" en turnos posteriores. + */ + +import { retrieveCandidates } from "../../catalogRetrieval.js"; +import { searchSnapshotItems } from "../../../shared/wooSnapshot.js"; +import { pool } from "../../../shared/db/pool.js"; + +const MIN_GOOD_SCORE = 0.4; + +function summarizeCandidate(c, idx) { + return { + index: idx + 1, + woo_id: c.woo_product_id, + name: c.name, + price: c.price ?? null, + sell_unit: c.sell_unit || null, + score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null, + }; +} + +export async function searchCatalogTool(args, ctx) { + const { query, hint_category = null, limit = 5 } = args; + + // 1) Búsqueda directa con catalogRetrieval (pg_trgm + alias + snapshot) + const result = await retrieveCandidates({ + tenantId: ctx.tenantId, + query, + limit: Math.max(limit, 5), + }); + let candidates = result?.candidates || []; + + // 2) Fallback por categoría si la búsqueda directa rinde poco + let usedFallback = null; + if ( + (candidates.length === 0 || + (candidates[0]?._score || 0) < MIN_GOOD_SCORE) && + hint_category + ) { + const byCategory = await searchByCategory({ + tenantId: ctx.tenantId, + category: hint_category, + limit, + }); + if (byCategory.length > 0) { + usedFallback = "category"; + candidates = byCategory; + } + } + + // Recortar al limit final + const top = candidates.slice(0, limit).map(summarizeCandidate); + + // Guardar last_shown_options si hay >1 (para select_candidate posterior) + if (top.length > 1) { + ctx.last_shown_options = top.map((t) => ({ + index: t.index, + woo_id: t.woo_id, + name: t.name, + price: t.price, + })); + } + + return { + ok: true, + query, + candidates: top, + used_fallback: usedFallback, + count: top.length, + }; +} + +async function searchByCategory({ tenantId, category, limit }) { + // Buscar productos cuya categories JSONB contenga el name/slug indicado. + // Sin nuevas tablas — explota lo que ya está en woo_products_snapshot.categories. + const sql = ` + SELECT woo_product_id, name, sku, slug, price, stock_status, stock_qty, + categories, sell_unit, payload + FROM woo_products_snapshot + WHERE tenant_id = $1 + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(categories) cat + WHERE LOWER(cat) LIKE '%' || LOWER($2) || '%' + ) + AND COALESCE(stock_status, 'instock') != 'outofstock' + ORDER BY name ASC + LIMIT $3 + `; + try { + const { rows } = await pool.query(sql, [tenantId, category, limit]); + return rows.map((r) => ({ + woo_product_id: r.woo_product_id, + name: r.name, + price: r.price, + sell_unit: r.sell_unit, + _score: 0.5, // score sintético para que pase MIN_GOOD_SCORE en el caller + })); + } catch (err) { + // Fallback: snapshot search por texto plano + const r = await searchSnapshotItems({ tenantId, q: category, limit }); + return r?.items || []; + } +} diff --git a/src/modules/3-turn-engine/agent/tools/selectCandidate.js b/src/modules/3-turn-engine/agent/tools/selectCandidate.js new file mode 100644 index 0000000..32f0a22 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/selectCandidate.js @@ -0,0 +1,72 @@ +/** + * select_candidate — resuelve un pending NEEDS_TYPE eligiendo woo_id. + * + * Si el pending tenía `requested_qty` ya, lo promueve directo a READY → cart. + * Sino lo deja en NEEDS_QUANTITY esperando set_quantity. + */ + +import { + updatePendingItem, + moveReadyToCart, + PendingStatus, +} from "../../orderModel.js"; +import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js"; + +export async function selectCandidateTool(args, ctx) { + const { pending_id, woo_id } = args; + const pending = (ctx.order.pending || []).find((p) => p.id === pending_id); + if (!pending) { + return { ok: false, error: "pending_not_found" }; + } + + // Buscar el producto seleccionado en el snapshot para nombre/unit/precio + const lookup = await getSnapshotItemsByIds({ + tenantId: ctx.tenantId, + wooProductIds: [woo_id], + }); + const found = (lookup?.items || [])[0]; + if (!found) { + return { + ok: false, + error: "woo_id_unknown", + hint: "El woo_id no existe en el catálogo. Probá con search_catalog.", + }; + } + + const sellsByWeight = + !found.sell_unit || !["unit", "unidad"].includes(found.sell_unit); + const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit"; + + const hasRequestedQty = + pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0; + const finalQty = hasRequestedQty ? pending.requested_qty : null; + const finalUnit = pending.requested_unit || displayUnit; + const needsQty = sellsByWeight && !hasRequestedQty; + + const updated = updatePendingItem(ctx.order, pending_id, { + selected_woo_id: woo_id, + selected_name: found.name, + selected_price: found.price ?? null, + selected_unit: displayUnit, + candidates: [], + qty: needsQty ? null : finalQty, + unit: finalUnit, + status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, + }); + ctx.order = moveReadyToCart(updated); + + if (!needsQty) { + ctx.pending_actions.push({ + type: "add_to_cart", + payload: { woo_id, qty: finalQty, unit: finalUnit }, + }); + ctx.last_shown_options = []; + } + + return { + ok: true, + selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight }, + needs_quantity: needsQty, + cart_size: (ctx.order.cart || []).length, + }; +} diff --git a/src/modules/3-turn-engine/agent/tools/setAddress.js b/src/modules/3-turn-engine/agent/tools/setAddress.js new file mode 100644 index 0000000..fa0cb24 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/setAddress.js @@ -0,0 +1,36 @@ +/** + * set_address — fija dirección y valida zona. + */ + +import { checkAddressInZone } from "../../storeContext.js"; + +export async function setAddressTool(args, ctx) { + const { text } = args; + const address = String(text || "").trim(); + if (address.length < 5) return { ok: false, error: "address_too_short" }; + + // Validar zona si hay zonas cargadas + const zoneCheck = checkAddressInZone({ + address, + storeConfig: ctx.storeConfig, + }); + if (!zoneCheck.inZone) { + return { + ok: false, + error: "out_of_zone", + reason: zoneCheck.reason, + available_zones: zoneCheck.zones, + hint: "Pedile al cliente otra dirección o ofrecele pickup.", + }; + } + + // Si no había is_delivery seteado, asumir delivery=true (le dieron dirección) + const is_delivery = ctx.order.is_delivery == null ? true : ctx.order.is_delivery; + ctx.order = { ...ctx.order, shipping_address: address, is_delivery }; + return { + ok: true, + address, + in_zone: true, + matched_zone: zoneCheck.matched || null, + }; +} diff --git a/src/modules/3-turn-engine/agent/tools/setQuantity.js b/src/modules/3-turn-engine/agent/tools/setQuantity.js new file mode 100644 index 0000000..e8dd681 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/setQuantity.js @@ -0,0 +1,36 @@ +/** + * set_quantity — completa la cantidad de un pending NEEDS_QUANTITY y lo + * promueve a READY → cart. + */ + +import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js"; + +export async function setQuantityTool(args, ctx) { + const { pending_id, qty, unit } = args; + const pending = (ctx.order.pending || []).find((p) => p.id === pending_id); + if (!pending) { + return { ok: false, error: "pending_not_found", hint: "Verificá el pending_id contra working_memory.order.pending." }; + } + if (!pending.selected_woo_id) { + return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." }; + } + + const updated = updatePendingItem(ctx.order, pending_id, { + qty, + unit, + status: PendingStatus.READY, + }); + ctx.order = moveReadyToCart(updated); + + ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id: pending.selected_woo_id, qty, unit } }); + ctx.last_shown_options = []; + if (ctx.order.failed_searches) { + ctx.order = { ...ctx.order, failed_searches: { count: 0 } }; + } + + return { + ok: true, + promoted: { name: pending.selected_name, qty, unit }, + cart_size: (ctx.order.cart || []).length, + }; +} diff --git a/src/modules/3-turn-engine/agent/tools/setShipping.js b/src/modules/3-turn-engine/agent/tools/setShipping.js new file mode 100644 index 0000000..a033ae5 --- /dev/null +++ b/src/modules/3-turn-engine/agent/tools/setShipping.js @@ -0,0 +1,12 @@ +/** + * set_shipping — fija el método de envío. + */ + +export async function setShippingTool(args, ctx) { + const { method } = args; + if (method !== "delivery" && method !== "pickup") { + return { ok: false, error: "invalid_method" }; + } + ctx.order = { ...ctx.order, is_delivery: method === "delivery" }; + return { ok: true, method, requires_address: method === "delivery" && !ctx.order.shipping_address }; +} diff --git a/src/modules/3-turn-engine/agent/workingMemory.js b/src/modules/3-turn-engine/agent/workingMemory.js new file mode 100644 index 0000000..fdef17e --- /dev/null +++ b/src/modules/3-turn-engine/agent/workingMemory.js @@ -0,0 +1,118 @@ +/** + * WorkingMemory — payload contextual que recibe el agente cada turno. + * + * Se serializa como JSON y se inyecta en el primer USER message. NO va en + * system (eso permite cachear el system prompt entre turnos). + * + * Reglas de poda: + * - history: últimos 8 mensajes, content truncado a 200 chars. + * - customer_profile: top 5 frequent_items. + * - last_shown_options: top 8. + * - cart/pending: enteros, sin truncar. + */ + +import { parseQuantity } from "./quantityParser.js"; +import { buildStoreContextVars } from "../storeContext.js"; + +const HISTORY_MAX = 8; +const HISTORY_CHAR_CAP = 200; +const LAST_SHOWN_MAX = 8; + +function nowIso() { + return new Date().toISOString(); +} + +function truncate(s, n) { + if (s == null) return ""; + const str = String(s); + if (str.length <= n) return str; + return str.slice(0, n - 1) + "…"; +} + +/** + * @param {Object} params + * @param {string} params.text - Mensaje crudo del usuario en este turno. + * @param {Object} params.order - order del context (cart, pending, etc.) + * @param {string} params.prev_state - estado FSM previo + * @param {Array} params.conversation_history + * @param {Object} params.storeConfig - resultado de getStoreConfig + * @param {Object} params.customerProfile - perfil del cliente (puede ser null) + * @param {Array} params.lastShownOptions - opciones del turno previo + */ +export function buildWorkingMemory({ + text, + order = {}, + prev_state = "IDLE", + conversation_history = [], + storeConfig = {}, + customerProfile = null, + lastShownOptions = [], +}) { + const storeVars = buildStoreContextVars(storeConfig); + + const cart = (order.cart || []).map((it) => ({ + woo_id: it.woo_id, + name: it.name, + qty: it.qty, + unit: it.unit, + price: it.price ?? null, + })); + + const pending = (order.pending || []).map((p) => ({ + id: p.id, + query: p.query, + status: p.status, + selected_woo_id: p.selected_woo_id ?? null, + selected_name: p.selected_name ?? null, + selected_unit: p.selected_unit ?? null, + requested_qty: p.requested_qty ?? null, + requested_unit: p.requested_unit ?? null, + candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({ + index: i + 1, + woo_id: c.woo_id ?? c.woo_product_id, + name: c.name, + price: c.price ?? null, + display_unit: c.display_unit ?? null, + })), + })); + + const history = (conversation_history || []).slice(-HISTORY_MAX).map((m) => ({ + role: m.role === "user" ? "user" : "assistant", + text: truncate(m.content || m.text || "", HISTORY_CHAR_CAP), + })); + + const last_shown_options = (lastShownOptions || []).slice(0, LAST_SHOWN_MAX).map((o, i) => ({ + index: o.index ?? i + 1, + woo_id: o.woo_id, + name: o.name, + price: o.price ?? null, + })); + + const preparsed = parseQuantity(text || ""); + + return { + now: nowIso(), + store: { + name: storeVars.store_name || "la carnicería", + hours_today: storeVars.store_hours_today || "consultar", + delivery: { + available_now: storeVars.delivery_available_now || "", + zones_summary: storeVars.delivery_zones_summary || "", + }, + }, + fsm_state: prev_state || "IDLE", + order: { + cart, + pending, + is_delivery: order.is_delivery ?? null, + shipping_address: order.shipping_address ?? null, + woo_order_id: order.woo_order_id ?? null, + }, + last_shown_options, + paused_until: order.paused_until ?? null, + customer_profile: customerProfile, + history, + user_message: text || "", + preparsed, + }; +} diff --git a/src/modules/3-turn-engine/fsm.js b/src/modules/3-turn-engine/fsm.js index 1c04662..43a47c0 100644 --- a/src/modules/3-turn-engine/fsm.js +++ b/src/modules/3-turn-engine/fsm.js @@ -10,7 +10,8 @@ export const ConversationState = Object.freeze({ IDLE: "IDLE", CART: "CART", SHIPPING: "SHIPPING", - AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano + PAUSED: "PAUSED", // Cliente dijo "después te digo" - cart preservado, TTL 7d + AWAITING_HUMAN: "AWAITING_HUMAN", }); export const ALL_STATES = Object.freeze(Object.values(ConversationState)); @@ -27,6 +28,11 @@ export const INTENTS_BY_STATE = Object.freeze({ [ConversationState.SHIPPING]: [ "provide_address", "select_shipping", "add_to_cart", "view_cart", "other", ], + [ConversationState.PAUSED]: [ + // Cualquier intent del cliente lo reactiva; el agente decide el destino. + "greeting", "add_to_cart", "browse", "view_cart", "confirm_order", + "select_shipping", "provide_address", "remove_from_cart", "other", + ], [ConversationState.AWAITING_HUMAN]: [ "other", ], @@ -142,18 +148,29 @@ const ALLOWED = Object.freeze({ [ConversationState.IDLE]: [ ConversationState.IDLE, ConversationState.CART, + ConversationState.PAUSED, ConversationState.AWAITING_HUMAN, ], [ConversationState.CART]: [ ConversationState.CART, ConversationState.SHIPPING, ConversationState.IDLE, + ConversationState.PAUSED, ConversationState.AWAITING_HUMAN, ], [ConversationState.SHIPPING]: [ ConversationState.SHIPPING, ConversationState.IDLE, ConversationState.CART, + ConversationState.PAUSED, + ConversationState.AWAITING_HUMAN, + ], + [ConversationState.PAUSED]: [ + // Cualquier mensaje saca de paused + ConversationState.PAUSED, + ConversationState.CART, + ConversationState.SHIPPING, + ConversationState.IDLE, ConversationState.AWAITING_HUMAN, ], [ConversationState.AWAITING_HUMAN]: [ diff --git a/src/modules/3-turn-engine/fsm.test.js b/src/modules/3-turn-engine/fsm.test.js index 483eaf5..c5001b8 100644 --- a/src/modules/3-turn-engine/fsm.test.js +++ b/src/modules/3-turn-engine/fsm.test.js @@ -17,18 +17,19 @@ import { } from './fsm.js'; describe('ConversationState', () => { - it('tiene los estados del flujo (sin payment/waiting)', () => { + it('tiene los estados del flujo (incluye PAUSED)', () => { expect(ConversationState.IDLE).toBe('IDLE'); expect(ConversationState.CART).toBe('CART'); expect(ConversationState.SHIPPING).toBe('SHIPPING'); + expect(ConversationState.PAUSED).toBe('PAUSED'); expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN'); expect(ConversationState.PAYMENT).toBeUndefined(); expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined(); }); - it('ALL_STATES contiene 4 estados', () => { - expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'AWAITING_HUMAN'])); - expect(ALL_STATES).toHaveLength(4); + it('ALL_STATES contiene 5 estados', () => { + expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'PAUSED', 'AWAITING_HUMAN'])); + expect(ALL_STATES).toHaveLength(5); }); it('INTENTS_BY_STATE define intents por estado', () => { diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index 847e231..013c9fc 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -19,11 +19,12 @@ import { 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 para XState +// Feature flags function useXState() { const v = String(process.env.USE_XSTATE || "").toLowerCase(); return v === "1" || v === "true" || v === "yes"; @@ -32,6 +33,14 @@ 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"; +} /** * Compara plan/decision entre legacy y XState para shadow mode. @@ -81,6 +90,11 @@ export async function runTurnV3({ 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 }); @@ -206,8 +220,7 @@ export async function runTurnV3({ const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches); - // Shadow mode: corre XState en paralelo, devuelve legacy, persiste diffs - // estructurales en audit_log para revisarlos después. + // 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) => { @@ -229,6 +242,37 @@ export async function runTurnV3({ .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; }