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