From 04ac33430f630b5aa76ca2806f5d89a65c3f112a Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Fri, 1 May 2026 20:38:26 -0300 Subject: [PATCH] Tier 2: XState statechart como motor de turno (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza el dispatcher en turnEngineV3.js por un statechart formal en XState v5. La machine es pura: produce un effect log (pending_actions) + un descriptor de reply (pending_reply) que el runner traduce afuera. API externa intacta: runTurnV3 sigue retornando { plan, decision } con shape compatible con pipeline.js. Snapshot persiste en context.xstate_snapshot dentro del JSONB existente. - machine/index.js: statechart top-level (idle/cart/shipping/payment/ waiting/awaiting_human) + cart sub-statechart con todo el flujo multi-turno (searching/resolving/askingClarification/askingQuantity/ computingFromPersonas/added/showing/pricing/researching). - guards.js: portados de fsm.js (hasCart, wantsToAddProduct, etc). - actions.js: assigns para mutations + reply descriptors (pending_reply con templateKey/vars/rawText). Las async no entran en la machine. - actors.js: fromPromise wrappers de retrieveCandidates y getProductQtyRules. - runner.js: boot con prev_context.xstate_snapshot o migrateOldContext. NLU → nluToEvent → send → settle (espera invokes) → realizeReply (renderReply real con rewriter) → getPersistedSnapshot → format. - nluToEvent.js: adapter NLU intent → evento XState (1:1). Feature flags: USE_XSTATE=1 reemplaza el path; XSTATE_SHADOW=1 corre ambos en paralelo, devuelve legacy y loguea diffs estructurales para validar antes de flippar prod. 16 unit tests para la machine cubren: arranque, regla universal cart-on-add, flow de cart con strong/multi match, checkout completo (shipping/pickup/ payment/cash) y rehidratación de snapshot. 224 tests totales pasando. Co-Authored-By: Claude Sonnet 4.6 --- env.example | 9 + package-lock.json | 11 + package.json | 1 + src/modules/3-turn-engine/machine/actions.js | 400 ++++++++++++++++++ src/modules/3-turn-engine/machine/actors.js | 45 ++ src/modules/3-turn-engine/machine/guards.js | 92 ++++ src/modules/3-turn-engine/machine/index.js | 367 ++++++++++++++++ .../3-turn-engine/machine/index.test.js | 242 +++++++++++ .../3-turn-engine/machine/nluToEvent.js | 49 +++ src/modules/3-turn-engine/machine/runner.js | 245 +++++++++++ src/modules/3-turn-engine/turnEngineV3.js | 53 ++- 11 files changed, 1513 insertions(+), 1 deletion(-) create mode 100644 src/modules/3-turn-engine/machine/actions.js create mode 100644 src/modules/3-turn-engine/machine/actors.js create mode 100644 src/modules/3-turn-engine/machine/guards.js create mode 100644 src/modules/3-turn-engine/machine/index.js create mode 100644 src/modules/3-turn-engine/machine/index.test.js create mode 100644 src/modules/3-turn-engine/machine/nluToEvent.js create mode 100644 src/modules/3-turn-engine/machine/runner.js diff --git a/env.example b/env.example index f6124fa..bc48d82 100644 --- a/env.example +++ b/env.example @@ -54,6 +54,15 @@ MAX_CHARS_PER_MESSAGE=4000 REPLY_REWRITER=0 REPLY_REWRITER_TIMEOUT_MS=1500 +# =================== +# XState (Turn engine v2) +# =================== +# USE_XSTATE=1 → reemplaza dispatcher legacy con statechart formal +# XSTATE_SHADOW=1 → corre ambos paths, devuelve legacy, loguea diffs en logs +USE_XSTATE=0 +XSTATE_SHADOW=0 +XSTATE_SETTLE_MS=10000 + # =================== # Debug Flags (1/true/yes/on para activar) # =================== diff --git a/package-lock.json b/package-lock.json index e9ffbc7..d021cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "openai": "^6.15.0", "pg": "^8.16.3", "undici": "^7.16.0", + "xstate": "^5.31.0", "zod": "^4.3.4" }, "devDependencies": { @@ -3404,6 +3405,16 @@ "node": ">=8" } }, + "node_modules/xstate": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.31.0.tgz", + "integrity": "sha512-5B+0DqC0uNUrcLUEY3pn3iNy+swvK2E0ZpYp5gnV3oxMX5y87vzXkU5YXv9CAtyG5c5FOJ1SzvTWHrwE8fMZNQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 14c85c5..1daaf74 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "openai": "^6.15.0", "pg": "^8.16.3", "undici": "^7.16.0", + "xstate": "^5.31.0", "zod": "^4.3.4" }, "devDependencies": { diff --git a/src/modules/3-turn-engine/machine/actions.js b/src/modules/3-turn-engine/machine/actions.js new file mode 100644 index 0000000..e5686d2 --- /dev/null +++ b/src/modules/3-turn-engine/machine/actions.js @@ -0,0 +1,400 @@ +/** + * Actions XState — mutaciones de context (assign builders) y emisores de + * efectos (que se vuelcan al "effect log" en context para que el runner + * los drene fuera de la machine). + * + * Reusa orderModel.js para todas las mutaciones del carrito — no duplica + * lógica. + */ + +import { assign } from "xstate"; +import { + PendingStatus, + moveReadyToCart, + getNextPendingItem, + updatePendingItem, + addPendingItem, + removeCartItem, + formatCartForDisplay, + formatOptionsForDisplay, + createPendingItem, +} from "../orderModel.js"; +import { + inferDefaultUnit, + parseIndexSelection, + findMatchingCandidate, + normalizeUnit, + unitAskFor, +} from "../stateHandlers/utils.js"; +import { renderReply, pushRecent } from "../replyTemplates.js"; +import { buildStoreContextVars } from "../storeContext.js"; +import { createPendingItemFromSearch } from "../stateHandlers/cartHelpers.js"; + +// ───────────────────────────────────────────────────────────── +// Helpers internos +// ───────────────────────────────────────────────────────────── + +function rewriteCtx(context) { + return { + conversation_history: context.conversation_history || [], + state: context.fsmState || null, + userText: context.userText || "", + }; +} + +function storeVars(context) { + return buildStoreContextVars(context.storeConfig || {}); +} + +// ───────────────────────────────────────────────────────────── +// Actions sincrónicas (assign) +// ───────────────────────────────────────────────────────────── + +export const setUserText = assign({ + userText: ({ event }) => event.text || event.userText || "", +}); + +export const recordReply = assign({ + last_reply: ({ event }) => event.output || null, + recent_replies: ({ context, event }) => { + const tid = event.output?.template_id; + return tid ? pushRecent(context.recent_replies || [], tid) : context.recent_replies || []; + }, +}); + +export const bumpFailedSearch = assign({ + failed_searches: ({ context, event }) => { + const cur = context.failed_searches || { count: 0 }; + return { + count: (cur.count || 0) + 1, + last_query: event.query || cur.last_query || null, + last_at: new Date().toISOString(), + }; + }, +}); + +export const resetFailedSearch = assign({ + failed_searches: () => ({ count: 0, last_query: null, last_at: null }), +}); + +export const addPendingFromCandidates = assign({ + order: ({ context, event }) => { + const results = event.output || []; + let order = context.order; + for (const r of results) { + const pending = createPendingItemFromSearch({ + query: r.query, + quantity: r.quantity, + unit: r.unit, + candidates: r.candidates, + }); + order = addPendingItem(order, pending); + } + return moveReadyToCart(order); + }, +}); + +export const moveReady = assign({ + order: ({ context }) => moveReadyToCart(context.order), +}); + +export const removeFromCart = assign({ + order: ({ context, event }) => { + const items = event.items || []; + let order = context.order; + for (const item of items) { + if (!item.product_query) continue; + const { order: next } = removeCartItem(order, item.product_query); + order = next; + } + return order; + }, +}); + +export const skipFirstPending = assign({ + order: ({ context }) => { + const next = getNextPendingItem(context.order); + if (!next) return context.order; + return { + ...context.order, + pending: (context.order.pending || []).filter((p) => p.id !== next.id), + }; + }, +}); + +export const selectByIndex = assign({ + order: ({ context, event }) => { + const text = String(event.text || ""); + const next = getNextPendingItem(context.order); + if (!next || next.status !== "NEEDS_TYPE") return context.order; + + const idx = parseIndexSelection(text); + const textMatch = !idx && next.candidates?.length > 0 + ? findMatchingCandidate(next.candidates, text) + : null; + const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null); + if (!effectiveIdx || effectiveIdx > (next.candidates?.length || 0)) return context.order; + + const selected = next.candidates[effectiveIdx - 1]; + const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); + const requestedQty = next.requested_qty; + const requestedUnit = next.requested_unit || displayUnit; + const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0; + const sellsByWeight = displayUnit !== "unit"; + const needsQuantity = sellsByWeight && !hasRequestedQty; + const finalQty = hasRequestedQty ? requestedQty : 1; + const finalUnit = requestedUnit || displayUnit; + + return updatePendingItem(context.order, next.id, { + selected_woo_id: selected.woo_id, + selected_name: selected.name, + selected_price: selected.price, + selected_unit: displayUnit, + candidates: [], + status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, + qty: needsQuantity ? null : finalQty, + unit: finalUnit, + }); + }, +}); + +export const setPendingQuantity = assign({ + order: ({ context, event }) => { + const next = getNextPendingItem(context.order); + if (!next || next.status !== "NEEDS_QUANTITY") return context.order; + const text = String(event.text || ""); + const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text); + if (!m) return context.order; + const qty = parseFloat(m[1].replace(",", ".")); + if (!Number.isFinite(qty) || qty <= 0) return context.order; + const unitFromText = m[2] ? normalizeUnit(m[2]) : null; + const finalUnit = unitFromText || next.selected_unit || "kg"; + return updatePendingItem(context.order, next.id, { + qty, + unit: finalUnit, + status: PendingStatus.READY, + }); + }, +}); + +export const setQuantityFromRule = assign({ + order: ({ context, event }) => { + // event.output: array de rules. event.params: { peopleCount } + const next = getNextPendingItem(context.order); + if (!next) return context.order; + const rules = event.output || []; + const peopleCount = context._peopleCount || 1; + const rule = rules.find((r) => r.event_type === "asado" && r.person_type === "adult") + || rules.find((r) => r.event_type === null && r.person_type === "adult") + || rules.find((r) => r.person_type === "adult") + || rules[0]; + + let calculatedQty; + let calculatedUnit = next.selected_unit || "kg"; + if (rule && rule.qty_per_person > 0) { + calculatedQty = rule.qty_per_person * peopleCount; + calculatedUnit = rule.unit || calculatedUnit; + } else { + const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; + calculatedQty = fallbackPerPerson * peopleCount; + } + if (calculatedUnit === "unit") calculatedQty = Math.ceil(calculatedQty); + else calculatedQty = Math.round(calculatedQty * 10) / 10; + + return updatePendingItem(context.order, next.id, { + qty: calculatedQty, + unit: calculatedUnit, + status: PendingStatus.READY, + }); + }, +}); + +export const capturePeopleCount = assign({ + _peopleCount: ({ event }) => { + const text = String(event.text || ""); + const m = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text) + || /\bpara\s+(\d+)\b/i.exec(text) + || /\bcomo\s+para\s+(\d+)\b/i.exec(text); + return m ? parseInt(m[1], 10) : 1; + }, +}); + +export const setShipping = assign({ + order: ({ context, event }) => { + const method = event.method; + if (method !== "delivery" && method !== "pickup") return context.order; + return { ...context.order, is_delivery: method === "delivery" }; + }, +}); + +export const setAddress = assign({ + order: ({ context, event }) => { + const addr = event.address || event.text || ""; + if (!addr || addr.length < 5) return context.order; + return { ...context.order, shipping_address: String(addr).trim() }; + }, +}); + +export const setPayment = assign({ + order: ({ context, event }) => { + const method = event.method; + if (method !== "cash" && method !== "link") return context.order; + return { ...context.order, payment_type: method }; + }, +}); + +export const enqueueWooCreateOrder = assign({ + pending_actions: ({ context }) => [ + ...(context.pending_actions || []), + { type: "create_order", payload: { payment: context.order?.payment_type } }, + ], +}); + +export const enqueueAddToCart = assign({ + pending_actions: ({ context }) => { + const last = (context.order?.cart || []).slice(-1)[0]; + return [...(context.pending_actions || []), { type: "add_to_cart", payload: last || {} }]; + }, +}); + +export const enqueueRemoveFromCart = assign({ + pending_actions: ({ context, event }) => [ + ...(context.pending_actions || []), + { type: "remove_from_cart", payload: { items: event.items || [] } }, + ], +}); + +// ───────────────────────────────────────────────────────────── +// Async reply renderers (entry actions que producen reply async) +// ───────────────────────────────────────────────────────────── + +/** + * Helper que renderiza un reply y devuelve { reply, template_id } — + * el caller debe asignar a context.last_reply. + */ +async function _render(context, templateKey, vars = {}) { + const merged = { ...storeVars(context), ...vars }; + return await renderReply({ + tenantId: context.tenantId, + templateKey, + vars: merged, + recentReplies: context.recent_replies || [], + ...rewriteCtx(context), + }); +} + +/** + * Las "reply actions" no pueden ser async dentro de XState v5 directamente, + * así que las modelamos como side-effects que el runner ejecuta DESPUÉS + * de que la máquina settle, leyendo context.pending_reply (un descriptor). + * + * Cada estado que emite respuesta hace `assign({ pending_reply: { templateKey, vars } })` + * en su entry. El runner traduce eso a renderReply real. + */ + +function makeReplyAction(templateKey, varsBuilder = null) { + return assign({ + pending_reply: ({ context, event }) => ({ + templateKey, + vars: varsBuilder ? varsBuilder({ context, event }) : {}, + }), + }); +} + +export const replyIdleGreeting = makeReplyAction("idle.greeting"); +export const replyIdleHelp = makeReplyAction("idle.help_prompt"); +export const replyAskMore = makeReplyAction("cart.ask_more"); +export const replyEmptyCart = makeReplyAction("cart.empty_prompt"); +export const replyNotFound = makeReplyAction("cart.not_found", ({ event, context }) => ({ + query: event.query || context.failed_searches?.last_query || "", +})); +export const replyDidntUnderstand = makeReplyAction("cart.didnt_understand"); +export const replySkipAck = makeReplyAction("cart.skip_acknowledged"); +export const replyConfirmToShipping = makeReplyAction("cart.confirm_to_shipping"); +export const replyPendingBeforeClose = makeReplyAction("cart.pending_before_close"); +export const replyAskWhatProduct = makeReplyAction("cart.ask_what_product"); +export const replyAddedConfirm = makeReplyAction("cart.added_confirm", ({ context }) => { + const last = (context.order?.cart || []).slice(-1)[0]; + if (!last) return { summary: "" }; + const qtyStr = last.unit === "unit" ? last.qty : `${last.qty}${last.unit}`; + return { summary: `${qtyStr} de ${last.name}` }; +}); +export const replyShippingAskMethod = makeReplyAction("shipping.ask_method"); +export const replyShippingAskAddress = makeReplyAction("shipping.ask_address"); +export const replyShippingAddressRecorded = makeReplyAction("shipping.address_recorded", ({ context }) => ({ + address: context.order?.shipping_address || "", +})); +export const replyShippingPickupToPayment = makeReplyAction("shipping.pickup_to_payment"); +export const replyPaymentAskMethod = makeReplyAction("payment.ask_method"); +export const replyPaymentConfirmed = makeReplyAction("payment.confirmed"); +export const replyWaitingInProgress = makeReplyAction("waiting.in_progress"); + +// View cart: necesita armar reply con cartDisplay + ask_more +export const replyViewCart = assign({ + pending_reply: ({ context }) => ({ + templateKey: "cart.ask_more", + prefix: formatCartForDisplay(context.order), + }), +}); + +// Show options del primer pending +export const replyOptions = assign({ + pending_reply: ({ context }) => { + const next = getNextPendingItem(context.order); + if (!next) return null; + const { question } = formatOptionsForDisplay(next); + return { rawText: question }; + }, +}); + +// Ask quantity (data-driven, no template) +export const replyAskQuantity = assign({ + pending_reply: ({ context }) => { + const next = getNextPendingItem(context.order); + if (!next) return null; + const unitQuestion = unitAskFor(next.selected_unit || "kg"); + return { rawText: `Para ${next.selected_name || next.query}, ${unitQuestion}` }; + }, +}); + +export const actions = { + setUserText, + recordReply, + bumpFailedSearch, + resetFailedSearch, + addPendingFromCandidates, + moveReady, + removeFromCart, + skipFirstPending, + selectByIndex, + setPendingQuantity, + setQuantityFromRule, + capturePeopleCount, + setShipping, + setAddress, + setPayment, + enqueueWooCreateOrder, + enqueueAddToCart, + enqueueRemoveFromCart, + replyIdleGreeting, + replyIdleHelp, + replyAskMore, + replyEmptyCart, + replyNotFound, + replyDidntUnderstand, + replySkipAck, + replyConfirmToShipping, + replyPendingBeforeClose, + replyAskWhatProduct, + replyAddedConfirm, + replyShippingAskMethod, + replyShippingAskAddress, + replyShippingAddressRecorded, + replyShippingPickupToPayment, + replyPaymentAskMethod, + replyPaymentConfirmed, + replyWaitingInProgress, + replyViewCart, + replyOptions, + replyAskQuantity, +}; diff --git a/src/modules/3-turn-engine/machine/actors.js b/src/modules/3-turn-engine/machine/actors.js new file mode 100644 index 0000000..4dd8748 --- /dev/null +++ b/src/modules/3-turn-engine/machine/actors.js @@ -0,0 +1,45 @@ +/** + * Actores XState (fromPromise) — wrappers de side effects async. + * La machine es pura; estos actores aíslan llamadas a DB / WooCommerce / LLM. + */ + +import { fromPromise } from "xstate"; +import { retrieveCandidates } from "../catalogRetrieval.js"; +import { getProductQtyRules } from "../../0-ui/db/repo.js"; + +/** + * Busca candidatos para una lista de queries de producto. + * Input: { tenantId, items: [{product_query, quantity, unit}, ...] } + * Output: array paralelo a items con { query, quantity, unit, candidates } + */ +export const searchCatalogActor = fromPromise(async ({ input }) => { + const { tenantId, items = [] } = input || {}; + const results = []; + for (const it of items) { + if (!it?.product_query) continue; + const r = await retrieveCandidates({ tenantId, query: it.product_query, limit: 20 }); + results.push({ + query: it.product_query, + quantity: it.quantity ?? null, + unit: it.unit ?? null, + candidates: r?.candidates || [], + }); + } + return results; +}); + +/** + * Lookup de qty rules para un producto. + * Input: { tenantId, wooProductId } + * Output: array de rules + */ +export const getQtyRulesActor = fromPromise(async ({ input }) => { + const { tenantId, wooProductId } = input || {}; + if (!tenantId || !wooProductId) return []; + return await getProductQtyRules({ tenantId, wooProductId }); +}); + +export const actors = { + searchCatalogActor, + getQtyRulesActor, +}; diff --git a/src/modules/3-turn-engine/machine/guards.js b/src/modules/3-turn-engine/machine/guards.js new file mode 100644 index 0000000..f87606a --- /dev/null +++ b/src/modules/3-turn-engine/machine/guards.js @@ -0,0 +1,92 @@ +/** + * Guards XState — predicados puros sobre context+event. + * Portados desde fsm.js manteniendo semántica idéntica. + */ + +import { + hasCartItems as hasCart, + hasPendingItems as hasPending, + hasReadyPendingItems as hasReadyPending, + hasShippingInfo as hasShipping, + hasPaymentInfo as hasPayment, + isPaid, +} from "../fsm.js"; +import { + parseIndexSelection, + isShowMoreRequest, + isShowOptionsRequest, +} from "../stateHandlers/utils.js"; + +const ESCAPE_CANCEL_RE = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i; + +export const guards = { + hasCart: ({ context }) => hasCart(context.order), + hasPending: ({ context }) => hasPending(context.order), + hasReadyPending: ({ context }) => hasReadyPending(context.order), + hasShipping: ({ context }) => hasShipping(context.order), + hasPayment: ({ context }) => hasPayment(context.order), + isPaid: ({ context }) => isPaid(context.order), + + noCart: ({ context }) => !hasCart(context.order), + noShipping: ({ context }) => !hasShipping(context.order), + + // Universal "return to cart": el usuario quiere agregar productos desde un estado != IDLE/CART. + // Replica shouldReturnToCart de fsm.js. + wantsToAddProduct: ({ context, event }) => { + if (event.type !== "ADD_TO_CART" && event.type !== "BROWSE" && event.type !== "PRICE_QUERY") return false; + // Verificar que tiene un item real + const items = event.items || []; + return items.some((i) => String(i?.product_query || "").trim().length > 2); + }, + + // En checkout, "2" es selección de opción, no producto. + isCheckoutNumberOnly: ({ event }) => { + const text = String(event.text || event.userText || "").trim(); + return /^\s*\d+([.,]\d+)?\s*$/.test(text); + }, + + hasItems: ({ event }) => Array.isArray(event.items) && event.items.length > 0, + + isCancelText: ({ event }) => ESCAPE_CANCEL_RE.test(String(event.text || "")), + + isIndexSelection: ({ event }) => parseIndexSelection(String(event.text || "")) !== null, + + isShowMore: ({ event }) => { + const t = String(event.text || ""); + return isShowMoreRequest(t) || isShowOptionsRequest(t); + }, + + isTextRefinement: ({ event, context }) => { + const t = String(event.text || "").trim(); + if (parseIndexSelection(t) !== null) return false; + if (isShowMoreRequest(t) || isShowOptionsRequest(t)) return false; + return t.length > 2; + }, + + // Pending item inspections + pendingNeedsType: ({ context }) => { + const next = (context.order?.pending || []).find( + (p) => p.status === "NEEDS_TYPE" || p.status === "NEEDS_QUANTITY" + ); + return next?.status === "NEEDS_TYPE"; + }, + + pendingNeedsQuantity: ({ context }) => { + const next = (context.order?.pending || []).find( + (p) => p.status === "NEEDS_TYPE" || p.status === "NEEDS_QUANTITY" + ); + return next?.status === "NEEDS_QUANTITY"; + }, + + isPersonasInput: ({ event }) => { + const t = String(event.text || ""); + return /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.test(t) + || /\bpara\s+(\d+)\b/i.test(t) + || /\bcomo\s+para\s+(\d+)\b/i.test(t); + }, + + isQuantityInput: ({ event, context }) => { + const t = String(event.text || ""); + return /\d+(?:[.,]\d+)?\s*(?:kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.test(t); + }, +}; diff --git a/src/modules/3-turn-engine/machine/index.js b/src/modules/3-turn-engine/machine/index.js new file mode 100644 index 0000000..0df7fb2 --- /dev/null +++ b/src/modules/3-turn-engine/machine/index.js @@ -0,0 +1,367 @@ +/** + * Botino conversation machine (XState v5). + * + * Reemplaza el dispatcher en turnEngineV3.js + stateHandlers/* con un + * statechart formal. La API externa queda igual: el runner consume el + * snapshot tras settle y emite { plan, decision } compatible con pipeline.js. + * + * Top-level: idle → cart → shipping → payment → waiting → idle. + * `cart` es un sub-statechart que maneja el flujo multi-turno de pending items + * (NEEDS_TYPE → NEEDS_QUANTITY → READY). + * + * Replies se modelan como entry actions que escriben a `context.pending_reply` + * (descriptor). El runner las traduce a texto via renderReply *después* del + * settle — esto evita awaits dentro de la machine. + */ + +import { setup } from "xstate"; +import { guards } from "./guards.js"; +import { actions } from "./actions.js"; +import { actors } from "./actors.js"; +import { createEmptyOrder } from "../orderModel.js"; + +export const ConversationStates = Object.freeze({ + IDLE: "idle", + CART: "cart", + SHIPPING: "shipping", + PAYMENT: "payment", + WAITING: "waiting", + AWAITING_HUMAN: "awaiting_human", +}); + +export const machine = setup({ + types: { + context: {}, + events: {}, + }, + guards, + actions, + actors, +}).createMachine({ + id: "botino", + initial: ConversationStates.IDLE, + context: ({ input }) => ({ + tenantId: input?.tenantId || null, + chat_id: input?.chat_id || null, + storeConfig: input?.storeConfig || {}, + order: input?.initialOrder || createEmptyOrder(), + recent_replies: input?.recentReplies || [], + failed_searches: input?.failedSearches || { count: 0, last_query: null, last_at: null }, + conversation_history: input?.conversation_history || [], + userText: "", + last_reply: null, + pending_reply: null, + pending_actions: [], + fsmState: "IDLE", + _peopleCount: null, + }), + // Universal: si el usuario quiere agregar producto desde cualquier lado, va a cart. + on: { + ADD_TO_CART: { + guard: "wantsToAddProduct", + actions: "setUserText", + target: `.${ConversationStates.CART}.searching`, + }, + BROWSE: { + guard: "wantsToAddProduct", + actions: "setUserText", + target: `.${ConversationStates.CART}.searching`, + }, + }, + states: { + // ───────────────────────────────────────────────────────── + [ConversationStates.IDLE]: { + entry: ["resetFailedSearch"], + on: { + GREETING: { actions: ["replyIdleGreeting"], target: ConversationStates.IDLE, reenter: false }, + ADD_TO_CART: { + guard: "wantsToAddProduct", + actions: "setUserText", + target: `${ConversationStates.CART}.searching`, + }, + BROWSE: { + guard: "wantsToAddProduct", + actions: "setUserText", + target: `${ConversationStates.CART}.searching`, + }, + PRICE_QUERY: { actions: "setUserText", target: `${ConversationStates.CART}.pricing` }, + VIEW_CART: { target: `${ConversationStates.CART}.showing` }, + CONFIRM_ORDER: { actions: "replyEmptyCart" }, + OTHER: { actions: "replyIdleHelp" }, + }, + }, + + // ───────────────────────────────────────────────────────── + [ConversationStates.CART]: { + initial: "idle", + on: { + VIEW_CART: ".showing", + REMOVE_FROM_CART: { + actions: ["removeFromCart", "enqueueRemoveFromCart"], + target: ".showing", + }, + CONFIRM_ORDER: [ + { + guard: "hasPending", + target: ".askingClarification", + }, + { + guard: "hasCart", + actions: "replyConfirmToShipping", + target: `#botino.${ConversationStates.SHIPPING}`, + }, + { + actions: "replyEmptyCart", + target: ".idle", + }, + ], + PRICE_QUERY: { actions: "setUserText", target: ".pricing" }, + GREETING: { actions: "replyIdleGreeting", target: ".idle" }, + }, + states: { + idle: { + // Reposo del cart, esperando próximo evento + }, + + searching: { + invoke: { + src: "searchCatalogActor", + input: ({ context, event }) => ({ + tenantId: context.tenantId, + items: event.items || [], + }), + onDone: { + actions: "addPendingFromCandidates", + target: "resolving", + }, + onError: { + actions: "replyDidntUnderstand", + target: "idle", + }, + }, + }, + + resolving: { + // moveReady ya fue aplicado en addPendingFromCandidates + always: [ + { guard: "pendingNeedsType", target: "askingClarification" }, + { guard: "pendingNeedsQuantity", target: "askingQuantity" }, + { target: "added" }, + ], + }, + + askingClarification: { + entry: ["replyOptions"], + on: { + OTHER: [ + { + guard: "isCancelText", + actions: ["skipFirstPending", "replySkipAck"], + target: "resolving", + }, + { + guard: "isShowMore", + target: "askingClarification", + reenter: true, + }, + { + guard: "isIndexSelection", + actions: ["selectByIndex"], + target: "resolving", + }, + { + guard: "isTextRefinement", + actions: ["setUserText"], + target: "researching", + }, + { + actions: ["replyDidntUnderstand"], + }, + ], + }, + }, + + researching: { + invoke: { + src: "searchCatalogActor", + input: ({ context }) => ({ + tenantId: context.tenantId, + items: [{ product_query: context.userText, quantity: null, unit: null }], + }), + onDone: { + actions: "addPendingFromCandidates", + target: "resolving", + }, + onError: { + actions: ["bumpFailedSearch", "replyDidntUnderstand"], + target: "askingClarification", + }, + }, + }, + + askingQuantity: { + entry: ["replyAskQuantity"], + on: { + OTHER: [ + { + guard: "isPersonasInput", + actions: ["capturePeopleCount"], + target: "computingFromPersonas", + }, + { + guard: "isQuantityInput", + actions: ["setPendingQuantity"], + target: "resolving", + }, + { + actions: ["replyDidntUnderstand"], + }, + ], + }, + }, + + computingFromPersonas: { + invoke: { + src: "getQtyRulesActor", + input: ({ context }) => { + const next = (context.order?.pending || []).find( + (p) => p.status === "NEEDS_TYPE" || p.status === "NEEDS_QUANTITY" + ); + return { + tenantId: context.tenantId, + wooProductId: next?.selected_woo_id, + }; + }, + onDone: { + actions: "setQuantityFromRule", + target: "resolving", + }, + onError: { + actions: ["replyDidntUnderstand"], + target: "askingQuantity", + }, + }, + }, + + added: { + entry: ["replyAddedConfirm", "enqueueAddToCart", "resetFailedSearch"], + always: "idle", + }, + + showing: { + entry: ["replyViewCart"], + always: "idle", + }, + + pricing: { + // Para v1, pricing reusa el flow de searching y muestra resultados. + // Una iteración futura podría tener un actor separado para no agregar al carrito. + invoke: { + src: "searchCatalogActor", + input: ({ context, event }) => ({ + tenantId: context.tenantId, + items: event.items || [], + }), + onDone: [ + { + guard: ({ event }) => (event.output || []).every((r) => (r.candidates || []).length === 0), + actions: ["bumpFailedSearch", "replyNotFound"], + target: "idle", + }, + { + actions: "addPendingFromCandidates", + target: "resolving", + }, + ], + onError: { + actions: ["replyDidntUnderstand"], + target: "idle", + }, + }, + }, + }, + }, + + // ───────────────────────────────────────────────────────── + [ConversationStates.SHIPPING]: { + entry: ({ context }) => { context.fsmState = "SHIPPING"; }, + on: { + SELECT_SHIPPING: [ + { + guard: ({ event }) => event.method === "pickup", + actions: ["setShipping", "replyShippingPickupToPayment"], + target: ConversationStates.PAYMENT, + }, + { + guard: ({ event }) => event.method === "delivery", + actions: ["setShipping", "replyShippingAskAddress"], + }, + { + actions: ["replyShippingAskMethod"], + }, + ], + PROVIDE_ADDRESS: [ + { + guard: ({ context }) => context.order?.is_delivery === true, + actions: ["setAddress", "replyShippingAddressRecorded"], + target: ConversationStates.PAYMENT, + }, + { + actions: ["replyShippingAskMethod"], + }, + ], + VIEW_CART: { actions: "replyShippingAskMethod" }, + OTHER: { actions: "replyShippingAskMethod" }, + }, + }, + + // ───────────────────────────────────────────────────────── + [ConversationStates.PAYMENT]: { + entry: ({ context }) => { context.fsmState = "PAYMENT"; }, + on: { + SELECT_PAYMENT: { + guard: ({ event }) => event.method === "cash" || event.method === "link", + actions: ["setPayment", "enqueueWooCreateOrder", "replyPaymentConfirmed"], + target: ConversationStates.WAITING, + }, + VIEW_CART: { actions: "replyPaymentAskMethod" }, + OTHER: { actions: "replyPaymentAskMethod" }, + }, + }, + + // ───────────────────────────────────────────────────────── + [ConversationStates.WAITING]: { + entry: ({ context }) => { context.fsmState = "WAITING_WEBHOOKS"; }, + on: { + WEBHOOK_PAID: { target: ConversationStates.IDLE }, + VIEW_CART: { actions: "replyWaitingInProgress" }, + OTHER: { actions: "replyWaitingInProgress" }, + }, + }, + + // ───────────────────────────────────────────────────────── + [ConversationStates.AWAITING_HUMAN]: { + // Estado terminal hasta que un humano resuelva. No emite reply propio. + }, + }, +}); + +/** + * Map XState state value → legacy state string esperado por pipeline. + */ +export function xstateToLegacyState(value) { + if (typeof value === "string") { + if (value === "idle") return "IDLE"; + if (value === "shipping") return "SHIPPING"; + if (value === "payment") return "PAYMENT"; + if (value === "waiting") return "WAITING_WEBHOOKS"; + if (value === "awaiting_human") return "AWAITING_HUMAN"; + } + if (value && typeof value === "object") { + if (value.cart) return "CART"; + if (value.shipping) return "SHIPPING"; + if (value.payment) return "PAYMENT"; + if (value.waiting) return "WAITING_WEBHOOKS"; + } + return "IDLE"; +} diff --git a/src/modules/3-turn-engine/machine/index.test.js b/src/modules/3-turn-engine/machine/index.test.js new file mode 100644 index 0000000..dba6ba6 --- /dev/null +++ b/src/modules/3-turn-engine/machine/index.test.js @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createActor } from "xstate"; + +// Mock pool de DB para que aliases / store / qty rules respondan vacío +vi.mock("../../shared/db/pool.js", () => ({ + pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, +})); + +// Mock el rewriter (no usar LLM en tests) +vi.mock("../replyRewriter.js", () => ({ + rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })), +})); + +// Mock catalogRetrieval para evitar dependencia de DB / Woo +vi.mock("../catalogRetrieval.js", () => ({ + retrieveCandidates: vi.fn(async ({ query }) => ({ + candidates: query === "chorizo" + ? [{ woo_product_id: 100, name: "Chorizo Parrillero", price: 1500, sell_unit: "kg", _score: 1.0 }] + : query === "asado" + ? [ + { woo_product_id: 200, name: "Asado de tira", price: 2000, sell_unit: "kg", _score: 0.85 }, + { woo_product_id: 201, name: "Asado banderita", price: 2200, sell_unit: "kg", _score: 0.8 }, + ] + : [], + audit: {}, + })), +})); + +vi.mock("../../0-ui/db/repo.js", () => ({ + getProductQtyRules: vi.fn(async () => []), +})); + +import { machine, xstateToLegacyState } from "./index.js"; + +const TENANT = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; + +function makeActor(input = {}) { + return createActor(machine, { + input: { tenantId: TENANT, chat_id: "t1", storeConfig: {}, ...input }, + }); +} + +async function settle(actor) { + // Espera a que actores invocados terminen (los onDone disparan transiciones). + for (let i = 0; i < 50; i++) { + const snap = actor.getSnapshot(); + const children = Object.values(snap.children || {}); + const running = children.some((c) => { + try { + return c.getSnapshot()?.status === "active"; + } catch { + return false; + } + }); + if (!running) return snap; + await new Promise((r) => setTimeout(r, 5)); + } + return actor.getSnapshot(); +} + +describe("machine — initial state", () => { + it("starts in idle", () => { + const a = makeActor(); + a.start(); + expect(a.getSnapshot().value).toBe("idle"); + a.stop(); + }); + + it("greeting in idle stays in idle and emits idle.greeting reply", () => { + const a = makeActor(); + a.start(); + a.send({ type: "GREETING" }); + const snap = a.getSnapshot(); + expect(snap.value).toBe("idle"); + expect(snap.context.pending_reply).toMatchObject({ templateKey: "idle.greeting" }); + a.stop(); + }); +}); + +describe("machine — universal cart-on-add rule", () => { + it("ADD_TO_CART from idle goes to cart.searching", () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo" }], text: "chorizo" }); + expect(a.getSnapshot().value).toEqual({ cart: "searching" }); + a.stop(); + }); + + it("ADD_TO_CART from shipping returns to cart (universal rule)", async () => { + const a = makeActor(); + a.start(); + // forzar shipping con qty+unit completos para que strong-match resuelva READY + a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg chorizo" }); + await settle(a); + a.send({ type: "CONFIRM_ORDER" }); + expect(a.getSnapshot().value).toBe("shipping"); + // ahora desde shipping pide otro producto + a.send({ type: "ADD_TO_CART", items: [{ product_query: "asado" }], text: "asado" }); + expect(a.getSnapshot().value).toEqual({ cart: "searching" }); + a.stop(); + }); + + it("ADD_TO_CART without real product does NOT redirect", () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [], text: "" }); + // No items reales → guard wantsToAddProduct rechaza, queda en idle + expect(a.getSnapshot().value).toBe("idle"); + a.stop(); + }); +}); + +describe("machine — cart flow", () => { + it("strong-match product goes searching → resolving → askingQuantity", async () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo" }], text: "1 chorizo" }); + const snap = await settle(a); + // chorizo resuelve a 1 candidato (strong) sin qty → askingQuantity (vende por kg) + expect(["askingQuantity", "added"]).toContain(snap.value.cart); + a.stop(); + }); + + it("multi-match product goes searching → resolving → askingClarification", async () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "asado" }], text: "asado" }); + const snap = await settle(a); + expect(snap.value).toEqual({ cart: "askingClarification" }); + expect(snap.context.pending_reply?.rawText).toMatch(/asado/i); + a.stop(); + }); + + it("index selection in askingClarification advances", async () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "asado" }], text: "asado" }); + await settle(a); + a.send({ type: "OTHER", text: "1" }); + const after = await settle(a); + // Después de seleccionar 1 (Asado de tira, kg), debe ir a askingQuantity + expect(["askingQuantity", "added"]).toContain(after.value.cart); + a.stop(); + }); +}); + +describe("machine — checkout flow", () => { + async function buildCartWithItem(actor) { + actor.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" }); + await settle(actor); + } + + it("CONFIRM_ORDER with cart goes to shipping", async () => { + const a = makeActor(); + a.start(); + await buildCartWithItem(a); + a.send({ type: "CONFIRM_ORDER" }); + expect(a.getSnapshot().value).toBe("shipping"); + a.stop(); + }); + + it("CONFIRM_ORDER with empty cart shows empty prompt", () => { + const a = makeActor(); + a.start(); + a.send({ type: "CONFIRM_ORDER" }); + const snap = a.getSnapshot(); + expect(snap.context.pending_reply?.templateKey).toBe("cart.empty_prompt"); + a.stop(); + }); + + it("SELECT_SHIPPING pickup goes to payment", async () => { + const a = makeActor(); + a.start(); + await buildCartWithItem(a); + a.send({ type: "CONFIRM_ORDER" }); + a.send({ type: "SELECT_SHIPPING", method: "pickup" }); + expect(a.getSnapshot().value).toBe("payment"); + expect(a.getSnapshot().context.order.is_delivery).toBe(false); + a.stop(); + }); + + it("SELECT_SHIPPING delivery + PROVIDE_ADDRESS goes to payment", async () => { + const a = makeActor(); + a.start(); + await buildCartWithItem(a); + a.send({ type: "CONFIRM_ORDER" }); + a.send({ type: "SELECT_SHIPPING", method: "delivery" }); + expect(a.getSnapshot().value).toBe("shipping"); + a.send({ type: "PROVIDE_ADDRESS", address: "Corrientes 1234" }); + expect(a.getSnapshot().value).toBe("payment"); + expect(a.getSnapshot().context.order.shipping_address).toBe("Corrientes 1234"); + a.stop(); + }); + + it("SELECT_PAYMENT cash goes to waiting and enqueues create_order", async () => { + const a = makeActor(); + a.start(); + await buildCartWithItem(a); + a.send({ type: "CONFIRM_ORDER" }); + a.send({ type: "SELECT_SHIPPING", method: "pickup" }); + a.send({ type: "SELECT_PAYMENT", method: "cash" }); + const snap = a.getSnapshot(); + expect(snap.value).toBe("waiting"); + expect(snap.context.pending_actions.some((a) => a.type === "create_order")).toBe(true); + a.stop(); + }); +}); + +describe("machine — snapshot persistence", () => { + it("rehydrates from getPersistedSnapshot preserving order state", async () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" }); + await settle(a); + const persisted = a.getPersistedSnapshot(); + a.stop(); + + // boot another actor from the same snapshot + const b = createActor(machine, { + snapshot: persisted, + input: { tenantId: TENANT, chat_id: "t1", storeConfig: {} }, + }); + b.start(); + const snap = b.getSnapshot(); + expect(snap.context.order.cart.length).toBeGreaterThan(0); + expect(snap.context.order.cart[0].name).toMatch(/Chorizo/i); + b.stop(); + }); +}); + +describe("xstateToLegacyState", () => { + it("maps top-level idle/shipping/payment/waiting", () => { + expect(xstateToLegacyState("idle")).toBe("IDLE"); + expect(xstateToLegacyState("shipping")).toBe("SHIPPING"); + expect(xstateToLegacyState("payment")).toBe("PAYMENT"); + expect(xstateToLegacyState("waiting")).toBe("WAITING_WEBHOOKS"); + }); + it("maps cart sub-states to CART", () => { + expect(xstateToLegacyState({ cart: "idle" })).toBe("CART"); + expect(xstateToLegacyState({ cart: "askingClarification" })).toBe("CART"); + }); +}); diff --git a/src/modules/3-turn-engine/machine/nluToEvent.js b/src/modules/3-turn-engine/machine/nluToEvent.js new file mode 100644 index 0000000..72eb5a0 --- /dev/null +++ b/src/modules/3-turn-engine/machine/nluToEvent.js @@ -0,0 +1,49 @@ +/** + * NLU → XState event adapter. + * Cada NLU intent se traduce a un único evento de la máquina. + */ + +import { extractProductQueries } from "../stateHandlers/cartHelpers.js"; + +export function nluToEvent(nlu, text) { + const intent = nlu?.intent || "other"; + const entities = nlu?.entities || {}; + + switch (intent) { + case "greeting": + return { type: "GREETING" }; + + case "add_to_cart": + return { type: "ADD_TO_CART", items: extractProductQueries(nlu) }; + + case "view_cart": + return { type: "VIEW_CART" }; + + case "remove_from_cart": + return { type: "REMOVE_FROM_CART", items: entities.items || [] }; + + case "confirm_order": + return { type: "CONFIRM_ORDER" }; + + case "price_query": + return { type: "PRICE_QUERY", items: extractProductQueries(nlu) }; + + case "recommend": + return { type: "RECOMMEND", text }; + + case "browse": + return { type: "BROWSE", items: extractProductQueries(nlu) }; + + case "select_shipping": + return { type: "SELECT_SHIPPING", method: entities.shipping_method || null }; + + case "provide_address": + return { type: "PROVIDE_ADDRESS", address: entities.address || text }; + + case "select_payment": + return { type: "SELECT_PAYMENT", method: entities.payment_method || null }; + + default: + return { type: "OTHER", text }; + } +} diff --git a/src/modules/3-turn-engine/machine/runner.js b/src/modules/3-turn-engine/machine/runner.js new file mode 100644 index 0000000..75cc1bb --- /dev/null +++ b/src/modules/3-turn-engine/machine/runner.js @@ -0,0 +1,245 @@ +/** + * 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, + }, + }; +} diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index b752597..eea81fa 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -20,9 +20,41 @@ import { } from "./stateHandlers.js"; import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; import { pushRecent } from "./replyTemplates.js"; +import { runTurnXState } from "./machine/runner.js"; // Feature flag para NLU modular const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; +// Feature flags para XState +function useXState() { + const v = String(process.env.USE_XSTATE || "").toLowerCase(); + return v === "1" || v === "true" || v === "yes"; +} +function shadowXState() { + const v = String(process.env.XSTATE_SHADOW || "").toLowerCase(); + return v === "1" || v === "true" || v === "yes"; +} + +/** + * Compara plan/decision entre legacy y XState para shadow mode. + * No hace assertions; solo loguea diferencias estructurales. + */ +function diffResults(legacy, xstate) { + const diffs = []; + if (legacy?.plan?.next_state !== xstate?.plan?.next_state) { + diffs.push({ key: "next_state", legacy: legacy?.plan?.next_state, xstate: xstate?.plan?.next_state }); + } + const lActions = (legacy?.decision?.actions || []).map((a) => a.type).sort().join(","); + const xActions = (xstate?.decision?.actions || []).map((a) => a.type).sort().join(","); + if (lActions !== xActions) { + diffs.push({ key: "action_types", legacy: lActions, xstate: xActions }); + } + const lCart = (legacy?.decision?.context_patch?.order?.cart || []).map((c) => `${c.woo_id}:${c.qty}${c.unit}`).sort().join(","); + const xCart = (xstate?.decision?.context_patch?.order?.cart || []).map((c) => `${c.woo_id}:${c.qty}${c.unit}`).sort().join(","); + if (lCart !== xCart) { + diffs.push({ key: "cart", legacy: lCart, xstate: xCart }); + } + return diffs; +} /** * Genera un resumen corto del historial para el NLU @@ -50,6 +82,11 @@ export async function runTurnV3({ prev_context, conversation_history, }) { + // Branch: XState completo (USE_XSTATE=1) + if (useXState() && !shadowXState()) { + return runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }); + } + const audit = { trace: { tenantId, @@ -176,7 +213,21 @@ export async function runTurnV3({ result = await handleIdleState(handlerParams); } - return formatResult(result, prev_context, recentReplies, failedSearches); + const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches); + + // Shadow mode: corre XState en paralelo, devuelve legacy, loguea diffs. + if (shadowXState()) { + runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) + .then((xstateResult) => { + const diffs = diffResults(legacyResult, xstateResult); + if (diffs.length) { + console.log("[xstate-shadow] diffs", { chat_id, diffs }); + } + }) + .catch((err) => console.error("[xstate-shadow] error", err?.message || err)); + } + + return legacyResult; } /**