Redesign: agente tool-calling con DeepSeek (D2-D10 del plan)
Reemplaza el NLU rígido (intent+entities) por un agente LLM con tool-calling
que decide y muta estado en cada turno. Opt-in vía AGENT_TURN_ENGINE=1.
DeepSeek V4 (deepseek-chat) configurado como modelo (OpenAI-compatible).
Arquitectura nueva en src/modules/3-turn-engine/agent/:
- workingMemory.js: arma el JSON contextual que recibe el LLM cada turno
(cart, pending, last_shown_options, store, customer_profile, history,
preparsed quantity).
- systemPrompt.js: prompt estático ~70 líneas. Define rol + reglas duras +
cómo procesar mensajes + cómo escribir el say. Sin enumeración de intents.
- runTurn.js: loop de tool-calling con tool_choice="required". Cap 10 tool
calls / 20s timeout. Métricas in-memory.
- customerProfile.js: lookup de frequent_items en woo_orders_cache por
teléfono (chat_id → phone), top 5 últimos 6 meses. Cache 10 min.
- tools/schemas.js: 11 tools (search_catalog, add_to_cart, set_quantity,
select_candidate, remove_from_cart, set_shipping, set_address,
confirm_order, pause, escalate_to_human, say).
- tools/executor.js: validación Ajv + dispatch + observación al LLM.
woo_id se valida contra snapshot — si no existe el agente vuelve a
search_catalog (anti-halucinación).
- tools/searchCatalog.js: wrappea retrieveCandidates + fallback por
categoría usando jsonb_array_elements_text del snapshot. Persiste
last_shown_options automáticamente.
- tools/{addToCart, setQuantity, selectCandidate, removeFromCart,
setShipping, setAddress, confirmOrder, pause, escalateToHuman}.js:
side effects atómicos sobre el order.
- quantityParser.js (D1): determinístico, parsea fracciones, frases
compuestas (media docena, cuarto kilo), numéricos. 46 tests.
FSM extendida (fsm.js): nuevo estado PAUSED (TTL 7d, cart preservado,
"después te digo" → pause tool).
pipeline.js: TTL stale ahora 24h general, 7d si PAUSED, infinito si
AWAITING_HUMAN.
turnEngineV3.js: nuevas flags AGENT_TURN_ENGINE y AGENT_TURN_ENGINE_SHADOW.
Branch a runTurnAgent cuando full o corre en paralelo escribiendo diffs
estructurales en audit_log (entity_type='agent_shadow') para validar
paridad antes de flippar.
Endpoint nuevo: GET /api/metrics/agent → turns, avg_tool_calls, fallback
rate, escalations, pauses, orders_confirmed.
Smoke test E2E con DeepSeek real:
- "hola" → say (2.3s, 1 tool)
- "2kg de vacio" → search → add_to_cart → say (8.8s, 3 tools)
- "media docena de chorizos" → search → say con clarificación (10.3s, 4 tools)
- "listo" → say (3.3s, 1 tool)
- "retiro" → set_shipping → confirm → say (5.1s, 3 tools)
Cart final correcto: 2kg de Vacío. Estado: CART → SHIPPING.
Tests: 238/238 pasando.
D9 (cleanup legacy ~1200 LOC NLU/handlers/replyRewriter) DEFERRED:
se hace después de paridad shadow validada con tráfico real. Hoy
agente coexiste con legacy; default sigue siendo el motor V3.
Plan completo en ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settin
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js";
|
||||
import { getRewriterMetrics } from "../../3-turn-engine/replyRewriter.js";
|
||||
import { getAgentMetrics } from "../../3-turn-engine/agent/runTurn.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -52,6 +53,7 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
*/
|
||||
router.post("/sim/send", makeSimSend());
|
||||
router.get("/api/metrics/rewriter", (req, res) => res.json(getRewriterMetrics()));
|
||||
router.get("/api/metrics/agent", (req, res) => res.json(getAgentMetrics()));
|
||||
|
||||
router.get("/conversations", makeGetConversations(getTenantId));
|
||||
router.get("/conversations/state", makeGetConversationState(getTenantId));
|
||||
|
||||
@@ -128,12 +128,15 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha
|
||||
mark("start");
|
||||
const stageDebug = dbg.perf;
|
||||
mark("after_touchConversationState");
|
||||
// Detectar conversación nueva (más de 24 horas sin actividad)
|
||||
const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas
|
||||
// TTL stale: 24h general, 7d si la conversación quedó PAUSED, sin TTL si AWAITING_HUMAN.
|
||||
const stateNow = prev?.state || "IDLE";
|
||||
let staleThresholdMs = 24 * 60 * 60 * 1000;
|
||||
if (stateNow === "PAUSED") staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
|
||||
if (stateNow === "AWAITING_HUMAN") staleThresholdMs = Infinity;
|
||||
const isStale =
|
||||
prev?.state_updated_at &&
|
||||
Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs;
|
||||
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
|
||||
const prev_state = isStale ? "IDLE" : stateNow;
|
||||
let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
|
||||
112
src/modules/3-turn-engine/agent/customerProfile.js
Normal file
112
src/modules/3-turn-engine/agent/customerProfile.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* customerProfile — perfil del cliente para "lo de siempre".
|
||||
*
|
||||
* Lookup por teléfono (extraído del chat_id WhatsApp) en woo_orders_cache.
|
||||
* Agrupa items por woo_product_id en los últimos 6 meses, top 5 frequent_items.
|
||||
*
|
||||
* Cache 10 min por chat_id.
|
||||
*/
|
||||
|
||||
import { pool } from "../../shared/db/pool.js";
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const _cache = new Map();
|
||||
|
||||
function phoneFromChatId(chatId) {
|
||||
if (!chatId) return null;
|
||||
// chat_id típico: "5491133230322@s.whatsapp.net"
|
||||
const m = /^(\d+)/.exec(String(chatId));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function normalizePhone(p) {
|
||||
return String(p || "").replace(/[^\d]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve perfil del cliente o null si no hay datos.
|
||||
*/
|
||||
export async function getCustomerProfile({ tenantId, chat_id }) {
|
||||
if (!tenantId || !chat_id) return null;
|
||||
const cacheKey = `${tenantId}:${chat_id}`;
|
||||
const cached = _cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.value;
|
||||
|
||||
const phone = phoneFromChatId(chat_id);
|
||||
if (!phone) {
|
||||
_cache.set(cacheKey, { value: null, t: Date.now() });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await fetchProfile({ tenantId, phone });
|
||||
_cache.set(cacheKey, { value: profile, t: Date.now() });
|
||||
return profile;
|
||||
} catch (err) {
|
||||
console.error("[customerProfile] error:", err?.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile({ tenantId, phone }) {
|
||||
const phoneClean = normalizePhone(phone);
|
||||
if (!phoneClean) return null;
|
||||
|
||||
// Match phones que terminen igual (los Woo a veces vienen con +54 o sin)
|
||||
const phoneSuffix = phoneClean.slice(-8);
|
||||
|
||||
const orderSql = `
|
||||
SELECT id, woo_order_id, total, date_created,
|
||||
shipping_address_1, shipping_address_2, shipping_city, customer_name
|
||||
FROM woo_orders_cache
|
||||
WHERE tenant_id = $1
|
||||
AND regexp_replace(coalesce(customer_phone,''), '\\D', '', 'g') LIKE '%' || $2
|
||||
AND date_created > NOW() - INTERVAL '6 months'
|
||||
ORDER BY date_created DESC
|
||||
LIMIT 30
|
||||
`;
|
||||
const { rows: orders } = await pool.query(orderSql, [tenantId, phoneSuffix]);
|
||||
if (orders.length === 0) {
|
||||
return { is_returning: false, last_order_at: null, frequent_items: [], preferred_address: null };
|
||||
}
|
||||
|
||||
const orderIds = orders.map((o) => o.woo_order_id);
|
||||
const itemsSql = `
|
||||
SELECT woo_product_id, product_name, sell_unit,
|
||||
COUNT(*) AS times,
|
||||
SUM(quantity) AS total_qty,
|
||||
AVG(quantity) AS avg_qty
|
||||
FROM woo_order_items
|
||||
WHERE tenant_id = $1 AND woo_order_id = ANY($2::bigint[]) AND woo_product_id IS NOT NULL
|
||||
GROUP BY woo_product_id, product_name, sell_unit
|
||||
ORDER BY times DESC, total_qty DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
const { rows: items } = await pool.query(itemsSql, [tenantId, orderIds]);
|
||||
|
||||
const frequent_items = items.map((it) => ({
|
||||
woo_id: Number(it.woo_product_id),
|
||||
name: it.product_name,
|
||||
times_ordered: Number(it.times),
|
||||
avg_qty: Number(it.avg_qty),
|
||||
avg_unit: it.sell_unit || null,
|
||||
}));
|
||||
|
||||
const lastOrder = orders[0];
|
||||
const preferredAddress = [lastOrder.shipping_address_1, lastOrder.shipping_address_2, lastOrder.shipping_city]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
is_returning: true,
|
||||
last_order_at: lastOrder.date_created,
|
||||
customer_name: lastOrder.customer_name || null,
|
||||
frequent_items,
|
||||
preferred_address: preferredAddress || null,
|
||||
total_orders_last_6m: orders.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateCustomerProfileCache(chat_id) {
|
||||
for (const k of _cache.keys()) if (k.endsWith(`:${chat_id}`)) _cache.delete(k);
|
||||
}
|
||||
319
src/modules/3-turn-engine/agent/runTurn.js
Normal file
319
src/modules/3-turn-engine/agent/runTurn.js
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* runTurn — Punto de entrada del agente tool-calling.
|
||||
*
|
||||
* Reemplaza turnEngineV3 cuando AGENT_TURN_ENGINE=1.
|
||||
* Mantiene la firma compatible con pipeline.js:
|
||||
* runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
|
||||
* → { plan, decision }
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { migrateOldContext, createEmptyOrder } from "../orderModel.js";
|
||||
import { getStoreConfig } from "../../0-ui/db/settingsRepo.js";
|
||||
import { ConversationState, safeNextState } from "../fsm.js";
|
||||
import { buildWorkingMemory } from "./workingMemory.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
import { TOOL_SCHEMAS } from "./tools/schemas.js";
|
||||
import { executeToolCall } from "./tools/executor.js";
|
||||
import { getCustomerProfile } from "./customerProfile.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
|
||||
const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10);
|
||||
const TURN_TIMEOUT_MS = parseInt(process.env.AGENT_TURN_TIMEOUT_MS || "20000", 10);
|
||||
|
||||
// Métricas in-memory: turns/calls, fallback rate, escalation rate, avg duration.
|
||||
const _metrics = {
|
||||
turns: 0,
|
||||
total_tool_calls: 0,
|
||||
total_llm_calls: 0,
|
||||
total_duration_ms: 0,
|
||||
fallback_used: 0,
|
||||
llm_errors: 0,
|
||||
escalations: 0,
|
||||
pauses: 0,
|
||||
orders_confirmed: 0,
|
||||
};
|
||||
|
||||
export function getAgentMetrics() {
|
||||
const t = _metrics.turns;
|
||||
return {
|
||||
turns: t,
|
||||
avg_tool_calls_per_turn: t ? +(_metrics.total_tool_calls / t).toFixed(2) : 0,
|
||||
avg_llm_calls_per_turn: t ? +(_metrics.total_llm_calls / t).toFixed(2) : 0,
|
||||
avg_duration_ms: t ? Math.round(_metrics.total_duration_ms / t) : 0,
|
||||
fallback_rate: t ? +(_metrics.fallback_used / t).toFixed(3) : 0,
|
||||
error_rate: t ? +(_metrics.llm_errors / t).toFixed(3) : 0,
|
||||
escalations: _metrics.escalations,
|
||||
pauses: _metrics.pauses,
|
||||
orders_confirmed: _metrics.orders_confirmed,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAgentMetrics() {
|
||||
for (const k of Object.keys(_metrics)) _metrics[k] = 0;
|
||||
}
|
||||
|
||||
let _client = null;
|
||||
function getClient() {
|
||||
if (_client) return _client;
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY not set");
|
||||
const baseURL = process.env.OPENAI_BASE_URL || undefined;
|
||||
_client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
return process.env.OPENAI_MODEL || "deepseek-chat";
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Punto de entrada principal. Mismo signature que runTurnV3.
|
||||
*/
|
||||
export async function runTurnAgent({
|
||||
tenantId,
|
||||
chat_id,
|
||||
text,
|
||||
prev_state,
|
||||
prev_context,
|
||||
conversation_history,
|
||||
}) {
|
||||
const t0 = Date.now();
|
||||
const audit = {
|
||||
trace: { tenantId, chat_id, text_preview: String(text || "").slice(0, 50), prev_state, engine: "agent" },
|
||||
tool_calls: [],
|
||||
llm_calls: 0,
|
||||
};
|
||||
|
||||
// Cargar order, store, last_shown_options, customer_profile
|
||||
const order = migrateOldContext(prev_context);
|
||||
const storeConfig = await getStoreConfig({ tenantId });
|
||||
const lastShownOptions = Array.isArray(prev_context?.last_shown_options)
|
||||
? prev_context.last_shown_options
|
||||
: [];
|
||||
const customerProfile = await getCustomerProfile({ tenantId, chat_id }).catch((err) => {
|
||||
audit.customer_profile_error = String(err?.message || err);
|
||||
return null;
|
||||
});
|
||||
|
||||
// Construir working memory
|
||||
const wm = buildWorkingMemory({
|
||||
text,
|
||||
order,
|
||||
prev_state: prev_state || "IDLE",
|
||||
conversation_history,
|
||||
storeConfig,
|
||||
customerProfile,
|
||||
lastShownOptions,
|
||||
});
|
||||
|
||||
// Estado mutable que los tools mutan
|
||||
const ctx = {
|
||||
tenantId,
|
||||
chat_id,
|
||||
order: { ...order, last_shown_options: lastShownOptions },
|
||||
pending_actions: [],
|
||||
last_shown_options: [...lastShownOptions],
|
||||
storeConfig,
|
||||
say_text: null,
|
||||
paused: false,
|
||||
paused_until: order.paused_until ?? null,
|
||||
awaiting_human: false,
|
||||
awaiting_human_reason: null,
|
||||
fsm_state: prev_state || "IDLE",
|
||||
};
|
||||
|
||||
// Mensajes para el LLM
|
||||
const systemPrompt = buildSystemPrompt({ storeName: storeConfig?.name });
|
||||
const messages = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: JSON.stringify({ working_memory: wm }) },
|
||||
];
|
||||
|
||||
// Loop tool-calling
|
||||
const client = getClient();
|
||||
const model = getModel();
|
||||
let turnDone = false;
|
||||
let llmError = null;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < MAX_TOOL_CALLS && !turnDone; i++) {
|
||||
audit.llm_calls++;
|
||||
const elapsed = Date.now() - t0;
|
||||
const remaining = Math.max(2000, TURN_TIMEOUT_MS - elapsed);
|
||||
|
||||
if (dbg.llm) console.log("[agent] llm.request", { model, iteration: i, remaining_ms: remaining });
|
||||
|
||||
const resp = await withTimeout(
|
||||
client.chat.completions.create({
|
||||
model,
|
||||
temperature: 0.4,
|
||||
max_tokens: 600,
|
||||
tools: TOOL_SCHEMAS,
|
||||
tool_choice: "required",
|
||||
messages,
|
||||
}),
|
||||
remaining,
|
||||
"agent_llm"
|
||||
);
|
||||
|
||||
const msg = resp?.choices?.[0]?.message || {};
|
||||
messages.push({ role: "assistant", content: msg.content || "", tool_calls: msg.tool_calls || [] });
|
||||
|
||||
const calls = msg.tool_calls || [];
|
||||
if (!calls.length) {
|
||||
// Sin tool calls → forzar say con fallback y salir
|
||||
audit.no_tool_calls = true;
|
||||
ctx.say_text = ctx.say_text || msg.content || "Disculpame, no te entendí. ¿Me lo decís de otra forma?";
|
||||
turnDone = true;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const call of calls) {
|
||||
const obs = await executeToolCall(call, ctx);
|
||||
audit.tool_calls.push({
|
||||
name: call.function?.name,
|
||||
ok: obs.ok !== false,
|
||||
error: obs.error || null,
|
||||
duration_ms: obs.duration_ms || null,
|
||||
});
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(obs),
|
||||
});
|
||||
if (obs.terminal) {
|
||||
turnDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
llmError = String(err?.message || err);
|
||||
audit.llm_error = llmError;
|
||||
if (dbg.llm) console.error("[agent] error", llmError);
|
||||
}
|
||||
|
||||
// Si no hay say, fallback determinista
|
||||
if (!ctx.say_text) {
|
||||
ctx.say_text = pickFallbackReply(ctx, llmError);
|
||||
audit.fallback_used = true;
|
||||
}
|
||||
|
||||
audit.duration_ms = Date.now() - t0;
|
||||
|
||||
// Actualizar métricas
|
||||
_metrics.turns++;
|
||||
_metrics.total_tool_calls += audit.tool_calls.length;
|
||||
_metrics.total_llm_calls += audit.llm_calls;
|
||||
_metrics.total_duration_ms += audit.duration_ms;
|
||||
if (audit.fallback_used) _metrics.fallback_used++;
|
||||
if (audit.llm_error) _metrics.llm_errors++;
|
||||
if (ctx.awaiting_human) _metrics.escalations++;
|
||||
if (ctx.paused) _metrics.pauses++;
|
||||
if (ctx.pending_actions.some((a) => a.type === "create_order")) _metrics.orders_confirmed++;
|
||||
|
||||
// Derivar nextState desde el order resultante
|
||||
const signals = {
|
||||
confirm_order: ctx.pending_actions.some((a) => a.type === "create_order"),
|
||||
shipping_completed: ctx.pending_actions.some((a) => a.type === "create_order"),
|
||||
return_to_cart: false,
|
||||
};
|
||||
// Si el agente pausó la conversación, mantenemos el order pero el next_state
|
||||
// queda guardado en ctx.fsm_state ("PAUSED") para que pipeline lo persista.
|
||||
let nextState;
|
||||
if (ctx.awaiting_human) {
|
||||
nextState = ConversationState.AWAITING_HUMAN;
|
||||
} else if (ctx.paused) {
|
||||
nextState = "PAUSED"; // estado nuevo, fsm.js lo va a permitir tras D7
|
||||
} else {
|
||||
nextState = safeNextState(prev_state, ctx.order, signals).next_state;
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: ctx.say_text,
|
||||
next_state: nextState,
|
||||
intent: detectIntent(audit.tool_calls),
|
||||
missing_fields: [],
|
||||
order_action: ctx.pending_actions[0]?.type || "none",
|
||||
basket_resolved: { items: (ctx.order.cart || []).map(toBasketItem) },
|
||||
},
|
||||
decision: {
|
||||
actions: ctx.pending_actions,
|
||||
context_patch: buildContextPatch(ctx),
|
||||
audit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pickFallbackReply(ctx, err) {
|
||||
if (ctx.awaiting_human) return "Te paso con un humano que pueda ayudarte.";
|
||||
if (ctx.paused) return "Dale, cuando quieras seguimos.";
|
||||
if (err) return "Disculpame, tuve un problema. ¿Lo intentás de nuevo?";
|
||||
return "No te seguí, ¿me lo decís de otra forma?";
|
||||
}
|
||||
|
||||
function detectIntent(toolCalls = []) {
|
||||
const names = toolCalls.map((c) => c.name);
|
||||
if (names.includes("confirm_order")) return "confirm_order";
|
||||
if (names.includes("set_address") || names.includes("set_shipping")) return "select_shipping";
|
||||
if (names.includes("escalate_to_human")) return "escalate";
|
||||
if (names.includes("pause")) return "pause";
|
||||
if (names.includes("add_to_cart") || names.includes("set_quantity") || names.includes("select_candidate")) return "add_to_cart";
|
||||
if (names.includes("remove_from_cart")) return "remove_from_cart";
|
||||
if (names.includes("search_catalog")) return "browse";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function toBasketItem(item) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContextPatch(ctx) {
|
||||
const order = ctx.order || createEmptyOrder();
|
||||
return {
|
||||
order,
|
||||
order_basket: { items: (order.cart || []).map(toBasketItem) },
|
||||
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,
|
||||
last_shown_options: ctx.last_shown_options || [],
|
||||
paused_until: ctx.paused_until || null,
|
||||
awaiting_human: ctx.awaiting_human || false,
|
||||
awaiting_human_reason: ctx.awaiting_human_reason || null,
|
||||
};
|
||||
}
|
||||
76
src/modules/3-turn-engine/agent/systemPrompt.js
Normal file
76
src/modules/3-turn-engine/agent/systemPrompt.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* System prompt del agente conversacional.
|
||||
*
|
||||
* Se mantiene estático para aprovechar prompt caching. La parte dinámica
|
||||
* (cart, pending, store, history, preparsed) va en el primer user message
|
||||
* como JSON estructurado.
|
||||
*/
|
||||
|
||||
export function buildSystemPrompt({ storeName = "la carnicería" } = {}) {
|
||||
return `Sos Botino, el empleado virtual de ${storeName} (carnicería argentina).
|
||||
Hablás como vendedor: cálido, breve, "vos", sin emojis, sin marketing.
|
||||
|
||||
TU TRABAJO ES UNO SOLO: tomar pedidos por WhatsApp.
|
||||
1. Entendés lo que pide el cliente y lo anotás en el carrito.
|
||||
2. Coordinás envío (delivery o retiro) y dirección si corresponde.
|
||||
3. Cerrás el pedido. NO cobrás — el pago lo coordina el comercio aparte.
|
||||
|
||||
REGLAS DURAS:
|
||||
- NUNCA inventes productos ni precios. Para CUALQUIER producto que el cliente
|
||||
mencione, llamá primero a search_catalog. Si la lista viene vacía, decilo.
|
||||
- NUNCA pidas datos que ya están en el contexto (cart, dirección, método).
|
||||
- NO ofrezcas promociones, métodos de pago, ni info que no esté en store.
|
||||
- Si el cliente mezcla pedido + duda + cambio de tema, resolvé con tools y
|
||||
aclarás lo que falte en say.
|
||||
- Si te pide algo fuera de tomar pedidos (queja, cambio, factura, otro idioma),
|
||||
llamá escalate_to_human.
|
||||
|
||||
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory):
|
||||
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until.
|
||||
- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
|
||||
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
|
||||
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé
|
||||
contra last_shown_options. Si está vacío, pedí que aclare.
|
||||
- Si user da SOLO una cantidad ("300 gramos", "media docena") y hay un pending
|
||||
con status=NEEDS_QUANTITY, asumí que es para ese producto y llamá set_quantity.
|
||||
- Si user dice "lo de siempre" / "lo mismo de la otra vez", mirá
|
||||
customer_profile.frequent_items. Si hay 1-2 items, ofrecelos con
|
||||
search_catalog para confirmar y un say preguntando si va eso.
|
||||
- Si menciona producto genérico ("asado") y el catálogo tiene varios,
|
||||
mostrá top 3-5 con say (numerados). El sistema guarda last_shown_options
|
||||
automáticamente.
|
||||
- Si dice "después te digo" / "más tarde" / "ahora no puedo", llamá pause
|
||||
con reason="user_paused" y un say corto tipo "Dale, cuando quieras seguimos".
|
||||
|
||||
CÓMO ESCRIBÍS EL say:
|
||||
- 1-2 oraciones máximo. Concreto. Sin emojis.
|
||||
- Confirmá lo anotado con cantidad y producto: "Va, anoté 500g de Vacío.
|
||||
¿Algo más?"
|
||||
- Cuando preguntás cantidad, mencioná el producto: "¿Cuántas botellas de
|
||||
Chimichurri querés?" — NUNCA "¿cuántas?" pelado.
|
||||
- Si no encontraste el producto, sugerí 1-2 alternativas concretas que
|
||||
vinieron del catálogo: "No tengo Chinchulín, pero tengo Mollejas y
|
||||
Riñones. ¿Te sirve alguno?"
|
||||
- Cuando cerrás el pedido, listá items y pasás a shipping.
|
||||
|
||||
ORDEN DE TOOLS EN UN TURNO TÍPICO:
|
||||
1. (opcional) search_catalog si hay producto sin resolver. Llamala UNA VEZ por
|
||||
producto distinto. NO la llames de nuevo con la misma query si ya devolvió
|
||||
resultados — usá los que tenés.
|
||||
2. Si search_catalog devolvió 1 candidato fuerte → add_to_cart con qty/unit.
|
||||
3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que
|
||||
elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
|
||||
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping /
|
||||
set_address / confirm_order / remove_from_cart / pause / escalate_to_human.
|
||||
5. say SIEMPRE como último tool del turno. Sin say no hay respuesta.
|
||||
|
||||
LIMITES TÉCNICOS:
|
||||
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
|
||||
redundantes — si una query ya devolvió X candidatos, NO la repitas.
|
||||
- Si después de 2 búsquedas no encontrás nada útil, llamá say pidiendo que el
|
||||
cliente reformule ("no te encontré X, ¿lo decís de otra forma?").
|
||||
|
||||
LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES):
|
||||
- Lo que diga el bloque store del working_memory.
|
||||
- Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos.`;
|
||||
}
|
||||
66
src/modules/3-turn-engine/agent/tools/addToCart.js
Normal file
66
src/modules/3-turn-engine/agent/tools/addToCart.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* add_to_cart — agrega un producto resuelto al carrito.
|
||||
*
|
||||
* Valida woo_id contra el snapshot (anti-halucinación). Si no existe,
|
||||
* devuelve error obligando re-search.
|
||||
*/
|
||||
|
||||
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||
import { createCartItem } from "../../orderModel.js";
|
||||
|
||||
export async function addToCartTool(args, ctx) {
|
||||
const { woo_id, qty, unit } = args;
|
||||
|
||||
// Validar que el producto existe en el snapshot
|
||||
const lookup = await getSnapshotItemsByIds({
|
||||
tenantId: ctx.tenantId,
|
||||
wooProductIds: [woo_id],
|
||||
});
|
||||
const found = (lookup?.items || [])[0];
|
||||
if (!found) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "woo_id_unknown",
|
||||
hint: "Volvé a llamar search_catalog para obtener un woo_id válido.",
|
||||
};
|
||||
}
|
||||
|
||||
// Crear item de carrito
|
||||
const newItem = createCartItem({
|
||||
woo_id,
|
||||
qty,
|
||||
unit,
|
||||
name: found.name,
|
||||
price: found.price ?? null,
|
||||
});
|
||||
|
||||
// Si ya existe el woo_id en el cart, sumamos cantidad
|
||||
const cart = ctx.order.cart || [];
|
||||
const existingIdx = cart.findIndex((c) => Number(c.woo_id) === Number(woo_id));
|
||||
let nextCart;
|
||||
if (existingIdx >= 0) {
|
||||
nextCart = cart.map((c, i) =>
|
||||
i === existingIdx ? { ...c, qty: (c.qty || 0) + qty, unit: c.unit || unit } : c
|
||||
);
|
||||
} else {
|
||||
nextCart = [...cart, newItem];
|
||||
}
|
||||
ctx.order = { ...ctx.order, cart: nextCart };
|
||||
|
||||
// Enqueue add_to_cart action para SSE/UI (reutiliza shape existente)
|
||||
ctx.pending_actions.push({ type: "add_to_cart", payload: newItem });
|
||||
|
||||
// Reset failed_searches si hubiera
|
||||
if (ctx.order.failed_searches) {
|
||||
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
|
||||
}
|
||||
|
||||
// Limpiar last_shown_options — ya resolvió la elección
|
||||
ctx.last_shown_options = [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cart_size: nextCart.length,
|
||||
added: { woo_id, name: found.name, qty, unit },
|
||||
};
|
||||
}
|
||||
32
src/modules/3-turn-engine/agent/tools/confirmOrder.js
Normal file
32
src/modules/3-turn-engine/agent/tools/confirmOrder.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* confirm_order — emite create_order si hay cart + shipping completo.
|
||||
*/
|
||||
|
||||
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
|
||||
|
||||
export async function confirmOrderTool(_args, ctx) {
|
||||
if (!hasCartItems(ctx.order)) {
|
||||
return { ok: false, error: "empty_cart", hint: "Pedile al cliente que agregue productos antes de confirmar." };
|
||||
}
|
||||
if (!hasShippingInfo(ctx.order)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "shipping_missing",
|
||||
hint:
|
||||
ctx.order.is_delivery == null
|
||||
? "Falta saber si es delivery o pickup. Llamá set_shipping."
|
||||
: "Falta dirección. Llamá set_address.",
|
||||
};
|
||||
}
|
||||
// Idempotencia: si ya existe create_order encolado, no duplicar
|
||||
const already = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||
if (!already) {
|
||||
ctx.pending_actions.push({ type: "create_order", payload: { source: "wa_bot" } });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
is_delivery: !!ctx.order.is_delivery,
|
||||
address: ctx.order.shipping_address || null,
|
||||
};
|
||||
}
|
||||
16
src/modules/3-turn-engine/agent/tools/escalateToHuman.js
Normal file
16
src/modules/3-turn-engine/agent/tools/escalateToHuman.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* escalate_to_human — pasa la conversación a awaiting_human.
|
||||
* El registro real en human_takeovers se crea downstream en pipeline.js
|
||||
* vía la action "request_human_takeover".
|
||||
*/
|
||||
|
||||
export async function escalateToHumanTool(args, ctx) {
|
||||
const { reason } = args;
|
||||
ctx.awaiting_human = true;
|
||||
ctx.awaiting_human_reason = String(reason || "unspecified");
|
||||
ctx.pending_actions.push({
|
||||
type: "request_human_takeover",
|
||||
payload: { reason: ctx.awaiting_human_reason, source: "agent" },
|
||||
});
|
||||
return { ok: true, reason: ctx.awaiting_human_reason };
|
||||
}
|
||||
102
src/modules/3-turn-engine/agent/tools/executor.js
Normal file
102
src/modules/3-turn-engine/agent/tools/executor.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Executor: parsea + valida + ejecuta tool calls del agente.
|
||||
* Devuelve `obs` (objeto serializable) que se pushea como `tool` message.
|
||||
*
|
||||
* Convenciones:
|
||||
* - obs.ok: true|false
|
||||
* - obs.error: string si !ok
|
||||
* - obs.terminal: true si esta tool finaliza el turno (say, pause, escalate)
|
||||
*/
|
||||
|
||||
import Ajv from "ajv";
|
||||
import { TOOL_SCHEMAS } from "./schemas.js";
|
||||
import { searchCatalogTool } from "./searchCatalog.js";
|
||||
import { addToCartTool } from "./addToCart.js";
|
||||
import { setQuantityTool } from "./setQuantity.js";
|
||||
import { selectCandidateTool } from "./selectCandidate.js";
|
||||
import { removeFromCartTool } from "./removeFromCart.js";
|
||||
import { setShippingTool } from "./setShipping.js";
|
||||
import { setAddressTool } from "./setAddress.js";
|
||||
import { confirmOrderTool } from "./confirmOrder.js";
|
||||
import { pauseTool } from "./pause.js";
|
||||
import { escalateToHumanTool } from "./escalateToHuman.js";
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
|
||||
// Compilar validators una vez
|
||||
const VALIDATORS = {};
|
||||
for (const t of TOOL_SCHEMAS) {
|
||||
VALIDATORS[t.function.name] = ajv.compile(t.function.parameters);
|
||||
}
|
||||
|
||||
const TOOLS = {
|
||||
search_catalog: searchCatalogTool,
|
||||
add_to_cart: addToCartTool,
|
||||
set_quantity: setQuantityTool,
|
||||
select_candidate: selectCandidateTool,
|
||||
remove_from_cart: removeFromCartTool,
|
||||
set_shipping: setShippingTool,
|
||||
set_address: setAddressTool,
|
||||
confirm_order: confirmOrderTool,
|
||||
pause: pauseTool,
|
||||
escalate_to_human: escalateToHumanTool,
|
||||
// `say` se maneja inline (asigna ctx.say_text y termina el turno)
|
||||
};
|
||||
|
||||
export async function executeToolCall(call, ctx) {
|
||||
const t0 = Date.now();
|
||||
const name = call?.function?.name;
|
||||
const argsRaw = call?.function?.arguments || "{}";
|
||||
|
||||
let args;
|
||||
try {
|
||||
args = typeof argsRaw === "string" ? JSON.parse(argsRaw) : argsRaw;
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid_json_args: ${String(e?.message || e)}`,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
// `say` es especial: termina el turno
|
||||
if (name === "say") {
|
||||
const validator = VALIDATORS.say;
|
||||
if (!validator(args)) {
|
||||
return { ok: false, error: "say_args_invalid", details: validator.errors, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
ctx.say_text = args.text;
|
||||
return { ok: true, terminal: true, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
const validator = VALIDATORS[name];
|
||||
if (!validator) {
|
||||
return { ok: false, error: `unknown_tool: ${name}`, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
if (!validator(args)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "args_schema_invalid",
|
||||
details: validator.errors,
|
||||
tool: name,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
const handler = TOOLS[name];
|
||||
if (!handler) {
|
||||
return { ok: false, error: `tool_not_implemented: ${name}`, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args, ctx);
|
||||
return { ...result, duration_ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `tool_error: ${String(err?.message || err)}`,
|
||||
tool: name,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/modules/3-turn-engine/agent/tools/pause.js
Normal file
14
src/modules/3-turn-engine/agent/tools/pause.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* pause — marca la conversación como pausada (TTL 7d).
|
||||
*
|
||||
* El cart NO se limpia. Cuando el cliente vuelva, sale de paused y sigue.
|
||||
*/
|
||||
|
||||
const PAUSE_TTL_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
export async function pauseTool(args, ctx) {
|
||||
const { reason = "user_paused" } = args;
|
||||
ctx.paused = true;
|
||||
ctx.paused_until = new Date(Date.now() + PAUSE_TTL_MS).toISOString();
|
||||
return { ok: true, paused_until: ctx.paused_until, reason, terminal: false };
|
||||
}
|
||||
43
src/modules/3-turn-engine/agent/tools/removeFromCart.js
Normal file
43
src/modules/3-turn-engine/agent/tools/removeFromCart.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* remove_from_cart — quita un producto por woo_id (string numérico) o por
|
||||
* substring del nombre.
|
||||
*/
|
||||
|
||||
import { removeCartItem } from "../../orderModel.js";
|
||||
|
||||
export async function removeFromCartTool(args, ctx) {
|
||||
const { target } = args;
|
||||
const t = String(target || "").trim();
|
||||
if (!t) return { ok: false, error: "empty_target" };
|
||||
|
||||
// Si es un número puro, intentar match por woo_id directo
|
||||
const asNumber = /^\d+$/.test(t) ? Number(t) : null;
|
||||
let removed = null;
|
||||
let nextOrder = ctx.order;
|
||||
|
||||
if (asNumber != null) {
|
||||
const cart = ctx.order.cart || [];
|
||||
const idx = cart.findIndex((c) => Number(c.woo_id) === asNumber);
|
||||
if (idx >= 0) {
|
||||
removed = cart[idx];
|
||||
nextOrder = { ...ctx.order, cart: cart.filter((_, i) => i !== idx) };
|
||||
}
|
||||
}
|
||||
|
||||
// Match por nombre
|
||||
if (!removed) {
|
||||
const result = removeCartItem(ctx.order, t);
|
||||
if (result?.removed) {
|
||||
removed = result.removed;
|
||||
nextOrder = result.order;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
return { ok: false, error: "not_found_in_cart", target: t };
|
||||
}
|
||||
|
||||
ctx.order = nextOrder;
|
||||
ctx.pending_actions.push({ type: "remove_from_cart", payload: { removed } });
|
||||
return { ok: true, removed: { woo_id: removed.woo_id, name: removed.name }, cart_size: (nextOrder.cart || []).length };
|
||||
}
|
||||
176
src/modules/3-turn-engine/agent/tools/schemas.js
Normal file
176
src/modules/3-turn-engine/agent/tools/schemas.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* JSON Schemas de los tools que el agente puede invocar. Formato
|
||||
* OpenAI/DeepSeek (function calling).
|
||||
*
|
||||
* El executor valida los args con Ajv y devuelve error obligando re-llamada
|
||||
* si falla.
|
||||
*/
|
||||
|
||||
export const TOOL_SCHEMAS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_catalog",
|
||||
description:
|
||||
"Busca productos en el catálogo. Devuelve top candidatos con woo_id, nombre, precio, unidad de venta. Llamala SIEMPRE antes de cualquier add_to_cart si no tenés el woo_id.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["query"],
|
||||
properties: {
|
||||
query: { type: "string", minLength: 2, description: "Término de búsqueda libre, lo que dijo el cliente." },
|
||||
hint_category: { type: "string", description: "Categoría heurística para fallback (ej. 'parrilla', 'embutidos')." },
|
||||
limit: { type: "integer", minimum: 1, maximum: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "add_to_cart",
|
||||
description: "Agrega un producto resuelto al carrito.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["woo_id", "qty", "unit"],
|
||||
properties: {
|
||||
woo_id: { type: "integer", description: "Woo product ID exacto del producto." },
|
||||
qty: { type: "number", exclusiveMinimum: 0 },
|
||||
unit: { type: "string", enum: ["kg", "g", "unit"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_quantity",
|
||||
description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["pending_id", "qty", "unit"],
|
||||
properties: {
|
||||
pending_id: { type: "string" },
|
||||
qty: { type: "number", exclusiveMinimum: 0 },
|
||||
unit: { type: "string", enum: ["kg", "g", "unit"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "select_candidate",
|
||||
description: "Resuelve un pending NEEDS_TYPE eligiendo uno de los candidatos mostrados (last_shown_options).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["pending_id", "woo_id"],
|
||||
properties: {
|
||||
pending_id: { type: "string" },
|
||||
woo_id: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "remove_from_cart",
|
||||
description: "Quita un producto del carrito por woo_id o nombre/query.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["target"],
|
||||
properties: {
|
||||
target: { type: "string", description: "woo_id como string o nombre del producto a quitar." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_shipping",
|
||||
description: "Setea el método de envío (delivery o pickup).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["method"],
|
||||
properties: {
|
||||
method: { type: "string", enum: ["delivery", "pickup"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_address",
|
||||
description: "Setea la dirección de entrega y valida zona. Si está fuera de zona devuelve error.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string", minLength: 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "confirm_order",
|
||||
description: "Confirma el pedido. Requiere cart no vacío y shipping completo (pickup o delivery+address). Emite acción create_order.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "pause",
|
||||
description: "Pausa la conversación cuando el cliente dice 'después te digo' o equivalente. Mantiene el cart 7 días.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["reason"],
|
||||
properties: {
|
||||
reason: { type: "string", enum: ["user_paused", "user_busy", "needs_to_check"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "escalate_to_human",
|
||||
description: "Escala a un humano (quejas, dudas de pago/factura, urgencias, no podemos resolver).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["reason"],
|
||||
properties: {
|
||||
reason: { type: "string", minLength: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "say",
|
||||
description: "Texto final que se envía al cliente. ÚLTIMO tool del turno SIEMPRE.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string", minLength: 1, maxLength: 600 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
106
src/modules/3-turn-engine/agent/tools/searchCatalog.js
Normal file
106
src/modules/3-turn-engine/agent/tools/searchCatalog.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* search_catalog tool — wrappea retrieveCandidates con fallback por categoría.
|
||||
*
|
||||
* Side effects: muta `ctx.last_shown_options` con el top de candidatos para
|
||||
* que select_candidate pueda resolver "el segundo" en turnos posteriores.
|
||||
*/
|
||||
|
||||
import { retrieveCandidates } from "../../catalogRetrieval.js";
|
||||
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
|
||||
import { pool } from "../../../shared/db/pool.js";
|
||||
|
||||
const MIN_GOOD_SCORE = 0.4;
|
||||
|
||||
function summarizeCandidate(c, idx) {
|
||||
return {
|
||||
index: idx + 1,
|
||||
woo_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
sell_unit: c.sell_unit || null,
|
||||
score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchCatalogTool(args, ctx) {
|
||||
const { query, hint_category = null, limit = 5 } = args;
|
||||
|
||||
// 1) Búsqueda directa con catalogRetrieval (pg_trgm + alias + snapshot)
|
||||
const result = await retrieveCandidates({
|
||||
tenantId: ctx.tenantId,
|
||||
query,
|
||||
limit: Math.max(limit, 5),
|
||||
});
|
||||
let candidates = result?.candidates || [];
|
||||
|
||||
// 2) Fallback por categoría si la búsqueda directa rinde poco
|
||||
let usedFallback = null;
|
||||
if (
|
||||
(candidates.length === 0 ||
|
||||
(candidates[0]?._score || 0) < MIN_GOOD_SCORE) &&
|
||||
hint_category
|
||||
) {
|
||||
const byCategory = await searchByCategory({
|
||||
tenantId: ctx.tenantId,
|
||||
category: hint_category,
|
||||
limit,
|
||||
});
|
||||
if (byCategory.length > 0) {
|
||||
usedFallback = "category";
|
||||
candidates = byCategory;
|
||||
}
|
||||
}
|
||||
|
||||
// Recortar al limit final
|
||||
const top = candidates.slice(0, limit).map(summarizeCandidate);
|
||||
|
||||
// Guardar last_shown_options si hay >1 (para select_candidate posterior)
|
||||
if (top.length > 1) {
|
||||
ctx.last_shown_options = top.map((t) => ({
|
||||
index: t.index,
|
||||
woo_id: t.woo_id,
|
||||
name: t.name,
|
||||
price: t.price,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
query,
|
||||
candidates: top,
|
||||
used_fallback: usedFallback,
|
||||
count: top.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchByCategory({ tenantId, category, limit }) {
|
||||
// Buscar productos cuya categories JSONB contenga el name/slug indicado.
|
||||
// Sin nuevas tablas — explota lo que ya está en woo_products_snapshot.categories.
|
||||
const sql = `
|
||||
SELECT woo_product_id, name, sku, slug, price, stock_status, stock_qty,
|
||||
categories, sell_unit, payload
|
||||
FROM woo_products_snapshot
|
||||
WHERE tenant_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(categories) cat
|
||||
WHERE LOWER(cat) LIKE '%' || LOWER($2) || '%'
|
||||
)
|
||||
AND COALESCE(stock_status, 'instock') != 'outofstock'
|
||||
ORDER BY name ASC
|
||||
LIMIT $3
|
||||
`;
|
||||
try {
|
||||
const { rows } = await pool.query(sql, [tenantId, category, limit]);
|
||||
return rows.map((r) => ({
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.name,
|
||||
price: r.price,
|
||||
sell_unit: r.sell_unit,
|
||||
_score: 0.5, // score sintético para que pase MIN_GOOD_SCORE en el caller
|
||||
}));
|
||||
} catch (err) {
|
||||
// Fallback: snapshot search por texto plano
|
||||
const r = await searchSnapshotItems({ tenantId, q: category, limit });
|
||||
return r?.items || [];
|
||||
}
|
||||
}
|
||||
72
src/modules/3-turn-engine/agent/tools/selectCandidate.js
Normal file
72
src/modules/3-turn-engine/agent/tools/selectCandidate.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* select_candidate — resuelve un pending NEEDS_TYPE eligiendo woo_id.
|
||||
*
|
||||
* Si el pending tenía `requested_qty` ya, lo promueve directo a READY → cart.
|
||||
* Sino lo deja en NEEDS_QUANTITY esperando set_quantity.
|
||||
*/
|
||||
|
||||
import {
|
||||
updatePendingItem,
|
||||
moveReadyToCart,
|
||||
PendingStatus,
|
||||
} from "../../orderModel.js";
|
||||
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||
|
||||
export async function selectCandidateTool(args, ctx) {
|
||||
const { pending_id, woo_id } = args;
|
||||
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
|
||||
if (!pending) {
|
||||
return { ok: false, error: "pending_not_found" };
|
||||
}
|
||||
|
||||
// Buscar el producto seleccionado en el snapshot para nombre/unit/precio
|
||||
const lookup = await getSnapshotItemsByIds({
|
||||
tenantId: ctx.tenantId,
|
||||
wooProductIds: [woo_id],
|
||||
});
|
||||
const found = (lookup?.items || [])[0];
|
||||
if (!found) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "woo_id_unknown",
|
||||
hint: "El woo_id no existe en el catálogo. Probá con search_catalog.",
|
||||
};
|
||||
}
|
||||
|
||||
const sellsByWeight =
|
||||
!found.sell_unit || !["unit", "unidad"].includes(found.sell_unit);
|
||||
const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit";
|
||||
|
||||
const hasRequestedQty =
|
||||
pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0;
|
||||
const finalQty = hasRequestedQty ? pending.requested_qty : null;
|
||||
const finalUnit = pending.requested_unit || displayUnit;
|
||||
const needsQty = sellsByWeight && !hasRequestedQty;
|
||||
|
||||
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||
selected_woo_id: woo_id,
|
||||
selected_name: found.name,
|
||||
selected_price: found.price ?? null,
|
||||
selected_unit: displayUnit,
|
||||
candidates: [],
|
||||
qty: needsQty ? null : finalQty,
|
||||
unit: finalUnit,
|
||||
status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
});
|
||||
ctx.order = moveReadyToCart(updated);
|
||||
|
||||
if (!needsQty) {
|
||||
ctx.pending_actions.push({
|
||||
type: "add_to_cart",
|
||||
payload: { woo_id, qty: finalQty, unit: finalUnit },
|
||||
});
|
||||
ctx.last_shown_options = [];
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight },
|
||||
needs_quantity: needsQty,
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
};
|
||||
}
|
||||
36
src/modules/3-turn-engine/agent/tools/setAddress.js
Normal file
36
src/modules/3-turn-engine/agent/tools/setAddress.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* set_address — fija dirección y valida zona.
|
||||
*/
|
||||
|
||||
import { checkAddressInZone } from "../../storeContext.js";
|
||||
|
||||
export async function setAddressTool(args, ctx) {
|
||||
const { text } = args;
|
||||
const address = String(text || "").trim();
|
||||
if (address.length < 5) return { ok: false, error: "address_too_short" };
|
||||
|
||||
// Validar zona si hay zonas cargadas
|
||||
const zoneCheck = checkAddressInZone({
|
||||
address,
|
||||
storeConfig: ctx.storeConfig,
|
||||
});
|
||||
if (!zoneCheck.inZone) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "out_of_zone",
|
||||
reason: zoneCheck.reason,
|
||||
available_zones: zoneCheck.zones,
|
||||
hint: "Pedile al cliente otra dirección o ofrecele pickup.",
|
||||
};
|
||||
}
|
||||
|
||||
// Si no había is_delivery seteado, asumir delivery=true (le dieron dirección)
|
||||
const is_delivery = ctx.order.is_delivery == null ? true : ctx.order.is_delivery;
|
||||
ctx.order = { ...ctx.order, shipping_address: address, is_delivery };
|
||||
return {
|
||||
ok: true,
|
||||
address,
|
||||
in_zone: true,
|
||||
matched_zone: zoneCheck.matched || null,
|
||||
};
|
||||
}
|
||||
36
src/modules/3-turn-engine/agent/tools/setQuantity.js
Normal file
36
src/modules/3-turn-engine/agent/tools/setQuantity.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* set_quantity — completa la cantidad de un pending NEEDS_QUANTITY y lo
|
||||
* promueve a READY → cart.
|
||||
*/
|
||||
|
||||
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
|
||||
|
||||
export async function setQuantityTool(args, ctx) {
|
||||
const { pending_id, qty, unit } = args;
|
||||
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
|
||||
if (!pending) {
|
||||
return { ok: false, error: "pending_not_found", hint: "Verificá el pending_id contra working_memory.order.pending." };
|
||||
}
|
||||
if (!pending.selected_woo_id) {
|
||||
return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." };
|
||||
}
|
||||
|
||||
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||
qty,
|
||||
unit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
ctx.order = moveReadyToCart(updated);
|
||||
|
||||
ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id: pending.selected_woo_id, qty, unit } });
|
||||
ctx.last_shown_options = [];
|
||||
if (ctx.order.failed_searches) {
|
||||
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
promoted: { name: pending.selected_name, qty, unit },
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
};
|
||||
}
|
||||
12
src/modules/3-turn-engine/agent/tools/setShipping.js
Normal file
12
src/modules/3-turn-engine/agent/tools/setShipping.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* set_shipping — fija el método de envío.
|
||||
*/
|
||||
|
||||
export async function setShippingTool(args, ctx) {
|
||||
const { method } = args;
|
||||
if (method !== "delivery" && method !== "pickup") {
|
||||
return { ok: false, error: "invalid_method" };
|
||||
}
|
||||
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
|
||||
return { ok: true, method, requires_address: method === "delivery" && !ctx.order.shipping_address };
|
||||
}
|
||||
118
src/modules/3-turn-engine/agent/workingMemory.js
Normal file
118
src/modules/3-turn-engine/agent/workingMemory.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* WorkingMemory — payload contextual que recibe el agente cada turno.
|
||||
*
|
||||
* Se serializa como JSON y se inyecta en el primer USER message. NO va en
|
||||
* system (eso permite cachear el system prompt entre turnos).
|
||||
*
|
||||
* Reglas de poda:
|
||||
* - history: últimos 8 mensajes, content truncado a 200 chars.
|
||||
* - customer_profile: top 5 frequent_items.
|
||||
* - last_shown_options: top 8.
|
||||
* - cart/pending: enteros, sin truncar.
|
||||
*/
|
||||
|
||||
import { parseQuantity } from "./quantityParser.js";
|
||||
import { buildStoreContextVars } from "../storeContext.js";
|
||||
|
||||
const HISTORY_MAX = 8;
|
||||
const HISTORY_CHAR_CAP = 200;
|
||||
const LAST_SHOWN_MAX = 8;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (s == null) return "";
|
||||
const str = String(s);
|
||||
if (str.length <= n) return str;
|
||||
return str.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {string} params.text - Mensaje crudo del usuario en este turno.
|
||||
* @param {Object} params.order - order del context (cart, pending, etc.)
|
||||
* @param {string} params.prev_state - estado FSM previo
|
||||
* @param {Array} params.conversation_history
|
||||
* @param {Object} params.storeConfig - resultado de getStoreConfig
|
||||
* @param {Object} params.customerProfile - perfil del cliente (puede ser null)
|
||||
* @param {Array} params.lastShownOptions - opciones del turno previo
|
||||
*/
|
||||
export function buildWorkingMemory({
|
||||
text,
|
||||
order = {},
|
||||
prev_state = "IDLE",
|
||||
conversation_history = [],
|
||||
storeConfig = {},
|
||||
customerProfile = null,
|
||||
lastShownOptions = [],
|
||||
}) {
|
||||
const storeVars = buildStoreContextVars(storeConfig);
|
||||
|
||||
const cart = (order.cart || []).map((it) => ({
|
||||
woo_id: it.woo_id,
|
||||
name: it.name,
|
||||
qty: it.qty,
|
||||
unit: it.unit,
|
||||
price: it.price ?? null,
|
||||
}));
|
||||
|
||||
const pending = (order.pending || []).map((p) => ({
|
||||
id: p.id,
|
||||
query: p.query,
|
||||
status: p.status,
|
||||
selected_woo_id: p.selected_woo_id ?? null,
|
||||
selected_name: p.selected_name ?? null,
|
||||
selected_unit: p.selected_unit ?? null,
|
||||
requested_qty: p.requested_qty ?? null,
|
||||
requested_unit: p.requested_unit ?? null,
|
||||
candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({
|
||||
index: i + 1,
|
||||
woo_id: c.woo_id ?? c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
display_unit: c.display_unit ?? null,
|
||||
})),
|
||||
}));
|
||||
|
||||
const history = (conversation_history || []).slice(-HISTORY_MAX).map((m) => ({
|
||||
role: m.role === "user" ? "user" : "assistant",
|
||||
text: truncate(m.content || m.text || "", HISTORY_CHAR_CAP),
|
||||
}));
|
||||
|
||||
const last_shown_options = (lastShownOptions || []).slice(0, LAST_SHOWN_MAX).map((o, i) => ({
|
||||
index: o.index ?? i + 1,
|
||||
woo_id: o.woo_id,
|
||||
name: o.name,
|
||||
price: o.price ?? null,
|
||||
}));
|
||||
|
||||
const preparsed = parseQuantity(text || "");
|
||||
|
||||
return {
|
||||
now: nowIso(),
|
||||
store: {
|
||||
name: storeVars.store_name || "la carnicería",
|
||||
hours_today: storeVars.store_hours_today || "consultar",
|
||||
delivery: {
|
||||
available_now: storeVars.delivery_available_now || "",
|
||||
zones_summary: storeVars.delivery_zones_summary || "",
|
||||
},
|
||||
},
|
||||
fsm_state: prev_state || "IDLE",
|
||||
order: {
|
||||
cart,
|
||||
pending,
|
||||
is_delivery: order.is_delivery ?? null,
|
||||
shipping_address: order.shipping_address ?? null,
|
||||
woo_order_id: order.woo_order_id ?? null,
|
||||
},
|
||||
last_shown_options,
|
||||
paused_until: order.paused_until ?? null,
|
||||
customer_profile: customerProfile,
|
||||
history,
|
||||
user_message: text || "",
|
||||
preparsed,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export const ConversationState = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
CART: "CART",
|
||||
SHIPPING: "SHIPPING",
|
||||
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
|
||||
PAUSED: "PAUSED", // Cliente dijo "después te digo" - cart preservado, TTL 7d
|
||||
AWAITING_HUMAN: "AWAITING_HUMAN",
|
||||
});
|
||||
|
||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||
@@ -27,6 +28,11 @@ export const INTENTS_BY_STATE = Object.freeze({
|
||||
[ConversationState.SHIPPING]: [
|
||||
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other",
|
||||
],
|
||||
[ConversationState.PAUSED]: [
|
||||
// Cualquier intent del cliente lo reactiva; el agente decide el destino.
|
||||
"greeting", "add_to_cart", "browse", "view_cart", "confirm_order",
|
||||
"select_shipping", "provide_address", "remove_from_cart", "other",
|
||||
],
|
||||
[ConversationState.AWAITING_HUMAN]: [
|
||||
"other",
|
||||
],
|
||||
@@ -142,18 +148,29 @@ const ALLOWED = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
ConversationState.IDLE,
|
||||
ConversationState.CART,
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.CART]: [
|
||||
ConversationState.CART,
|
||||
ConversationState.SHIPPING,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.SHIPPING]: [
|
||||
ConversationState.SHIPPING,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.CART,
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.PAUSED]: [
|
||||
// Cualquier mensaje saca de paused
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.CART,
|
||||
ConversationState.SHIPPING,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.AWAITING_HUMAN]: [
|
||||
|
||||
@@ -17,18 +17,19 @@ import {
|
||||
} from './fsm.js';
|
||||
|
||||
describe('ConversationState', () => {
|
||||
it('tiene los estados del flujo (sin payment/waiting)', () => {
|
||||
it('tiene los estados del flujo (incluye PAUSED)', () => {
|
||||
expect(ConversationState.IDLE).toBe('IDLE');
|
||||
expect(ConversationState.CART).toBe('CART');
|
||||
expect(ConversationState.SHIPPING).toBe('SHIPPING');
|
||||
expect(ConversationState.PAUSED).toBe('PAUSED');
|
||||
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
|
||||
expect(ConversationState.PAYMENT).toBeUndefined();
|
||||
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ALL_STATES contiene 4 estados', () => {
|
||||
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'AWAITING_HUMAN']));
|
||||
expect(ALL_STATES).toHaveLength(4);
|
||||
it('ALL_STATES contiene 5 estados', () => {
|
||||
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'PAUSED', 'AWAITING_HUMAN']));
|
||||
expect(ALL_STATES).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('INTENTS_BY_STATE define intents por estado', () => {
|
||||
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
|
||||
import { pushRecent } from "./replyTemplates.js";
|
||||
import { runTurnXState } from "./machine/runner.js";
|
||||
import { runTurnAgent } from "./agent/runTurn.js";
|
||||
import { insertAuditLog } from "../0-ui/db/repo.js";
|
||||
|
||||
// Feature flag para NLU modular
|
||||
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
|
||||
// Feature flags para XState
|
||||
// Feature flags
|
||||
function useXState() {
|
||||
const v = String(process.env.USE_XSTATE || "").toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes";
|
||||
@@ -32,6 +33,14 @@ function shadowXState() {
|
||||
const v = String(process.env.XSTATE_SHADOW || "").toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes";
|
||||
}
|
||||
function useAgent() {
|
||||
const v = String(process.env.AGENT_TURN_ENGINE || "").toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes";
|
||||
}
|
||||
function shadowAgent() {
|
||||
const v = String(process.env.AGENT_TURN_ENGINE_SHADOW || "").toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compara plan/decision entre legacy y XState para shadow mode.
|
||||
@@ -81,6 +90,11 @@ export async function runTurnV3({
|
||||
prev_context,
|
||||
conversation_history,
|
||||
}) {
|
||||
// Branch: agente tool-calling (AGENT_TURN_ENGINE=1)
|
||||
if (useAgent() && !shadowAgent()) {
|
||||
return runTurnAgent({ 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 });
|
||||
@@ -206,8 +220,7 @@ export async function runTurnV3({
|
||||
|
||||
const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches);
|
||||
|
||||
// Shadow mode: corre XState en paralelo, devuelve legacy, persiste diffs
|
||||
// estructurales en audit_log para revisarlos después.
|
||||
// Shadow mode XState: corre en paralelo, devuelve legacy, loguea diffs.
|
||||
if (shadowXState()) {
|
||||
runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
|
||||
.then(async (xstateResult) => {
|
||||
@@ -229,6 +242,37 @@ export async function runTurnV3({
|
||||
.catch((err) => console.error("[xstate-shadow] error", err?.message || err));
|
||||
}
|
||||
|
||||
// Shadow mode AGENT: corre el agente nuevo en paralelo, devuelve legacy,
|
||||
// loguea diffs estructurales en audit_log para validar paridad antes
|
||||
// de flippar AGENT_TURN_ENGINE=1.
|
||||
if (shadowAgent()) {
|
||||
runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
|
||||
.then(async (agentResult) => {
|
||||
const diffs = diffResults(legacyResult, agentResult);
|
||||
try {
|
||||
await insertAuditLog({
|
||||
tenantId,
|
||||
entityType: "agent_shadow",
|
||||
entityId: chat_id,
|
||||
action: "compare",
|
||||
changes: {
|
||||
diffs,
|
||||
prev_state,
|
||||
text_preview: String(text || "").slice(0, 80),
|
||||
legacy_reply: legacyResult?.plan?.reply?.slice(0, 200),
|
||||
agent_reply: agentResult?.plan?.reply?.slice(0, 200),
|
||||
agent_tools: agentResult?.decision?.audit?.tool_calls?.map((t) => t.name) || [],
|
||||
agent_duration_ms: agentResult?.decision?.audit?.duration_ms,
|
||||
},
|
||||
actor: "system",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[agent-shadow] audit_log failed", err?.message || err);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("[agent-shadow] error", err?.message || err));
|
||||
}
|
||||
|
||||
return legacyResult;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user