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:
Lucas Tettamanti
2026-05-02 12:52:47 -03:00
parent 9c69cf8911
commit 03621f16f4
21 changed files with 1414 additions and 11 deletions

View 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,
};
}