Tier 2: XState statechart como motor de turno (opt-in)

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 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-01 20:38:26 -03:00
parent f784ddd62d
commit 04ac33430f
11 changed files with 1513 additions and 1 deletions

View File

@@ -54,6 +54,15 @@ MAX_CHARS_PER_MESSAGE=4000
REPLY_REWRITER=0 REPLY_REWRITER=0
REPLY_REWRITER_TIMEOUT_MS=1500 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) # Debug Flags (1/true/yes/on para activar)
# =================== # ===================

11
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"undici": "^7.16.0", "undici": "^7.16.0",
"xstate": "^5.31.0",
"zod": "^4.3.4" "zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {
@@ -3404,6 +3405,16 @@
"node": ">=8" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -30,6 +30,7 @@
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"undici": "^7.16.0", "undici": "^7.16.0",
"xstate": "^5.31.0",
"zod": "^4.3.4" "zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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);
},
};

View File

@@ -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";
}

View File

@@ -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");
});
});

View File

@@ -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 };
}
}

View File

@@ -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,
},
};
}

View File

@@ -20,9 +20,41 @@ import {
} from "./stateHandlers.js"; } from "./stateHandlers.js";
import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
import { pushRecent } from "./replyTemplates.js"; import { pushRecent } from "./replyTemplates.js";
import { runTurnXState } from "./machine/runner.js";
// Feature flag para NLU modular // Feature flag para NLU modular
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; 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 * Genera un resumen corto del historial para el NLU
@@ -50,6 +82,11 @@ export async function runTurnV3({
prev_context, prev_context,
conversation_history, 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 = { const audit = {
trace: { trace: {
tenantId, tenantId,
@@ -176,7 +213,21 @@ export async function runTurnV3({
result = await handleIdleState(handlerParams); 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;
} }
/** /**