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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user