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

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