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>
354 lines
11 KiB
JavaScript
354 lines
11 KiB
JavaScript
/**
|
|
* Turn Engine V3 - Dispatcher basado en estados
|
|
*
|
|
* Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
|
|
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
|
*
|
|
* Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular.
|
|
*/
|
|
|
|
import { llmNluV3 } from "./openai.js";
|
|
import { llmNluModular } from "./nlu/index.js";
|
|
import { ConversationState, shouldReturnToCart, safeNextState } from "./fsm.js";
|
|
import { migrateOldContext, createEmptyOrder } from "./orderModel.js";
|
|
import {
|
|
handleIdleState,
|
|
handleCartState,
|
|
handleShippingState,
|
|
handlePaymentState,
|
|
handleWaitingState,
|
|
} 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
|
|
*/
|
|
function shortSummary(history) {
|
|
if (!Array.isArray(history) || history.length === 0) return null;
|
|
const last = history.slice(-6);
|
|
return last
|
|
.map((m) => {
|
|
const role = m.role === "user" ? "U" : "A";
|
|
const txt = String(m.content || "").slice(0, 80);
|
|
return `${role}: ${txt}`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Punto de entrada principal del turn engine.
|
|
*/
|
|
export async function runTurnV3({
|
|
tenantId,
|
|
chat_id,
|
|
text,
|
|
prev_state,
|
|
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,
|
|
chat_id,
|
|
text_preview: String(text || "").slice(0, 50),
|
|
prev_state,
|
|
},
|
|
};
|
|
|
|
// Migrar contexto viejo a nuevo formato de orden
|
|
const order = migrateOldContext(prev_context);
|
|
|
|
// Mapear estados viejos a nuevos
|
|
const normalizedState = normalizeState(prev_state);
|
|
|
|
// Recent replies para dedup de templates (FIFO cap 8)
|
|
const recentReplies = Array.isArray(prev_context?.recent_replies)
|
|
? prev_context.recent_replies
|
|
: [];
|
|
|
|
// Counter de búsquedas fallidas consecutivas para escalación
|
|
const failedSearches = (prev_context?.failed_searches && typeof prev_context.failed_searches === "object")
|
|
? prev_context.failed_searches
|
|
: { count: 0, last_query: null, last_at: null };
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// NLU (con feature flag para sistema modular)
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
const nluInput = {
|
|
last_user_message: text,
|
|
conversation_state: normalizedState,
|
|
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: [], // Ya no usamos este campo
|
|
locale: "es-AR",
|
|
};
|
|
|
|
// Cargar configuración del tenant (se usa en NLU y handlers)
|
|
const storeConfig = await getStoreConfig({ tenantId });
|
|
|
|
let nluResult;
|
|
|
|
if (USE_MODULAR_NLU) {
|
|
// Nuevo sistema NLU modular con prompts editables
|
|
nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig });
|
|
audit.nlu = {
|
|
raw_text: nluResult.raw_text,
|
|
model: nluResult.model,
|
|
usage: nluResult.usage,
|
|
validation: nluResult.validation,
|
|
parsed: nluResult.nlu,
|
|
routing: nluResult.routing,
|
|
schema: "modular_v1",
|
|
};
|
|
} else {
|
|
// Sistema NLU clásico
|
|
nluResult = await llmNluV3({ input: nluInput });
|
|
audit.nlu = {
|
|
raw_text: nluResult.raw_text,
|
|
model: nluResult.model,
|
|
usage: nluResult.usage,
|
|
validation: nluResult.validation,
|
|
parsed: nluResult.nlu,
|
|
schema: "v3",
|
|
};
|
|
}
|
|
|
|
const nlu = nluResult.nlu;
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Dispatcher por estado
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
const handlerParams = {
|
|
tenantId,
|
|
chat_id,
|
|
text,
|
|
nlu,
|
|
order,
|
|
audit,
|
|
storeConfig,
|
|
recentReplies,
|
|
conversation_history: conversation_history || [],
|
|
failedSearches,
|
|
};
|
|
|
|
// Regla universal: si quiere agregar productos, volver a CART
|
|
const returnToCart = shouldReturnToCart(normalizedState, nlu, text);
|
|
if (returnToCart) {
|
|
const result = await handleCartState({ ...handlerParams, fromIdle: false });
|
|
return formatResult(result, prev_context, recentReplies, failedSearches);
|
|
}
|
|
|
|
// Dispatch por estado actual
|
|
let result;
|
|
|
|
switch (normalizedState) {
|
|
case ConversationState.IDLE:
|
|
result = await handleIdleState(handlerParams);
|
|
break;
|
|
|
|
case ConversationState.CART:
|
|
result = await handleCartState(handlerParams);
|
|
break;
|
|
|
|
case ConversationState.SHIPPING:
|
|
result = await handleShippingState(handlerParams);
|
|
break;
|
|
|
|
case ConversationState.PAYMENT:
|
|
result = await handlePaymentState(handlerParams);
|
|
break;
|
|
|
|
case ConversationState.WAITING_WEBHOOKS:
|
|
result = await handleWaitingState(handlerParams);
|
|
break;
|
|
|
|
default:
|
|
// Estado desconocido, tratar como IDLE
|
|
result = await handleIdleState(handlerParams);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Normaliza estados viejos al nuevo modelo
|
|
*/
|
|
function normalizeState(state) {
|
|
if (!state) return ConversationState.IDLE;
|
|
|
|
const s = String(state).toUpperCase();
|
|
|
|
// Mapeo directo
|
|
if (s === "IDLE") return ConversationState.IDLE;
|
|
if (s === "CART") return ConversationState.CART;
|
|
if (s === "SHIPPING") return ConversationState.SHIPPING;
|
|
if (s === "PAYMENT") return ConversationState.PAYMENT;
|
|
if (s === "WAITING_WEBHOOKS") return ConversationState.WAITING_WEBHOOKS;
|
|
|
|
// Estados viejos → CART
|
|
if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) {
|
|
return ConversationState.CART;
|
|
}
|
|
|
|
// Estados de checkout viejos
|
|
if (s === "CLARIFYING_PAYMENT") return ConversationState.PAYMENT;
|
|
if (s === "CLARIFYING_SHIPPING") return ConversationState.SHIPPING;
|
|
if (s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
|
|
if (s === "AWAITING_PAYMENT") return ConversationState.WAITING_WEBHOOKS;
|
|
if (s === "COMPLETED") return ConversationState.IDLE; // Nuevo ciclo
|
|
|
|
return ConversationState.IDLE;
|
|
}
|
|
|
|
/**
|
|
* Formatea el resultado para compatibilidad con el sistema existente
|
|
*/
|
|
function formatResult(result, prevContext, recentReplies = [], failedSearches = { count: 0 }) {
|
|
const { plan, decision } = result;
|
|
const order = decision?.order || createEmptyOrder();
|
|
|
|
// Mergear template_ids usados por los handlers en recent_replies
|
|
const idsUsed = Array.isArray(decision?.template_ids_used)
|
|
? decision.template_ids_used.filter(Boolean)
|
|
: [];
|
|
let nextRecent = recentReplies;
|
|
for (const id of idsUsed) {
|
|
nextRecent = pushRecent(nextRecent, id);
|
|
}
|
|
|
|
// failed_searches: handlers pueden devolver decision.failed_searches_next.
|
|
// Si no, mantener el previo.
|
|
const nextFailedSearches = decision?.failed_searches_next || failedSearches;
|
|
|
|
// Construir context_patch para compatibilidad con pipeline
|
|
const context_patch = {
|
|
// Nueva estructura
|
|
order,
|
|
|
|
// Compatibilidad: también guardar en formato viejo para UI/pipeline existente
|
|
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,
|
|
|
|
// Dedup de respuestas: ids de templates usados, FIFO cap 8
|
|
recent_replies: nextRecent,
|
|
// Counter de búsquedas fallidas para escalación
|
|
failed_searches: nextFailedSearches,
|
|
};
|
|
|
|
// Construir basket_resolved para UI
|
|
const basket_resolved = {
|
|
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,
|
|
})),
|
|
};
|
|
|
|
return {
|
|
plan: {
|
|
...plan,
|
|
basket_resolved,
|
|
},
|
|
decision: {
|
|
actions: decision?.actions || [],
|
|
context_patch,
|
|
audit: decision?.audit || {},
|
|
},
|
|
};
|
|
}
|
|
|
|
// Re-exportar safeNextState para compatibilidad
|
|
export { safeNextState } from "./fsm.js";
|