/** * 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", })), 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, 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, }, }; }