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>
113 lines
3.5 KiB
JavaScript
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);
|
|
}
|