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>
246 lines
8.0 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|