diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index ea17686..ef002c4 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -128,9 +128,11 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha mark("start"); const stageDebug = dbg.perf; mark("after_touchConversationState"); + // Detectar conversación nueva (más de 24 horas sin actividad) + const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas const isStale = prev?.state_updated_at && - Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; + Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs; const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; let externalCustomerId = await getExternalCustomerIdByChat({ tenant_id: tenantId, @@ -161,7 +163,21 @@ let externalCustomerId = await getExternalCustomerIdByChat({ logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state }); + // Cargar contexto previo, pero resetearlo si la conversación está "stale" let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {}; + if (isStale) { + // Conversación nueva: resetear carrito pero mantener datos del cliente + reducedContext = { + external_customer_id: reducedContext.external_customer_id, + // Resetear order y pending + order: null, + order_basket: null, + pending_items: null, + // Marcar que fue reseteado + _reset_reason: "stale", + _reset_at: new Date().toISOString(), + }; + } let decision; let plan; let llmMeta; diff --git a/src/modules/3-turn-engine/nlu/defaults/orders.txt b/src/modules/3-turn-engine/nlu/defaults/orders.txt index 96007f8..bad1b7d 100644 --- a/src/modules/3-turn-engine/nlu/defaults/orders.txt +++ b/src/modules/3-turn-engine/nlu/defaults/orders.txt @@ -2,6 +2,11 @@ Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje REGLAS CRÍTICAS (seguir estrictamente): +0. EXTRAER TODOS LOS PRODUCTOS - NUNCA OMITIR NINGUNO + Si el mensaje menciona 5 productos, el array items DEBE tener 5 elementos. + NUNCA omitas productos, incluso si no estás seguro del nombre exacto. + Extraé cada producto mencionado, separado por comas, "y", saltos de línea, etc. + 1. SIEMPRE USAR ARRAY "items" Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento. Cada item tiene: product_query, quantity, unit @@ -10,10 +15,13 @@ REGLAS CRÍTICAS (seguir estrictamente): El campo "product_query" debe ser el texto EXACTO que usó el cliente. - Si dice "asado de tira" → product_query: "asado de tira" - Si dice "vacío" → product_query: "vacío" + - Si dice "carre de cerdo" → product_query: "carre de cerdo" + - Si dice "provoletas wapi" → product_query: "provoletas wapi" - NUNCA modifiques, combines ni inventes nombres -3. EXTRAER CANTIDADES +3. EXTRAER CANTIDADES (pueden estar antes o después del producto) - "2kg de X" → quantity: 2, unit: "kg" + - "X 1kg" → quantity: 1, unit: "kg" (cantidad después del producto) - "3 provoletas" → quantity: 3, unit: "unidad" - "medio kilo" → quantity: 0.5, unit: "kg" - Sin cantidad → quantity: null @@ -24,13 +32,27 @@ REGLAS CRÍTICAS (seguir estrictamente): - unidad: unidades, u (para productos que no se pesan) 5. INTENTS - - add_to_cart: agregar productos (quiero, dame, anotame, poneme) + - add_to_cart: agregar productos (quiero, dame, anotame, poneme, hola quiero) - remove_from_cart: quitar productos (sacame, quitame) - view_cart: ver carrito (qué tengo, qué anoté, mi pedido) - confirm_order: cerrar pedido (listo, eso es todo, cerrar) EJEMPLOS: +Input: "hola, quiero 1kg de asado, vacio, carre de cerdo 1kg, chorizo mixto 1kg y 3 provoletas wapi" +Output: +{ + "intent": "add_to_cart", + "confidence": 0.95, + "items": [ + {"product_query": "asado", "quantity": 1, "unit": "kg"}, + {"product_query": "vacio", "quantity": null, "unit": null}, + {"product_query": "carre de cerdo", "quantity": 1, "unit": "kg"}, + {"product_query": "chorizo mixto", "quantity": 1, "unit": "kg"}, + {"product_query": "provoletas wapi", "quantity": 3, "unit": "unidad"} + ] +} + Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas" Output: { diff --git a/src/modules/3-turn-engine/orderModel.js b/src/modules/3-turn-engine/orderModel.js index db96d3d..ce5ff0d 100644 --- a/src/modules/3-turn-engine/orderModel.js +++ b/src/modules/3-turn-engine/orderModel.js @@ -154,6 +154,97 @@ export function addPendingItem(order, pendingItem) { }; } +/** + * Remueve items del carrito que coincidan con el query + * Usa fuzzy matching para encontrar el producto + */ +export function removeCartItem(order, productQuery) { + if (!order?.cart?.length || !productQuery) return { order, removed: null }; + + const query = String(productQuery).toLowerCase().trim(); + const words = query.split(/\s+/).filter(w => w.length > 2); + + // Buscar el item que mejor matchee + let bestIdx = -1; + let bestScore = 0; + + for (let i = 0; i < order.cart.length; i++) { + const item = order.cart[i]; + const name = String(item.name || "").toLowerCase(); + + // Score basado en coincidencia de palabras + let score = 0; + for (const word of words) { + if (name.includes(word)) score += 1; + } + // Bonus si el query entero está en el nombre + if (name.includes(query)) score += 2; + + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + if (bestIdx >= 0 && bestScore > 0) { + const removed = order.cart[bestIdx]; + const newCart = [...order.cart]; + newCart.splice(bestIdx, 1); + return { + order: { ...order, cart: newCart }, + removed, + }; + } + + return { order, removed: null }; +} + +/** + * Actualiza la cantidad de un item en el carrito + */ +export function updateCartItemQuantity(order, productQuery, newQty, newUnit = null) { + if (!order?.cart?.length || !productQuery) return { order, updated: null }; + + const query = String(productQuery).toLowerCase().trim(); + const words = query.split(/\s+/).filter(w => w.length > 2); + + // Buscar el item que mejor matchee + let bestIdx = -1; + let bestScore = 0; + + for (let i = 0; i < order.cart.length; i++) { + const item = order.cart[i]; + const name = String(item.name || "").toLowerCase(); + + let score = 0; + for (const word of words) { + if (name.includes(word)) score += 1; + } + if (name.includes(query)) score += 2; + + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + if (bestIdx >= 0 && bestScore > 0) { + const updated = { ...order.cart[bestIdx] }; + const newCart = [...order.cart]; + newCart[bestIdx] = { + ...updated, + qty: newQty, + unit: newUnit || updated.unit, + }; + return { + order: { ...order, cart: newCart }, + updated: newCart[bestIdx], + }; + } + + return { order, updated: null }; +} + /** * Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato */ @@ -217,18 +308,35 @@ export function migrateOldContext(ctx) { /** * Formatea el carrito para mostrar al usuario + * Incluye items del carrito + items pendientes (incompletos) */ export function formatCartForDisplay(order, locale = "es-AR") { - if (!order?.cart?.length) { - return "Tu carrito está vacío."; + const lines = []; + + // Items confirmados en el carrito + for (const item of (order?.cart || [])) { + if (item.qty != null) { + const qtyStr = item.unit === "kg" ? `${item.qty}kg` : + item.unit === "g" ? `${item.qty}g` : + item.unit === "unit" ? `${item.qty}` : `${item.qty}`; + lines.push(`- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`); + } else { + lines.push(`- ${item.name || `Producto #${item.woo_id}`} (falta cantidad)`); + } } - const lines = order.cart.map(item => { - const qtyStr = item.unit === "kg" ? `${item.qty}kg` : - item.unit === "g" ? `${item.qty}g` : - `${item.qty}`; - return `- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`; - }); + // Items pendientes (mostrar como provisionales) + for (const p of (order?.pending || [])) { + if (p.status === PendingStatus.NEEDS_TYPE) { + lines.push(`- "${p.query}" (pendiente de elegir)`); + } else if (p.status === PendingStatus.NEEDS_QUANTITY && p.selected_name) { + lines.push(`- ${p.selected_name} (falta cantidad)`); + } + } + + if (lines.length === 0) { + return "Tu carrito está vacío."; + } return "Tenés anotado:\n" + lines.join("\n"); } @@ -236,7 +344,7 @@ export function formatCartForDisplay(order, locale = "es-AR") { /** * Formatea opciones de un pending item para mostrar al usuario */ -export function formatOptionsForDisplay(pendingItem, pageSize = 9) { +export function formatOptionsForDisplay(pendingItem, pageSize = 12) { if (!pendingItem?.candidates?.length) { return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] }; } diff --git a/src/modules/3-turn-engine/stateHandlers.js b/src/modules/3-turn-engine/stateHandlers.js index 52e54a8..66bff40 100644 --- a/src/modules/3-turn-engine/stateHandlers.js +++ b/src/modules/3-turn-engine/stateHandlers.js @@ -17,6 +17,8 @@ import { migrateOldContext, formatCartForDisplay, formatOptionsForDisplay, + removeCartItem, + updateCartItemQuantity, } from "./orderModel.js"; import { handleRecommend } from "./recommendations.js"; import { getProductQtyRules } from "../0-ui/db/repo.js"; @@ -66,7 +68,72 @@ function isShowMoreRequest(text) { /\bmostr(a|ame)\s+m[aá]s\b/.test(t) || /\bmas\s+opciones\b/.test(t) || (/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) || - /\bsiguiente(s)?\b/.test(t) + /\bsiguiente(s)?\b/.test(t) || + // Patrones adicionales + /\b(no\s+)?hay\s+(otras?|m[aá]s)\b/.test(t) || + /\botras?\s+opciones\b/.test(t) || + /\bqu[eé]\s+m[aá]s\s+hay\b/.test(t) || + /\bver\s+m[aá]s\b/.test(t) || + /\btodas?\s+las?\s+opciones\b/.test(t) + ); +} + +// Detectar si el usuario pide ver las opciones disponibles +function isShowOptionsRequest(text) { + const t = String(text || "").toLowerCase(); + return ( + /\bqu[eé]\s+opciones\b/.test(t) || + /\bcu[aá]les\s+(son|hay|ten[eé]s)\b/.test(t) || + /\bmostr(a|ame)\s+(las\s+)?opciones\b/.test(t) || + /\bver\s+(las\s+)?opciones\b/.test(t) || + /\bqu[eé]\s+hay\b/.test(t) || + /\bqu[eé]\s+ten[eé]s\b/.test(t) + ); +} + +// Buscar un candidato que coincida con el texto del usuario (fuzzy match) +function findMatchingCandidate(candidates, text) { + if (!candidates?.length || !text) return null; + + const t = String(text).toLowerCase().trim(); + const words = t.split(/\s+/).filter(w => w.length > 2); + + let bestMatch = null; + let bestScore = 0; + + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + const name = String(candidate.name || "").toLowerCase(); + + let score = 0; + for (const word of words) { + if (name.includes(word)) score += 1; + } + // Bonus si el texto completo está en el nombre + if (name.includes(t)) score += 2; + + if (score > bestScore) { + bestScore = score; + bestMatch = { index: i, candidate, score }; + } + } + + // Requiere al menos una palabra que coincida + return bestScore > 0 ? bestMatch : null; +} + +// Detectar si el texto indica un intent de escape (ver carrito, confirmar, etc.) +function isEscapeRequest(text) { + const t = String(text || "").toLowerCase(); + return ( + /\b(que|qué)\s+tengo\b/.test(t) || + /\bmi\s+(carrito|pedido)\b/.test(t) || + /\bver\s+(carrito|pedido)\b/.test(t) || + /\bmostrar\s+(carrito|pedido)\b/.test(t) || + /\blisto\b/.test(t) || + /\bconfirmar?\b/.test(t) || + /\bcancelar?\b/.test(t) || + /\beso\s+(es\s+)?todo\b/.test(t) ); } @@ -144,8 +211,9 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i; const wantsToSkipPending = pendingItem && cancelPhrases.test(text || ""); - // Si quiere saltar el pending, eliminarlo - if (wantsToSkipPending && pendingItem) { + // Si quiere saltar el pending, eliminarlo - PERO solo si NO es un intent prioritario + // (ej: "nada más, que tengo en el carrito?" tiene "nada" pero el intent es view_cart) + if (wantsToSkipPending && pendingItem && !isPriorityIntent) { currentOrder = { ...currentOrder, pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id), @@ -210,6 +278,96 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI }; } + // 2.5) remove_from_cart: quitar productos del carrito o modificar cantidad + if (intent === "remove_from_cart") { + const items = nlu?.entities?.items || []; + const removedItems = []; + const addedItems = []; + const notFoundItems = []; + let updatedOrder = currentOrder; + + for (const item of items) { + if (!item.product_query) continue; + + // Intentar remover el producto + const { order: orderAfterRemove, removed } = removeCartItem(updatedOrder, item.product_query); + + if (removed) { + removedItems.push(removed.name || item.product_query); + updatedOrder = orderAfterRemove; + } else { + notFoundItems.push(item.product_query); + } + + // Si también quiere agregar una cantidad nueva (ej: "cambiame X por Y") + if (item.quantity && item.quantity > 0) { + // Esto parece un "agregar" más que un "quitar" + addedItems.push({ query: item.product_query, qty: item.quantity, unit: item.unit }); + } + } + + // Si hay items para agregar (como "3 provoletas wapi" en "no, 3 provoletas wapi") + if (addedItems.length > 0) { + for (const addItem of addedItems) { + const searchResult = await retrieveCandidates({ tenantId, query: addItem.query, limit: 20 }); + const candidates = searchResult?.candidates || []; + + const pendingItem = createPendingItemFromSearch({ + query: addItem.query, + quantity: addItem.qty, + unit: addItem.unit, + candidates, + }); + + updatedOrder = addPendingItem(updatedOrder, pendingItem); + } + updatedOrder = moveReadyToCart(updatedOrder); + } + + // Generar respuesta + let reply = ""; + if (removedItems.length > 0) { + reply += `Listo, saqué: ${removedItems.join(", ")}. `; + } + if (notFoundItems.length > 0 && removedItems.length === 0) { + reply += `No encontré "${notFoundItems.join(", ")}" en tu carrito. `; + } + + // Si hay pending items nuevos, pedir clarificación + const nextPending = getNextPendingItem(updatedOrder); + if (nextPending) { + if (nextPending.status === PendingStatus.NEEDS_TYPE) { + const { question } = formatOptionsForDisplay(nextPending); + reply += question; + return { + plan: { + reply: reply.trim(), + next_state: ConversationState.CART, + intent: "remove_from_cart", + missing_fields: ["product_selection"], + order_action: removedItems.length > 0 ? "remove_from_cart" : "none", + }, + decision: { actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], order: updatedOrder, audit }, + }; + } + } + + // Mostrar carrito actualizado + const cartDisplay = formatCartForDisplay(updatedOrder); + reply += `\n\n${cartDisplay}\n\n¿Algo más?`; + + return { + plan: { + reply: reply.trim(), + next_state: ConversationState.CART, + intent: "remove_from_cart", + missing_fields: [], + order_action: removedItems.length > 0 ? "remove_from_cart" : "none", + }, + decision: { actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], order: updatedOrder, audit }, + }; + } + // 3) confirm_order: ir a SHIPPING si hay items if (intent === "confirm_order") { // Primero mover pending READY a cart @@ -445,10 +603,11 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; + const cartSummary = formatCartForDisplay(finalOrder); return { plan: { - reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}. Ya lo anoté. ¿Algo más?`, + reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], @@ -478,9 +637,10 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI const lastAdded = currentOrder.cart[currentOrder.cart.length - 1]; if (lastAdded) { const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`; + const cartSummary = formatCartForDisplay(currentOrder); return { plan: { - reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}. ¿Algo más?`, + reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}.\n\n${cartSummary}\n\n¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], @@ -834,13 +994,25 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) { } async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) { + // Detectar intents que deberían escapar de la clarificación + const escapeIntents = ["view_cart", "confirm_order", "greeting", "cancel_order"]; + if (escapeIntents.includes(nlu?.intent)) { + audit.escape_from_pending = { reason: "intent", intent: nlu?.intent }; + return null; // Dejar que el handler principal procese + } + + // Detectar frases de escape explícitas + if (isEscapeRequest(text)) { + audit.escape_from_pending = { reason: "text_pattern", text }; + return null; + } + // Si necesita seleccionar tipo if (pendingItem.status === PendingStatus.NEEDS_TYPE) { const idx = parseIndexSelection(text); - // Show more - if (isShowMoreRequest(text)) { - // TODO: implement pagination + // Show more o mostrar opciones + if (isShowMoreRequest(text) || isShowOptionsRequest(text)) { const { question } = formatOptionsForDisplay(pendingItem); return { plan: { @@ -854,9 +1026,17 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending }; } - // Selection by index - if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) { - const selected = pendingItem.candidates[idx - 1]; + // Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar + const textMatch = !idx && pendingItem.candidates?.length > 0 + ? findMatchingCandidate(pendingItem.candidates, text) + : null; + + // Si encontramos un match por texto, usarlo como si fuera selección por índice + const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null); + + // Selection by index (o por match de texto) + if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) { + const selected = pendingItem.candidates[effectiveIdx - 1]; const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); // Usar la cantidad que pidió originalmente el usuario, o la unidad pedida para determinar si necesita cantidad @@ -904,9 +1084,10 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending const qtyDisplay = displayUnit === "unit" ? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}` : `${finalQty}${displayUnit}`; + const cartSummary = formatCartForDisplay(finalOrder); return { plan: { - reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}. ¿Algo más?`, + reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], @@ -916,13 +1097,17 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending }; } - // Si no hay candidatos (producto no encontrado) y el usuario da texto libre, - // intentar re-buscar con el texto como aclaración - if ((!pendingItem.candidates || pendingItem.candidates.length === 0) && text && text.length > 2) { + // Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar + // Esto funciona tanto si no hay candidatos como si hay pero el usuario quiere otra cosa + const isNumberSelection = idx !== null; + const hadTextMatch = effectiveIdx !== null && !idx; // Se encontró match por texto + const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2; + + if (isTextClarification) { const newQuery = text.trim(); const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 }); const newCandidates = searchResult?.candidates || []; - audit.retry_search = { query: newQuery, count: newCandidates.length }; + audit.retry_search = { query: newQuery, count: newCandidates.length, had_previous: pendingItem.candidates?.length || 0 }; if (newCandidates.length > 0) { // Encontramos opciones con la nueva búsqueda @@ -943,6 +1128,57 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p), }; + // Si hay match fuerte (1 candidato o score muy alto), seleccionar automáticamente + if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) { + const best = newCandidates[0]; + const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); + const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0; + const needsQuantity = displayUnit !== "unit" && !hasQty; + + const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, { + selected_woo_id: best.woo_product_id, + selected_name: best.name, + selected_price: best.price, + selected_unit: displayUnit, + candidates: [], + status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, + qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1), + unit: pendingItem.requested_unit || displayUnit, + }); + + if (needsQuantity) { + const unitQuestion = unitAskFor(displayUnit); + return { + plan: { + reply: `Para ${best.name}, ${unitQuestion}`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: ["quantity"], + order_action: "none", + }, + decision: { actions: [], order: autoSelectedOrder, audit }, + }; + } + + const finalOrder = moveReadyToCart(autoSelectedOrder); + const qty = hasQty ? pendingItem.requested_qty : 1; + const qtyDisplay = displayUnit === "unit" + ? `${qty} ${qty === 1 ? 'unidad de' : 'unidades de'}` + : `${qty}${displayUnit}`; + const cartSummary = formatCartForDisplay(finalOrder); + return { + plan: { + reply: `Perfecto, anoto ${qtyDisplay} ${best.name}.\n\n${cartSummary}\n\n¿Algo más?`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: [], + order_action: "add_to_cart", + }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + }; + } + + // Múltiples candidatos, mostrar opciones const { question } = formatOptionsForDisplay(updatedPending); return { plan: { @@ -956,30 +1192,44 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending }; } - // Sigue sin encontrar después de la aclaración -> escalar a humano - // Primero eliminar el pending item que no se puede resolver - const orderWithoutPending = { - ...order, - pending: (order.pending || []).filter(p => p.id !== pendingItem.id), - }; + // No encontró nada con la nueva búsqueda + // Si no había candidatos antes -> escalar a humano + if (!pendingItem.candidates || pendingItem.candidates.length === 0) { + const orderWithoutPending = { + ...order, + pending: (order.pending || []).filter(p => p.id !== pendingItem.id), + }; + + audit.escalated_to_human = true; + audit.original_query = pendingItem.query; + audit.retry_query = newQuery; + + return createHumanTakeoverResponse({ + pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`, + order: orderWithoutPending, + context: { + original_query: pendingItem.query, + user_clarification: newQuery, + search_attempts: 2, + }, + }); + } - audit.escalated_to_human = true; - audit.original_query = pendingItem.query; - audit.retry_query = newQuery; - - // Crear takeover con contexto de la búsqueda fallida - return createHumanTakeoverResponse({ - pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`, - order: orderWithoutPending, - context: { - original_query: pendingItem.query, - user_clarification: newQuery, - search_attempts: 2, + // Había candidatos pero la búsqueda nueva no encontró, mantener los anteriores + const { question } = formatOptionsForDisplay(pendingItem); + return { + plan: { + reply: `No encontré "${newQuery}". ${question}`, + next_state: ConversationState.CART, + intent: "other", + missing_fields: ["product_selection"], + order_action: "none", }, - }); + decision: { actions: [], order, audit }, + }; } - // No entendió, volver a preguntar + // No entendió (no es número, no es texto largo), volver a preguntar const { question } = formatOptionsForDisplay(pendingItem); return { plan: { @@ -1017,10 +1267,11 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`; + const cartSummary = formatCartForDisplay(finalOrder); return { plan: { - reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}. ¿Algo más?`, + reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], @@ -1083,10 +1334,11 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; + const cartSummary = formatCartForDisplay(finalOrder); return { plan: { - reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}. Ya lo anoté. ¿Algo más?`, + reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [],