Files
botino/src/modules/3-turn-engine/agent/customerProfile.js
Lucas Tettamanti 03621f16f4 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>
2026-05-02 12:52:47 -03:00

113 lines
3.5 KiB
JavaScript

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