diff --git a/.cursor/debug.log b/.cursor/debug.log new file mode 100644 index 0000000..c226620 --- /dev/null +++ b/.cursor/debug.log @@ -0,0 +1,7 @@ +{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399756809,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":1,"orderCartCount":1,"pendingItemsFromPatch":1,"pendingQueries":["chota"]},"timestamp":1769399763739,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"cartHelpers.js:320","message":"takeover - order before/after pending removal","data":{"pendingItemId":"pending_1769399763727_6f1m2r","pendingItemQuery":"chota","originalPendingCount":1,"newPendingCount":0,"originalPendingIds":["pending_1769399763727_6f1m2r"],"newPendingIds":[]},"timestamp":1769399769529,"sessionId":"debug-session","hypothesisId":"C"} +{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"human_takeover","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399769547,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"takeovers.handler.js:173","message":"respond - state read from DB","data":{"state":"CART","orderPendingCount":0,"orderCartCount":1,"pendingQueries":[]},"timestamp":1769399811578,"sessionId":"debug-session","hypothesisId":"B"} +{"location":"takeovers.handler.js:195","message":"respond - order being saved (with cart items)","data":{"orderPendingCount":0,"orderCartCount":2,"pendingQueries":[]},"timestamp":1769399811579,"sessionId":"debug-session","hypothesisId":"B-save"} +{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"view_cart","orderPendingCount":0,"orderCartCount":2,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399826724,"sessionId":"debug-session","hypothesisId":"A"} diff --git a/docs/env.md b/docs/env.md index 3189797..9f27df9 100644 --- a/docs/env.md +++ b/docs/env.md @@ -28,6 +28,25 @@ - **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`). - **`WOO_CONSUMER_SECRET`**: consumer secret (idem). +### Evolution API (WhatsApp) + +Variables para el envío de mensajes a WhatsApp via Evolution API. + +- **`EVOLUTION_API_URL`**: URL base de Evolution API (ej: `https://api.evolution.com`). +- **`EVOLUTION_API_KEY`**: API key para autenticación (header `apikey`). +- **`EVOLUTION_INSTANCE_NAME`**: nombre de la instancia de WhatsApp (ej: `piaf`). +- **`EVOLUTION_SEND_ENABLED`**: habilita/deshabilita el envío real a WhatsApp. + - `0` (default): Solo guarda mensajes en BD, NO envía a WhatsApp. Ideal para desarrollo/pruebas. + - `1`: Envía mensajes realmente a WhatsApp. Para producción. + +**Flujo:** +1. Bot recibe mensaje de Evolution via webhook (`/webhook/evolution`) +2. Procesa el mensaje y genera respuesta +3. Guarda respuesta en BD (`direction: "out"`) +4. Si `EVOLUTION_SEND_ENABLED=1`, envía respuesta a Evolution API → WhatsApp + +**Endpoint usado:** `POST {EVOLUTION_API_URL}/message/sendText/{instance}` + ## Debug por temas (nuevo) Todos aceptan `1/true/yes/on` para activar. diff --git a/env.example b/env.example index 07c809a..935aad4 100644 --- a/env.example +++ b/env.example @@ -32,6 +32,14 @@ WOO_BASE_URL=https://tu-tienda.com WOO_CONSUMER_KEY=ck_xxx WOO_CONSUMER_SECRET=cs_xxx +# =================== +# Evolution API - WhatsApp +# =================== +EVOLUTION_API_URL=https://api.evolution.com +EVOLUTION_API_KEY=your-api-key +EVOLUTION_INSTANCE_NAME=piaf +EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción) + # =================== # Debug Flags (1/true/yes/on para activar) # =================== diff --git a/public/components/conversation-inspector.js b/public/components/conversation-inspector.js index 3f11e33..c7b1a4d 100644 --- a/public/components/conversation-inspector.js +++ b/public/components/conversation-inspector.js @@ -29,12 +29,16 @@ class ConversationInspector extends HTMLElement { .item.in { background:#0f1520; border-color:#2a3a55; } .item.out { background:#111b2a; border-color:#2a3a55; } .item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); } - .kv { display:grid; grid-template-columns:70px 1fr; gap:6px 10px; } - .k { color:#8aa0b5; font-size:11px; letter-spacing:.4px; text-transform:uppercase; } - .v { font-size:12px; color:#e7eef7; } - .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; } - .chip { display:inline-flex; align-items:center; gap:6px; padding:2px 6px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:11px; color:#8aa0b5; } - .cart { margin-top:6px; font-size:11px; color:#c7d8ee; } + .item-row { display:flex; gap:8px; } + .item-left { flex:1; min-width:0; } + .item-right { display:flex; flex-direction:column; gap:4px; align-items:flex-end; justify-content:flex-start; min-width:60px; } + .kv { display:grid; grid-template-columns:55px 1fr; gap:4px 6px; } + .k { color:#8aa0b5; font-size:10px; letter-spacing:.3px; text-transform:uppercase; } + .v { font-size:11px; color:#e7eef7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .chips { display:flex; flex-direction:column; gap:3px; align-items:flex-end; } + .chip { display:inline-flex; align-items:center; gap:3px; padding:2px 5px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:9px; color:#8aa0b5; white-space:nowrap; } + .chip .dot { flex-shrink:0; } + .cart { margin-top:4px; font-size:10px; color:#c7d8ee; line-height:1.3; } .tool { margin-top:6px; font-size:11px; color:#8aa0b5; } .dot { width:8px; height:8px; border-radius:50%; } .ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; } @@ -216,12 +220,26 @@ class ConversationInspector extends HTMLElement { toolSummary(tools = []) { return tools.map((t) => ({ - type: t.type || t.name || "tool", + type: this.formatToolName(t.type || t.name || "tool"), ok: t.ok !== false, error: t.error || null, })); } + formatToolName(name) { + // Nombres cortos para tools comunes + const shortNames = { + "ensure_woo_customer": "woo customer", + "create_order": "create order", + "update_order": "update order", + "send_payment_link": "payment link", + "request_human_takeover": "human takeover", + "add_to_cart": "add to cart", + "human_response_sent": "human response", + }; + return shortNames[name] || name.replace(/_/g, " "); + } + render() { const metaEl = this.shadowRoot.getElementById("meta"); const countEl = this.shadowRoot.getElementById("count"); @@ -269,24 +287,30 @@ class ConversationInspector extends HTMLElement { : "NLU ok"; el.innerHTML = ` -
-
${dir === "in" ? "IN" : "OUT"}
-
${new Date(msg.ts).toLocaleString()}
-
STATE
-
${dir === "out" ? nextState : prevState}
-
INTENT
-
${dir === "out" ? intent : "—"}
-
NLU
-
${dir === "out" && llmMeta ? llmNote : "—"}
-
-
Carrito: ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}
-
- ${tools - .map( - (t) => - `${t.type}` - ) - .join("")} +
+
+
+
${dir === "in" ? "IN" : "OUT"}
+
${new Date(msg.ts).toLocaleString()}
+
STATE
+
${dir === "out" ? nextState : prevState}
+
INTENT
+
${dir === "out" ? intent : "—"}
+
NLU
+
${dir === "out" && llmMeta ? llmNote : "—"}
+
+
Carrito: ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}
+
+
+
+ ${tools + .map( + (t) => + `${t.type}` + ) + .join("")} +
+
`; @@ -397,18 +421,24 @@ class ConversationInspector extends HTMLElement { el.dataset.messageId = msg.message_id; el.innerHTML = ` -
-
IN
-
${new Date(msg.ts).toLocaleString()}
-
STATE
-
-
INTENT
-
-
NLU
-
procesando...
+
+
+
+
IN
+
${new Date(msg.ts).toLocaleString()}
+
STATE
+
+
INTENT
+
+
NLU
+
procesando...
+
+
Carrito:
+
+
+
+
-
Carrito:
-
`; list.appendChild(el); diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js index bfeeb59..c4716bd 100644 --- a/public/components/ops-shell.js +++ b/public/components/ops-shell.js @@ -183,6 +183,12 @@ class OpsShell extends HTMLElement { this.setView("takeovers", {}, { updateUrl: true }); }; + // Listen for new takeovers via SSE - update badge immediately + this._unsubTakeover = on("takeover:created", () => { + this._takeoverCount++; + this.updateTakeoverBadge(this._takeoverCount); + }); + // Start polling for takeovers this.pollTakeovers(); this._pollInterval = setInterval(() => this.pollTakeovers(), 30000); @@ -192,6 +198,7 @@ class OpsShell extends HTMLElement { this._unsub?.(); this._unsubSwitch?.(); this._unsubRouter?.(); + this._unsubTakeover?.(); if (this._pollInterval) clearInterval(this._pollInterval); } diff --git a/public/components/takeovers-crud.js b/public/components/takeovers-crud.js index ba6a02a..70ab941 100644 --- a/public/components/takeovers-crud.js +++ b/public/components/takeovers-crud.js @@ -127,7 +127,7 @@ class TakeoversCrud extends HTMLElement { this.loadProducts(); // Refresh cuando se recibe evento SSE de nuevo takeover - this._unsubSse = on("sse:takeover", () => this.load()); + this._unsubSse = on("takeover:created", () => this.load()); } disconnectedCallback() { @@ -298,11 +298,6 @@ class TakeoversCrud extends HTMLElement {
-
- - -
-

Agregar Alias (opcional)

@@ -320,6 +315,11 @@ class TakeoversCrud extends HTMLElement {
+
+ + +
+
diff --git a/public/lib/api.js b/public/lib/api.js index b50cab4..138579a 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -268,11 +268,11 @@ export const api = { return fetch(`/takeovers/${id}`).then(r => r.json()); }, - async respondTakeover(id, { response, add_alias }) { + async respondTakeover(id, { response, add_alias, cart_items }) { return fetch(`/takeovers/${id}/respond`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ response, add_alias }), + body: JSON.stringify({ response, add_alias, cart_items }), }).then(r => r.json()); }, diff --git a/public/lib/sse.js b/public/lib/sse.js index 0d50d60..bf18858 100644 --- a/public/lib/sse.js +++ b/public/lib/sse.js @@ -6,6 +6,7 @@ export function connectSSE() { es.addEventListener("hello", () => emit("sse:status", { ok: true })); es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data))); es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data))); + es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data))); es.onerror = () => emit("sse:status", { ok: false }); diff --git a/src/modules/0-ui/handlers/takeovers.js b/src/modules/0-ui/handlers/takeovers.js index 19439c6..03ee7b0 100644 --- a/src/modules/0-ui/handlers/takeovers.js +++ b/src/modules/0-ui/handlers/takeovers.js @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { createTakeover, listPendingTakeovers, @@ -10,7 +11,9 @@ import { getTakeoverStats, } from "../db/takeoverRepo.js"; import { insertAlias, upsertAliasMapping } from "../db/repo.js"; -import { getRecentMessagesForLLM, getConversationState, upsertConversationState } from "../../2-identity/db/repo.js"; +import { getRecentMessagesForLLM, getConversationState, upsertConversationState, insertMessage } from "../../2-identity/db/repo.js"; +import { sseSend } from "../../shared/sse.js"; +import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js"; /** * Lista takeovers pendientes de respuesta @@ -107,12 +110,69 @@ export async function handleRespondToTakeover({ id, humanResponse: response, respondedBy, - cartItems, // Pasar los items para que se guarden + cartItems, }); if (!result) { throw new Error("Takeover not found or already responded"); } + + // INSERTAR EL MENSAJE DE RESPUESTA EN LA BD + if (result.chat_id && response) { + try { + const messageId = `takeover_${crypto.randomUUID()}`; + await insertMessage({ + tenant_id: tenantId, + wa_chat_id: result.chat_id, + provider: "takeover", + message_id: messageId, + direction: "out", + text: response, + payload: { + reply: response, + takeover_id: id, + railguard: { simulated: false, source: "human_takeover" } + }, + run_id: null, + }); + + // Emitir SSE para que la UI se actualice + sseSend("run.created", { + run_id: null, + ts: new Date().toISOString(), + chat_id: result.chat_id, + from: respondedBy || "admin", + status: "ok", + prev_state: "AWAITING_HUMAN", + input: { text: "[human takeover response]" }, + llm_output: { + reply: response, + intent: "human_response", + next_state: "CART", + }, + tools: [], + invariants: { ok: true, checks: [] }, + final_reply: response, + order_id: null, + payment_link: null, + latency_ms: 0, + }); + + // Enviar a WhatsApp via Evolution API (si está habilitado) + if (isEvolutionEnabled()) { + try { + await sendTextMessage({ chatId: result.chat_id, text: response }); + console.log(`[takeovers] Message sent to Evolution for ${result.chat_id}`); + } catch (e) { + console.error("[takeovers] Evolution send error:", e.message); + } + } + + console.log(`[takeovers] Message inserted for ${result.chat_id}`); + } catch (e) { + console.error("[takeovers] Error inserting message:", e); + } + } // Si hay items para agregar al carrito, actualizar el estado de la conversación if (cartItems && cartItems.length > 0 && result.chat_id) { @@ -132,7 +192,7 @@ export async function handleRespondToTakeover({ })); order.cart = [...(order.cart || []), ...newCartItems]; - + // Actualizar estado: cambiar a CART para que el bot retome await upsertConversationState({ tenant_id: tenantId, diff --git a/src/modules/1-intake/services/evolutionSender.js b/src/modules/1-intake/services/evolutionSender.js new file mode 100644 index 0000000..0163103 --- /dev/null +++ b/src/modules/1-intake/services/evolutionSender.js @@ -0,0 +1,138 @@ +/** + * Evolution API Sender - Envía mensajes a WhatsApp via Evolution API + * + * Controlado por EVOLUTION_SEND_ENABLED: + * - 0 (default): Solo guarda en BD, no envía a WhatsApp (modo prueba) + * - 1: Envía realmente a WhatsApp (producción) + */ + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY; +const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME; + +/** + * Verifica si el envío a Evolution API está habilitado + */ +export function isEvolutionEnabled() { + const enabled = process.env.EVOLUTION_SEND_ENABLED; + return enabled === "1" || enabled === "true" || enabled === "yes" || enabled === "on"; +} + +/** + * Extrae el número de teléfono del chat_id + * @param {string} chatId - Formato: "5491133230322@s.whatsapp.net" + * @returns {string} - Solo el número: "5491133230322" + */ +function extractPhoneNumber(chatId) { + if (!chatId) return null; + return chatId.replace("@s.whatsapp.net", "").replace("@c.us", ""); +} + +/** + * Envía un mensaje de texto a través de Evolution API + * + * @param {Object} params + * @param {string} params.chatId - ID del chat (ej: "5491133230322@s.whatsapp.net") + * @param {string} params.text - Texto del mensaje a enviar + * @param {string} [params.instanceName] - Nombre de la instancia (opcional, usa env por defecto) + * @returns {Promise<{ok: boolean, messageId?: string, error?: string}>} + */ +export async function sendTextMessage({ chatId, text, instanceName = null }) { + // Verificar si está habilitado + if (!isEvolutionEnabled()) { + console.log(`[evolution] Send disabled, message NOT sent to ${chatId}`); + return { ok: true, skipped: true, reason: "EVOLUTION_SEND_ENABLED=0" }; + } + + // Validar configuración + if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY) { + console.error("[evolution] Missing EVOLUTION_API_URL or EVOLUTION_API_KEY"); + return { ok: false, error: "missing_config" }; + } + + const instance = instanceName || EVOLUTION_INSTANCE_NAME; + if (!instance) { + console.error("[evolution] Missing EVOLUTION_INSTANCE_NAME"); + return { ok: false, error: "missing_instance" }; + } + + const phoneNumber = extractPhoneNumber(chatId); + if (!phoneNumber) { + console.error("[evolution] Invalid chatId:", chatId); + return { ok: false, error: "invalid_chat_id" }; + } + + // Construir URL y payload + const url = `${EVOLUTION_API_URL}/message/sendText/${instance}`; + const payload = { + number: phoneNumber, + text: text, + }; + + try { + console.log(`[evolution] Sending to ${phoneNumber}...`); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "apikey": EVOLUTION_API_KEY, + }, + body: JSON.stringify(payload), + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + console.error(`[evolution] Error ${response.status}:`, data); + return { ok: false, error: `http_${response.status}`, details: data }; + } + + console.log(`[evolution] Sent to ${phoneNumber}, messageId: ${data?.key?.id || "unknown"}`); + return { + ok: true, + messageId: data?.key?.id || null, + response: data, + }; + + } catch (err) { + console.error("[evolution] Network error:", err.message); + return { ok: false, error: "network_error", message: err.message }; + } +} + +/** + * Verifica el estado de la instancia de Evolution + * @returns {Promise<{ok: boolean, connected?: boolean, error?: string}>} + */ +export async function checkInstanceStatus() { + if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE_NAME) { + return { ok: false, error: "missing_config" }; + } + + const url = `${EVOLUTION_API_URL}/instance/connectionState/${EVOLUTION_INSTANCE_NAME}`; + + try { + const response = await fetch(url, { + method: "GET", + headers: { + "apikey": EVOLUTION_API_KEY, + }, + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + return { ok: false, error: `http_${response.status}` }; + } + + return { + ok: true, + connected: data?.state === "open" || data?.instance?.state === "open", + state: data?.state || data?.instance?.state, + }; + + } catch (err) { + return { ok: false, error: "network_error", message: err.message }; + } +} diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index ef002c4..f95f5d4 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -18,6 +18,8 @@ import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js"; import { safeNextState } from "../../3-turn-engine/fsm.js"; import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js"; import { createPreference } from "../../6-mercadopago/mercadoPago.js"; +import { handleCreateTakeover } from "../../0-ui/handlers/takeovers.js"; +import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js"; function nowIso() { return new Date().toISOString(); @@ -283,6 +285,23 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; actionPatch.woo_order_id = order?.id || null; actionPatch.order_total = calcOrderTotal(order); newTools.push({ type: "update_order", ok: true, order_id: order?.id || null }); + } else if (act.type === "request_human_takeover") { + const takeoverResult = await handleCreateTakeover({ + tenantId, + chatId: chat_id, + pendingQuery: act.payload?.pending_query || "consulta pendiente", + reason: act.payload?.reason || "product_not_found", + contextSnapshot: act.payload?.context_snapshot || null, + }); + newTools.push({ type: "request_human_takeover", ok: takeoverResult?.ok || false, takeover_id: takeoverResult?.takeover?.id || null }); + // Notificar via SSE para actualizar la campanita inmediatamente + if (takeoverResult?.ok) { + sseSend("takeover.created", { + takeover_id: takeoverResult.takeover?.id, + chat_id, + pending_query: act.payload?.pending_query, + }); + } } else if (act.type === "send_payment_link") { const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null; if (!total || total <= 0) { @@ -339,6 +358,15 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; }); mark("after_insertMessage_out"); + // Enviar a WhatsApp via Evolution API (si está habilitado y el mensaje vino de evolution) + if (provider === "evolution" && !isSimulated && isEvolutionEnabled()) { + try { + await sendTextMessage({ chatId: chat_id, text: plan.reply }); + } catch (e) { + console.error("[pipeline] Evolution send error:", e.message); + } + } + if (llmMeta?.error) { const errMsgId = newId("err"); await insertMessage({ diff --git a/src/modules/3-turn-engine/stateHandlers/waiting.js b/src/modules/3-turn-engine/stateHandlers/waiting.js index 8e23c02..5768772 100644 --- a/src/modules/3-turn-engine/stateHandlers/waiting.js +++ b/src/modules/3-turn-engine/stateHandlers/waiting.js @@ -5,10 +5,51 @@ import { ConversationState } from "../fsm.js"; import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; +/** + * Detecta si el usuario pregunta por horarios/días de entrega + */ +function isDeliveryInfoQuestion(text) { + const t = String(text || "").toLowerCase(); + const patterns = [ + /cu[aá]ndo\s+(entrega|llega|env[ií])/i, + /qu[eé]\s+d[ií]as?\s+(entrega|env[ií]|repart)/i, + /d[ií]as?\s+de\s+(entrega|env[ií]|reparto)/i, + /horario\s+de\s+(entrega|env[ií]|reparto)/i, + /qu[eé]\s+horarios?/i, + /a\s+qu[eé]\s+hora/i, + /en\s+qu[eé]\s+horario/i, + /cuando\s+(me\s+)?lo\s+traen/i, + /d[ií]a\s+y\s+hora/i, + ]; + return patterns.some(p => p.test(t)); +} + +/** + * Formatea los días de entrega para mostrar + */ +function formatDeliveryDays(daysStr) { + if (!daysStr) return null; + + const dayMap = { + "lun": "Lunes", "mar": "Martes", "mie": "Miércoles", "mié": "Miércoles", + "jue": "Jueves", "vie": "Viernes", "sab": "Sábado", "sáb": "Sábado", "dom": "Domingo", + }; + + const days = daysStr.split(",").map(d => d.trim().toLowerCase()); + const formatted = days.map(d => dayMap[d] || d).filter(Boolean); + + if (formatted.length === 0) return null; + if (formatted.length === 1) return formatted[0]; + + // "Lunes, Martes, Miércoles y Jueves" + const last = formatted.pop(); + return `${formatted.join(", ")} y ${last}`; +} + /** * Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago) */ -export async function handleWaitingState({ tenantId, text, nlu, order, audit }) { +export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {} }) { const intent = nlu?.intent || "other"; const currentOrder = order || createEmptyOrder(); @@ -28,6 +69,37 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit }) }; } + // Preguntas sobre horarios/días de entrega + if (isDeliveryInfoQuestion(text)) { + const deliveryDays = formatDeliveryDays(storeConfig.delivery_days); + const startHour = storeConfig.delivery_hours_start?.slice(0, 5); + const endHour = storeConfig.delivery_hours_end?.slice(0, 5); + + let reply = ""; + if (deliveryDays) { + reply = `Hacemos entregas los días ${deliveryDays}`; + if (startHour && endHour) { + reply += ` de ${startHour} a ${endHour}`; + } + reply += ". "; + } else { + reply = "Todavía no tengo configurado los días de entrega. "; + } + + reply += "Tu pedido ya está en proceso, avisame cualquier cosa."; + + return { + plan: { + reply, + next_state: ConversationState.WAITING_WEBHOOKS, + intent: "delivery_info", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + // Default const reply = currentOrder.payment_type === "link" ? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago." diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index 07d18a3..4f24ec2 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -80,13 +80,13 @@ export async function runTurnV3({ 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 - // Cargar configuración del tenant desde la DB - const storeConfig = await getStoreConfig({ tenantId }); - nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig }); audit.nlu = { raw_text: nluResult.raw_text, @@ -123,6 +123,7 @@ export async function runTurnV3({ nlu, order, audit, + storeConfig, }; // Regla universal: si quiere agregar productos, volver a CART