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:
@@ -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)
|
||||
# ===================
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
400
src/modules/3-turn-engine/machine/actions.js
Normal file
400
src/modules/3-turn-engine/machine/actions.js
Normal 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,
|
||||
};
|
||||
45
src/modules/3-turn-engine/machine/actors.js
Normal file
45
src/modules/3-turn-engine/machine/actors.js
Normal 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,
|
||||
};
|
||||
92
src/modules/3-turn-engine/machine/guards.js
Normal file
92
src/modules/3-turn-engine/machine/guards.js
Normal 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);
|
||||
},
|
||||
};
|
||||
367
src/modules/3-turn-engine/machine/index.js
Normal file
367
src/modules/3-turn-engine/machine/index.js
Normal 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";
|
||||
}
|
||||
242
src/modules/3-turn-engine/machine/index.test.js
Normal file
242
src/modules/3-turn-engine/machine/index.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
49
src/modules/3-turn-engine/machine/nluToEvent.js
Normal file
49
src/modules/3-turn-engine/machine/nluToEvent.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
245
src/modules/3-turn-engine/machine/runner.js
Normal file
245
src/modules/3-turn-engine/machine/runner.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user