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:
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user