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