Schema delivery_zones JSONB pasa a { zones: [{ id, name, polygon (GeoJSON),
delivery_cost, delivery_days, delivery_hours, min_order_amount, enabled }] }.
Tirado el modelo legacy de 48 barrios CABA hardcoded.
Backend:
- lib/geo.js: pointInPolygon + findZoneForPoint (ray casting, sin deps) + 9 tests.
- storeContext.checkAddressInZone ahora valida con lat/lng (necesita ubicación
del cliente; no geocodifica texto). buildZonesForLLM expone zonas resumidas
para el agente. summarizeDeliveryZones genera prosa con costo+días+horas.
- settingsRepo expone delivery_zones (bug pre-existente: nunca se devolvía).
- pipeline: inboundLocation ⇒ persistir order.pending_location; orderModel
acepta pending_location, matched_zone, delivery_window.
Intake:
- evolutionParser detecta locationMessage/liveLocationMessage (Baileys).
- evolution + sim handlers propagan inboundLocation al pipeline.
Agent (DeepSeek tool-calling):
- workingMemory inyecta store.delivery.zones[], store.pickup.schedule,
order.pending_location/matched_zone/delivery_window.
- setAddress: matchea zona con la ubicación pendiente; sin location devuelve
need_location y el LLM le pide el pin al cliente.
- setShipping: para delivery, indica requires_location si faltan coords.
- confirmOrder: valida día+hora contra zone.delivery_days/hours o pickup.schedule.
- nueva tool set_delivery_window(day, time?) para registrar el slot pedido.
- systemPrompt agrega instrucciones de envío/zonas + flujo location share.
Frontend:
- zone-map-editor: web component (light DOM) que carga Leaflet 1.9 +
leaflet-geoman lazy desde CDN y permite dibujar/editar polígonos sobre OSM.
API zones get/set, eventos change/select, paleta tomada de --chart-*.
- settings-crud: borrada lista CABA_BARRIOS, nueva UI con mapa al lado y
formulario por zona seleccionada (nombre, costo, días, horario start/end,
mínimo, habilitada). Save serializa al schema nuevo.
Smoke E2E manual:
- "1kg vacío + envío" → bot pide pin → location en Centro → matched_zone
$1.500, lun-sab 10-20h → "martes 12hs" → confirma orden con total + envío.
- Location en Palermo Test → mar/jue 11-19h respetado.
- Location fuera de zonas → "no llegamos a esa zona" + lista de zonas válidas.
- Domingo en Centro → rechazado con días disponibles.
157/157 tests verde.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Después de validar el agente E2E con DeepSeek, el legacy queda muerto.
51 archivos cambiados (la mayoría borrados), el motor único es ahora el
agente tool-calling.
Borrados (~3500 LOC):
- src/modules/3-turn-engine/nlu/ (router + 4 specialists + promptLoader +
schemas + humanFallback + 6 default prompts) — reemplazado por systemPrompt.js
- src/modules/3-turn-engine/stateHandlers/ (cart.js, cartHelpers.js, idle.js,
shipping.js, utils.js, index.js) — reemplazado por tools del agente
- src/modules/3-turn-engine/stateHandlers.js (re-export shim)
- src/modules/3-turn-engine/openai.js (NLU clásico v3 + jsonCompletion +
llmRecommendWriter + llmPlanningRecommend) — el agente crea su propio
cliente OpenAI con tools nativos
- src/modules/3-turn-engine/replyRewriter.js (rewriting LLM) — el agente
escribe say directo, no necesita reescribir
- src/modules/3-turn-engine/replyTemplates.js + test (rotación de variantes)
— el agente varía naturalmente con tool_choice=required + temperature
- src/modules/3-turn-engine/recommendations.js (cross-sell + planning) —
el agente decide cuándo recomendar via tool calls
- src/modules/3-turn-engine/machine/ (XState v5 completo + 19 tests) —
reemplazado por la FSM podada en fsm.js + agent/runTurn.js
- src/modules/3-turn-engine/turnEngineV3.helpers.js, .units.js,
.pendingSelection.js (helpers del legacy)
- src/modules/0-ui/controllers/prompts.js, handlers/prompts.js,
db/promptsRepo.js — admin de prompts NLU (ya no hay prompts editables)
- public/components/prompts-crud.js + nav entry en ops-shell
turnEngineV3.js se reduce a un thin wrapper que exporta runTurnV3 (alias
de runTurnAgent) + safeNextState (re-export de fsm.js). Mantiene la firma
pública para no tocar pipeline.js.
Activado:
- AGENT_MAX_TOOL_CALLS=10 y AGENT_TURN_TIMEOUT_MS=25000 son los únicos
flags. Borradas: USE_MODULAR_NLU, USE_XSTATE, XSTATE_SHADOW,
XSTATE_SETTLE_MS, REPLY_REWRITER, REPLY_REWRITER_TIMEOUT_MS, TURN_ENGINE,
AGENT_TURN_ENGINE, AGENT_TURN_ENGINE_SHADOW (el agente es default).
Prompt caching DeepSeek:
- systemPrompt.js: era función con storeName interpolado → ahora export
const SYSTEM_PROMPT (100% estático). storeName se pasa por user message
via working_memory.store.name. Cualquier cambio al system invalida cache,
por eso es estático estricto.
- runTurn.js: captura usage.prompt_cache_hit_tokens (DeepSeek) o
prompt_tokens_details.cached_tokens (OpenAI compat) y suma a métricas.
- /api/metrics/agent ahora reporta prompt_tokens_total,
completion_tokens_total, prompt_cache_hit_tokens, cache_hit_ratio.
- Smoke test 3 turnos: cache_hit_ratio = 0.72 (17664 cached / 24546 total
prompt tokens). Saving directo en costo: ~$0.02/M cached vs $0.27/M no
cached en DeepSeek.
Tests: 148/148 (perdimos 90 tests del legacy XState/replyTemplates que
ya no aplican). Sim flow E2E confirmado: hola → agent responde, multi-turn
con cache caliente.
Si más adelante hace falta volver al legacy: git revert este commit
(c c9c69cf8 es el último estado verde con doble motor).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
7 frentes en una pasada:
1. Idempotency en pipeline.processMessage: si el message_id ya existía
(Evolution suele reentregar webhooks), skip todo el turn y devuelve
{ duplicate: true }. Antes el ON CONFLICT DO NOTHING evitaba el insert
pero igual procesaba NLU + side effects.
2. Limpieza de payment residual de admin/UI:
- ordersRepo.getMonthlyStats / getTotals: out las queries cash/card
- home-dashboard: out el donut "Efectivo vs Tarjeta"
- orders-crud: out columna "Pago" + sección de detalle de pago
- conversation-inspector: out 💵💳✅ del resumen
- takeovers: out payment_link en run, out payment del summary
- public/main.js: out la invariant no_checkout_without_payment_link
- prompts-crud: out la entry "payment" del PROMPT_LABELS
- wooOrders.parseOrder: out lectura de payment_method/is_paid (estado
del pago lo gestiona el comercio offline, fuera del bot)
- ordersRepo: out is_cash/is_paid del row mapping
3. Seed migration 20260501130000_seed_piaf_settings_and_replies:
- schedule realista para piaf (delivery/pickup días+horas)
- delivery_zones con barrios CABA reales (Palermo, Belgrano, etc)
- 41 reply_templates con 17 keys + variantes (todo lo de DEFAULTS)
Permite editar respuestas sin redeploy y desbloquea {{store_hours_today}},
{{delivery_zones_summary}} reales en los templates.
4. Address validation en shipping: nuevo checkAddressInZone() en
storeContext.js. Cuando el usuario da dirección, se valida contra
zonas configuradas. Si está fuera, renderiza shipping.address_out_of_zone
con sugerencia de zonas alternativas. Sin zonas configuradas → accept-by-default.
5. Métricas rewriter: getRewriterMetrics() expuesto, contadores
ok/fallback/timeouts + avg_ms + fallback_rate. Endpoint nuevo
/api/metrics/rewriter.
6. Shadow XState → audit_log: el shadow mode pasa de console.log a
insertAuditLog con entity_type='xstate_shadow'. Permite review post-mortem.
7. Recommend portado a XState: nuevo recommendActor (fromPromise) wrappea
handleRecommend; sub-state cart.recommending invoca el actor; ingestRecommendResult
absorbe { plan, decision } en context. RECOMMEND event funciona desde idle
y desde cart con USE_XSTATE=1.
8. tenantId opcional en renderReply/loadReplyVariants — defaultea a
getTenantId() del módulo shared/tenant.js. Backward-compat: callers
pueden seguir pasando tenantId o omitirlo.
E2E tests nuevos en machine/e2e.test.js: golden flow pickup, golden flow
delivery con address-in-zone, snapshot rehydrate full flow, universal
cart-on-add desde shipping. 192/192 tests pasando (188 previos + 4 E2E).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El sistema nunca fue realmente multi-tenant en la práctica. El esquema
DB conserva las columnas tenant_id (queda lista para escalar más adelante
sin migración), pero la app ahora resuelve el tenant una sola vez al
arranque y todas las capas leen de un único punto.
- src/modules/shared/tenant.js: nuevo módulo. setTenant() en boot,
getTenantId() lo lee desde cualquier lado.
- index.js: ensureTenant() → setTenant({ id, key }). Sin cambios externos.
- pipeline.resolveTenantId(): pasa de hacer 1-2 queries a DB por turno
a un return sincrónico del id cacheado. Mantiene firma async para no
romper callers.
- intake handlers (sim.js, evolution.js): usan getTenantId() directo,
sin parsing de tenant_key del chat_id ni lookup por canal.
- wooWebhooks: ya no requiere ?tenant_key=... en la query string.
El webhook va al único tenant configurado.
- repo.js: eliminados getTenantByKey() y getTenantIdByChannel() (no más
callers).
Plumbing del parámetro tenantId en signatures de handlers/repos/machine
queda intacto — bajar eso es ruido de alto riesgo y no aporta hoy.
188 tests pasando.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El bot conversacional no maneja pagos. Su trabajo: pedidos, datos de
entrega, dejar la orden anotada en Woo (status=pending). El cobro lo
gestiona el comercio offline. Todo lo de payment_type / is_paid /
PAYMENT / WAITING_WEBHOOKS era legacy de un flow viejo que se baja.
Nuevo flow: IDLE → CART → SHIPPING → IDLE (con orden creada).
Cuando el usuario completa shipping (pickup elegido OR delivery+address),
shipping.js emite create_order y el bot cierra con order.confirmed.
- fsm.js: 4 estados (IDLE/CART/SHIPPING/AWAITING_HUMAN). hasPaymentInfo
e isPaid eliminados. deriveNextState gira SHIPPING→IDLE en vez de
→PAYMENT→WAITING. ALLOWED transitions actualizadas.
- orderModel.js: createEmptyOrder() sin payment_type/is_paid.
migrateOldContext deja de leer payment_method / mp.payment_status.
- stateHandlers: payment.js y waiting.js eliminados. shipping.js gana
finalizeOrder() que emite create_order action y vuelve a IDLE.
- replyTemplates: payment.* y waiting.* fuera. order.confirmed nuevo,
con 3 variantes y rewriter habilitado.
- NLU openai.js + nlu/schemas.js: select_payment fuera del enum, payment_method
fuera de entities. Prompt sin la regla de SELECCIONAR PAGO.
- nlu/router.js + nlu/index.js: dominio "payment" eliminado.
shouldSkipRouter ya no chequea PAYMENT.
- nlu/specialists/payment.js: eliminado.
- promptsRepo.js + promptLoader.js: PROMPT_KEYS sin "payment".
- turnEngineV3.js: switch ya no dispatcha a PAYMENT/WAITING. normalizeState
mapea estados legacy (PAYMENT/WAITING_WEBHOOKS/COMPLETED) a IDLE.
context_patch ya no emite payment_method.
- wooOrders.createOrder: paymentMethod param eliminado. Order queda en
status=pending sin payment_method (cobro offline).
- pipeline.js: paymentMethod fuera del create_order glue. Invariant
"no_checkout_without_payment_link" eliminado. signal payment_selected
reemplazado por shipping_completed.
- XState machine: top-level PAYMENT y WAITING eliminados. SELECT_PAYMENT
event fuera. SHIPPING ahora cierra con enqueueWooCreateOrder +
replyOrderConfirmed → IDLE. Guards hasPayment/isPaid borrados.
- Tests fsm.test.js / orderModel.test.js / machine/index.test.js
actualizados al nuevo contrato. 188 tests pasando.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reemplaza el dispatcher en turnEngineV3.js por un statechart formal en
XState v5. La machine es pura: produce un effect log (pending_actions) +
un descriptor de reply (pending_reply) que el runner traduce afuera.
API externa intacta: runTurnV3 sigue retornando { plan, decision } con
shape compatible con pipeline.js. Snapshot persiste en
context.xstate_snapshot dentro del JSONB existente.
- machine/index.js: statechart top-level (idle/cart/shipping/payment/
waiting/awaiting_human) + cart sub-statechart con todo el flujo
multi-turno (searching/resolving/askingClarification/askingQuantity/
computingFromPersonas/added/showing/pricing/researching).
- guards.js: portados de fsm.js (hasCart, wantsToAddProduct, etc).
- actions.js: assigns para mutations + reply descriptors (pending_reply
con templateKey/vars/rawText). Las async no entran en la machine.
- actors.js: fromPromise wrappers de retrieveCandidates y getProductQtyRules.
- runner.js: boot con prev_context.xstate_snapshot o migrateOldContext.
NLU → nluToEvent → send → settle (espera invokes) → realizeReply
(renderReply real con rewriter) → getPersistedSnapshot → format.
- nluToEvent.js: adapter NLU intent → evento XState (1:1).
Feature flags: USE_XSTATE=1 reemplaza el path; XSTATE_SHADOW=1 corre
ambos en paralelo, devuelve legacy y loguea diffs estructurales para
validar antes de flippar prod.
16 unit tests para la machine cubren: arranque, regla universal cart-on-add,
flow de cart con strong/multi match, checkout completo (shipping/pickup/
payment/cash) y rehidratación de snapshot. 224 tests totales pasando.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Foco: matar repetición y adaptar respuestas. Los handlers tenían ~30 strings
hardcodeadas (3-7 lugares cada una). Aliases hacían substring exacto.
- pg_trgm + GIN indexes en product_aliases / alias_product_mappings.
Captura plurales, diminutivos, typos sin reglas. catalogRetrieval re-busca
el snapshot con normalized_alias cuando el query original no rinde
(vasio→vacio→Vacío).
- reply_templates table + replyTemplates.js. 20 keys, 2-3 variantes c/u
con DEFAULTS hardcodeados como fallback. pickVariant excluye las usadas
en context.recent_replies (FIFO cap 8). Wired en idle/cart/cartHelpers/
shipping/payment/waiting.
- failed_searches counter en context. count>=3 escala via humanFallback.
Reset en cada add_to_cart exitoso.
- storeContext.js: vars derivadas de getStoreConfig (delivery_zones, hours,
zonas) listas para inyectar en templates cuando los datos se carguen.
- replyRewriter.js: LLM call opcional (REPLY_REWRITER=1) que adapta el
template al hilo conversacional. 1.5s timeout, fallback al template puro.
Sólo activo en 8 slots semánticamente importantes.
- 12 unit tests para replyTemplates (rotation, recency, FIFO, vars).
208 tests totales pasando.
Plan completo: ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CLAUDE.md con arquitectura y comandos del proyecto
- env.example: agregar LIMIT_CONVERSATIONS, MAX_CHARS_PER_MESSAGE, OPENAI_BASE_URL
- docker-compose.override: puerto 3001, extra_hosts para modelo local en Linux
- OpenAI clients: soporte OPENAI_BASE_URL para apuntar a modelo local compatible
- stats.js: sync de órdenes en background, dashboard no bloquea al cargar
- package-lock: dbmate movido a prod dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>