Files
botino/src/modules/3-turn-engine/turnEngineV3.js
Lucas Tettamanti 17cea4aa9e Eliminar payment + waiting (legacy): el bot toma pedidos, no cobra
El bot conversacional no maneja pagos. Su trabajo: pedidos, datos de
entrega, dejar la orden anotada en Woo (status=pending). El cobro lo
gestiona el comercio offline. Todo lo de payment_type / is_paid /
PAYMENT / WAITING_WEBHOOKS era legacy de un flow viejo que se baja.

Nuevo flow: IDLE → CART → SHIPPING → IDLE (con orden creada).
Cuando el usuario completa shipping (pickup elegido OR delivery+address),
shipping.js emite create_order y el bot cierra con order.confirmed.

- fsm.js: 4 estados (IDLE/CART/SHIPPING/AWAITING_HUMAN). hasPaymentInfo
  e isPaid eliminados. deriveNextState gira SHIPPING→IDLE en vez de
  →PAYMENT→WAITING. ALLOWED transitions actualizadas.
- orderModel.js: createEmptyOrder() sin payment_type/is_paid.
  migrateOldContext deja de leer payment_method / mp.payment_status.
- stateHandlers: payment.js y waiting.js eliminados. shipping.js gana
  finalizeOrder() que emite create_order action y vuelve a IDLE.
- replyTemplates: payment.* y waiting.* fuera. order.confirmed nuevo,
  con 3 variantes y rewriter habilitado.
- NLU openai.js + nlu/schemas.js: select_payment fuera del enum, payment_method
  fuera de entities. Prompt sin la regla de SELECCIONAR PAGO.
- nlu/router.js + nlu/index.js: dominio "payment" eliminado.
  shouldSkipRouter ya no chequea PAYMENT.
- nlu/specialists/payment.js: eliminado.
- promptsRepo.js + promptLoader.js: PROMPT_KEYS sin "payment".
- turnEngineV3.js: switch ya no dispatcha a PAYMENT/WAITING. normalizeState
  mapea estados legacy (PAYMENT/WAITING_WEBHOOKS/COMPLETED) a IDLE.
  context_patch ya no emite payment_method.
- wooOrders.createOrder: paymentMethod param eliminado. Order queda en
  status=pending sin payment_method (cobro offline).
- pipeline.js: paymentMethod fuera del create_order glue. Invariant
  "no_checkout_without_payment_link" eliminado. signal payment_selected
  reemplazado por shipping_completed.
- XState machine: top-level PAYMENT y WAITING eliminados. SELECT_PAYMENT
  event fuera. SHIPPING ahora cierra con enqueueWooCreateOrder +
  replyOrderConfirmed → IDLE. Guards hasPayment/isPaid borrados.
- Tests fsm.test.js / orderModel.test.js / machine/index.test.js
  actualizados al nuevo contrato. 188 tests pasando.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:53:19 -03:00

339 lines
11 KiB
JavaScript

/**
* Turn Engine V3 - Dispatcher basado en estados
*
* Flujo: IDLE → CART → SHIPPING → IDLE (orden creada offline)
* 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,
} 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;
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;
// Estados viejos / payment-flow legacy → mapeos seguros
if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) {
return ConversationState.CART;
}
if (s === "CLARIFYING_SHIPPING" || s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
// Estados que ya no existen (payment / waiting / completed) vuelven a IDLE
if (["PAYMENT", "WAITING_WEBHOOKS", "CLARIFYING_PAYMENT", "AWAITING_PAYMENT", "COMPLETED"].includes(s)) {
return ConversationState.IDLE;
}
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",
})),
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";