Files
botino/src/modules/3-turn-engine/machine/runner.js
Lucas Tettamanti 04ac33430f 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>
2026-05-01 20:38:26 -03:00

246 lines
8.0 KiB
JavaScript

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