/** * Turn Engine V3 - Dispatcher basado en estados * * Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS * 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. */ 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, handlePaymentState, handleWaitingState, } from "./stateHandlers.js"; import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; import { pushRecent } from "./replyTemplates.js"; // Feature flag para NLU modular const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; /** * 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, }) { 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; case ConversationState.PAYMENT: result = await handlePaymentState(handlerParams); break; case ConversationState.WAITING_WEBHOOKS: result = await handleWaitingState(handlerParams); break; default: // Estado desconocido, tratar como IDLE result = await handleIdleState(handlerParams); } return formatResult(result, prev_context, recentReplies, failedSearches); } /** * 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; if (s === "PAYMENT") return ConversationState.PAYMENT; if (s === "WAITING_WEBHOOKS") return ConversationState.WAITING_WEBHOOKS; // Estados viejos → CART if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) { return ConversationState.CART; } // Estados de checkout viejos if (s === "CLARIFYING_PAYMENT") return ConversationState.PAYMENT; if (s === "CLARIFYING_SHIPPING") return ConversationState.SHIPPING; if (s === "AWAITING_ADDRESS") return ConversationState.SHIPPING; if (s === "AWAITING_PAYMENT") return ConversationState.WAITING_WEBHOOKS; if (s === "COMPLETED") return ConversationState.IDLE; // Nuevo ciclo 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", })), payment_method: order.payment_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";