From b1c8a3685cbb7fc8555e1079612762a77817593a Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:58:56 -0300 Subject: [PATCH] refactor stateHandlers --- src/modules/3-turn-engine/stateHandlers.js | 1396 +---------------- .../3-turn-engine/stateHandlers/cart.js | 554 +++++++ .../stateHandlers/cartHelpers.js | 538 +++++++ .../3-turn-engine/stateHandlers/idle.js | 44 + .../3-turn-engine/stateHandlers/index.js | 32 + .../3-turn-engine/stateHandlers/payment.js | 87 + .../3-turn-engine/stateHandlers/shipping.js | 120 ++ .../3-turn-engine/stateHandlers/utils.js | 147 ++ .../3-turn-engine/stateHandlers/waiting.js | 46 + 9 files changed, 1602 insertions(+), 1362 deletions(-) create mode 100644 src/modules/3-turn-engine/stateHandlers/cart.js create mode 100644 src/modules/3-turn-engine/stateHandlers/cartHelpers.js create mode 100644 src/modules/3-turn-engine/stateHandlers/idle.js create mode 100644 src/modules/3-turn-engine/stateHandlers/index.js create mode 100644 src/modules/3-turn-engine/stateHandlers/payment.js create mode 100644 src/modules/3-turn-engine/stateHandlers/shipping.js create mode 100644 src/modules/3-turn-engine/stateHandlers/utils.js create mode 100644 src/modules/3-turn-engine/stateHandlers/waiting.js diff --git a/src/modules/3-turn-engine/stateHandlers.js b/src/modules/3-turn-engine/stateHandlers.js index 66bff40..7e67d3f 100644 --- a/src/modules/3-turn-engine/stateHandlers.js +++ b/src/modules/3-turn-engine/stateHandlers.js @@ -1,1367 +1,39 @@ /** - * Handlers por estado para el flujo conversacional simplificado. - * Cada handler recibe params y retorna { plan, decision } + * State Handlers - Re-export desde módulo refactorizado + * + * Este archivo mantiene compatibilidad con imports existentes. + * La implementación real está en ./stateHandlers/ + * + * Estructura del módulo: + * - stateHandlers/utils.js - Utilidades de parseo y detección de texto + * - stateHandlers/cartHelpers.js - Helpers para manejo del carrito + * - stateHandlers/idle.js - Handler estado IDLE + * - stateHandlers/cart.js - Handler estado CART + * - stateHandlers/shipping.js - Handler estado SHIPPING + * - stateHandlers/payment.js - Handler estado PAYMENT + * - stateHandlers/waiting.js - Handler estado WAITING_WEBHOOKS */ -import { retrieveCandidates } from "./catalogRetrieval.js"; -import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "./fsm.js"; -import { - createEmptyOrder, - createPendingItem, - createCartItem, - PendingStatus, - moveReadyToCart, - getNextPendingItem, - updatePendingItem, - addPendingItem, - migrateOldContext, - formatCartForDisplay, - formatOptionsForDisplay, - removeCartItem, - updateCartItemQuantity, -} from "./orderModel.js"; -import { handleRecommend } from "./recommendations.js"; -import { getProductQtyRules } from "../0-ui/db/repo.js"; -import { createHumanTakeoverResponse } from "./nlu/humanFallback.js"; - -// ───────────────────────────────────────────────────────────── -// Utilidades -// ───────────────────────────────────────────────────────────── - -function inferDefaultUnit({ name, categories }) { - const n = String(name || "").toLowerCase(); - const cats = Array.isArray(categories) ? categories : []; - const hay = (re) => - cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); - if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) { - return "unit"; - } - if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) { - return "unit"; - } - if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { - return "unit"; - } - return "kg"; -} - -function parseIndexSelection(text) { - const t = String(text || "").toLowerCase(); - const m = /\b(\d{1,2})\b/.exec(t); - if (m) return parseInt(m[1], 10); - if (/\bprimera\b|\bprimero\b/.test(t)) return 1; - if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2; - if (/\btercera\b|\btercero\b/.test(t)) return 3; - if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4; - if (/\bquinta\b|\bquinto\b/.test(t)) return 5; - if (/\bsexta\b|\bsexto\b/.test(t)) return 6; - if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7; - if (/\boctava\b|\boctavo\b/.test(t)) return 8; - if (/\bnovena\b|\bnoveno\b/.test(t)) return 9; - if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10; - return null; -} - -function isShowMoreRequest(text) { - const t = String(text || "").toLowerCase(); - return ( - /\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) || - // 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; +export { + // Handlers principales + handleIdleState, + handleCartState, + handleShippingState, + handlePaymentState, + handleWaitingState, - const t = String(text).toLowerCase().trim(); - const words = t.split(/\s+/).filter(w => w.length > 2); + // Utilidades + inferDefaultUnit, + parseIndexSelection, + isShowMoreRequest, + isShowOptionsRequest, + findMatchingCandidate, + isEscapeRequest, + normalizeUnit, + unitAskFor, - 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) - ); -} - -function normalizeUnit(unit) { - if (!unit) return null; - const u = String(unit).toLowerCase(); - if (u === "kg" || u === "kilo" || u === "kilos") return "kg"; - if (u === "g" || u === "gramo" || u === "gramos") return "g"; - if (u === "unidad" || u === "unidades" || u === "unit") return "unit"; - return null; -} - -function unitAskFor(displayUnit) { - if (displayUnit === "unit") return "¿Cuántas unidades querés?"; - if (displayUnit === "g") return "¿Cuántos gramos querés?"; - return "¿Cuántos kilos querés?"; -} - -// ───────────────────────────────────────────────────────────── -// Handler: IDLE -// ───────────────────────────────────────────────────────────── - -export async function handleIdleState({ tenantId, text, nlu, order, audit }) { - const intent = nlu?.intent || "other"; - const actions = []; - - // Greeting - if (intent === "greeting") { - return { - plan: { - reply: "¡Hola! ¿En qué te puedo ayudar hoy?", - next_state: ConversationState.IDLE, - intent: "greeting", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; - } - - // Cualquier intent relacionado con productos → ir a CART - if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) { - // Delegar a handleCartState - return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true }); - } - - // Other - return { - plan: { - reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.", - next_state: ConversationState.IDLE, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; -} - -// ───────────────────────────────────────────────────────────── -// Handler: CART -// ───────────────────────────────────────────────────────────── - -export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) { - const intent = nlu?.intent || "other"; - let currentOrder = order || createEmptyOrder(); - const actions = []; - - // Intents que tienen prioridad sobre pending items (permiten "escapar" del loop) - const priorityIntents = ["view_cart", "confirm_order", "greeting"]; - const isPriorityIntent = priorityIntents.includes(intent); - - // Detectar si el usuario quiere cancelar/saltar el pending item actual - const pendingItem = getNextPendingItem(currentOrder); - 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 - 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), - }; - audit.skipped_pending = pendingItem.query; - - // Si hay más pending items, continuar con el siguiente - const nextPending = getNextPendingItem(currentOrder); - if (nextPending) { - const { question } = formatOptionsForDisplay(nextPending); - return { - plan: { - reply: `Ok, salteo "${pendingItem.query}". ${question}`, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // No hay más pending, mostrar confirmación - const cartDisplay = formatCartForDisplay(currentOrder); - return { - plan: { - reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`, - next_state: ConversationState.CART, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación - if (pendingItem && !isPriorityIntent) { - const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit }); - if (result) return result; - } - - // 2) view_cart: mostrar carrito actual - if (intent === "view_cart") { - const cartDisplay = formatCartForDisplay(currentOrder); - const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0; - let reply = cartDisplay; - if (pendingCount > 0) { - reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`; - } - reply += "\n\n¿Algo más?"; - - return { - plan: { - reply, - next_state: ConversationState.CART, - intent: "view_cart", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // 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 - currentOrder = moveReadyToCart(currentOrder); - - if (!hasCartItems(currentOrder)) { - return { - plan: { - reply: "Tu carrito está vacío. ¿Qué querés agregar?", - next_state: ConversationState.CART, - intent: "confirm_order", - missing_fields: ["cart_items"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Si hay pending items sin resolver, clarificarlos primero - if (hasPendingItems(currentOrder)) { - const nextPending = getNextPendingItem(currentOrder); - const { question } = formatOptionsForDisplay(nextPending); - return { - plan: { - reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`, - next_state: ConversationState.CART, - intent: "confirm_order", - missing_fields: ["pending_items"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Todo listo, ir a SHIPPING - const { next_state } = safeNextState(ConversationState.CART, currentOrder, { confirm_order: true }); - return { - plan: { - reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", - next_state, - intent: "confirm_order", - missing_fields: ["shipping_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // 4) recommend - if (intent === "recommend") { - try { - const recoResult = await handleRecommend({ - tenantId, - text, - nlu, - order: currentOrder, - prevContext: { order: currentOrder }, - audit - }); - if (recoResult?.plan?.reply) { - // Merge context_patch si existe - const newOrder = recoResult.decision?.order || currentOrder; - const contextPatch = recoResult.decision?.context_patch || {}; - return { - plan: { - ...recoResult.plan, - next_state: ConversationState.CART, - }, - decision: { - actions: recoResult.decision?.actions || [], - order: newOrder, - audit, - context_patch: contextPatch, - }, - }; - } - } catch (e) { - audit.recommend_error = String(e?.message || e); - } - } - - // 4.5) price_query - consulta de precios - if (intent === "price_query") { - const productQueries = extractProductQueries(nlu); - - if (productQueries.length === 0) { - return { - plan: { - reply: "¿De qué producto querés saber el precio?", - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: ["product_query"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Buscar productos y mostrar precios - const priceResults = []; - for (const pq of productQueries.slice(0, 5)) { - const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 }); - const candidates = searchResult?.candidates || []; - audit.price_search = audit.price_search || []; - audit.price_search.push({ query: pq.query, count: candidates.length }); - - for (const c of candidates.slice(0, 2)) { - const unit = inferDefaultUnit({ name: c.name, categories: c.categories }); - const priceStr = c.price != null ? `$${c.price}` : "consultar"; - const unitStr = unit === "unit" ? "/unidad" : "/kg"; - priceResults.push(`• ${c.name}: ${priceStr}${unitStr}`); - } - } - - if (priceResults.length === 0) { - return { - plan: { - reply: "No encontré ese producto. ¿Podés ser más específico?", - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: ["product_query"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?"; - return { - plan: { - reply, - next_state: ConversationState.CART, - intent: "price_query", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // 5) add_to_cart / browse / price_query: buscar productos - if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) { - const productQueries = extractProductQueries(nlu); - - if (productQueries.length === 0) { - return { - plan: { - reply: "¿Qué producto querés agregar?", - next_state: ConversationState.CART, - intent, - missing_fields: ["product_query"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Buscar candidatos para cada query - for (const pq of productQueries) { - const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 }); - const candidates = searchResult?.candidates || []; - audit.catalog_search = audit.catalog_search || []; - audit.catalog_search.push({ query: pq.query, count: candidates.length }); - - const pendingItem = createPendingItemFromSearch({ - query: pq.query, - quantity: pq.quantity, - unit: pq.unit, - candidates, - }); - - currentOrder = addPendingItem(currentOrder, pendingItem); - } - - // Mover items READY directamente al cart - currentOrder = moveReadyToCart(currentOrder); - - // Si hay pending items, pedir clarificación del primero - const nextPending = getNextPendingItem(currentOrder); - if (nextPending) { - if (nextPending.status === PendingStatus.NEEDS_TYPE) { - const { question } = formatOptionsForDisplay(nextPending); - return { - plan: { - reply: question, - next_state: ConversationState.CART, - intent, - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - if (nextPending.status === PendingStatus.NEEDS_QUANTITY) { - // Detectar "para X personas" en el texto original ANTES de preguntar cantidad - const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || - /\bpara\s+(\d+)\b/i.exec(text || "") || - /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); - - if (personasMatch && nextPending.selected_woo_id) { - const peopleCount = parseInt(personasMatch[1], 10); - - if (peopleCount > 0 && peopleCount <= 100) { - // Buscar reglas de cantidad por persona para este producto - let qtyRules = []; - try { - qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id }); - } catch (e) { - audit.qty_rules_error = e?.message; - } - - // Calcular cantidad recomendada - let calculatedQty; - let calculatedUnit = nextPending.selected_unit || "kg"; - const rule = qtyRules[0]; - - if (rule && rule.qty_per_person > 0) { - calculatedQty = rule.qty_per_person * peopleCount; - calculatedUnit = rule.unit || calculatedUnit; - audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount }; - } else { - // Fallback: 0.3 kg por persona para carnes - calculatedQty = 0.3 * peopleCount; - audit.qty_fallback = { default_per_person: 0.3, people: peopleCount }; - } - - // Actualizar el pending item y mover al cart - const updatedOrder = updatePendingItem(currentOrder, nextPending.id, { - qty: calculatedQty, - unit: calculatedUnit, - status: PendingStatus.READY, - }); - - 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}.\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 }, - }; - } - } - - // Si no hay "para X personas", preguntar cantidad normalmente - const unitQuestion = unitAskFor(nextPending.selected_unit || "kg"); - return { - plan: { - reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`, - next_state: ConversationState.CART, - intent, - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - } - - // Todo resuelto, confirmar agregado - 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}.\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", payload: lastAdded }], order: currentOrder, audit }, - }; - } - } - - // Default - return { - plan: { - reply: "¿Qué más querés agregar?", - next_state: ConversationState.CART, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; -} - -// ───────────────────────────────────────────────────────────── -// Handler: SHIPPING -// ───────────────────────────────────────────────────────────── - -export async function handleShippingState({ tenantId, text, nlu, order, audit }) { - const intent = nlu?.intent || "other"; - let currentOrder = order || createEmptyOrder(); - - // Detectar selección de shipping (delivery/pickup) - let shippingMethod = nlu?.entities?.shipping_method; - - // Detectar por número o texto - if (!shippingMethod) { - const t = String(text || "").toLowerCase(); - const idx = parseIndexSelection(text); - if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) { - shippingMethod = "delivery"; - } else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) { - shippingMethod = "pickup"; - } - } - - if (shippingMethod) { - currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; - - if (shippingMethod === "pickup") { - // Pickup: ir directo a PAYMENT - const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); - return { - plan: { - reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", - next_state, - intent: "select_shipping", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Delivery: pedir dirección si no la tiene - if (!currentOrder.shipping_address) { - return { - plan: { - reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?", - next_state: ConversationState.SHIPPING, - intent: "select_shipping", - missing_fields: ["address"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - } - - // Si ya eligió delivery y ahora da dirección - if (currentOrder.is_delivery === true && !currentOrder.shipping_address) { - // Extraer dirección del texto (el usuario probablemente escribió la dirección) - const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null); - - if (address) { - currentOrder = { ...currentOrder, shipping_address: address }; - const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); - - return { - plan: { - reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`, - next_state, - intent: "provide_address", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - return { - plan: { - reply: "Necesito la dirección de entrega. ¿Me la pasás?", - next_state: ConversationState.SHIPPING, - intent: "other", - missing_fields: ["address"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // view_cart - if (intent === "view_cart") { - const cartDisplay = formatCartForDisplay(currentOrder); - return { - plan: { - reply: cartDisplay + "\n\n¿Es para delivery o retiro?", - next_state: ConversationState.SHIPPING, - intent: "view_cart", - missing_fields: ["shipping_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Default: preguntar de nuevo - return { - plan: { - reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", - next_state: ConversationState.SHIPPING, - intent: "other", - missing_fields: ["shipping_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; -} - -// ───────────────────────────────────────────────────────────── -// Handler: PAYMENT -// ───────────────────────────────────────────────────────────── - -export async function handlePaymentState({ tenantId, text, nlu, order, audit }) { - const intent = nlu?.intent || "other"; - let currentOrder = order || createEmptyOrder(); - const actions = []; - - // Detectar selección de pago - let paymentMethod = nlu?.entities?.payment_method; - - if (!paymentMethod) { - const t = String(text || "").toLowerCase(); - const idx = parseIndexSelection(text); - if (idx === 1 || /efectivo|cash|plata/i.test(t)) { - paymentMethod = "cash"; - } else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) { - paymentMethod = "link"; - } - } - - if (paymentMethod) { - currentOrder = { ...currentOrder, payment_type: paymentMethod }; - actions.push({ type: "create_order", payload: { payment: paymentMethod } }); - - if (paymentMethod === "link") { - actions.push({ type: "send_payment_link", payload: {} }); - } - - const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true }); - - const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago"; - const deliveryInfo = currentOrder.is_delivery - ? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.` - : "Retiro en sucursal."; - - const paymentInfo = paymentMethod === "link" - ? "Te paso el link de pago en un momento." - : "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + "."; - - return { - plan: { - reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`, - next_state, - intent: "select_payment", - missing_fields: [], - order_action: "create_order", - }, - decision: { actions, order: currentOrder, audit }, - }; - } - - // view_cart - if (intent === "view_cart") { - const cartDisplay = formatCartForDisplay(currentOrder); - return { - plan: { - reply: cartDisplay + "\n\n¿Cómo preferís pagar?", - next_state: ConversationState.PAYMENT, - intent: "view_cart", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Default - return { - plan: { - reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", - next_state: ConversationState.PAYMENT, - intent: "other", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; -} - -// ───────────────────────────────────────────────────────────── -// Handler: WAITING_WEBHOOKS -// ───────────────────────────────────────────────────────────── - -export async function handleWaitingState({ tenantId, text, nlu, order, audit }) { - const intent = nlu?.intent || "other"; - const currentOrder = order || createEmptyOrder(); - - // view_cart - if (intent === "view_cart") { - const cartDisplay = formatCartForDisplay(currentOrder); - const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago..."; - return { - plan: { - reply: `${cartDisplay}\n\nEstado: ${status}`, - next_state: ConversationState.WAITING_WEBHOOKS, - intent: "view_cart", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Default - const reply = currentOrder.payment_type === "link" - ? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago." - : "Tu pedido está listo. Avisame si necesitás algo más."; - - return { - plan: { - reply, - next_state: ConversationState.WAITING_WEBHOOKS, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; -} - -// ───────────────────────────────────────────────────────────── -// Helpers internos -// ───────────────────────────────────────────────────────────── - -function extractProductQueries(nlu) { - const queries = []; - - // Multi-items - if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) { - for (const item of nlu.entities.items) { - if (item.product_query) { - queries.push({ - query: item.product_query, - quantity: item.quantity, - unit: item.unit, - }); - } - } - return queries; - } - - // Single item - if (nlu?.entities?.product_query) { - queries.push({ - query: nlu.entities.product_query, - quantity: nlu.entities.quantity, - unit: nlu.entities.unit, - }); - } - - return queries; -} - -function createPendingItemFromSearch({ query, quantity, unit, candidates }) { - const cands = (candidates || []).filter(c => c && c.woo_product_id); - - if (cands.length === 0) { - return createPendingItem({ - query, - candidates: [], - status: PendingStatus.NEEDS_TYPE, // Will show "not found" message - }); - } - - // Check for strong match - const best = cands[0]; - const second = cands[1]; - const isStrong = cands.length === 1 || - (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2)); - - if (isStrong) { - // Usar sell_unit del producto si está configurado, sino inferir por heurística - const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); - const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; - const sellsByWeight = displayUnit !== "unit"; - const hasExplicitUnit = unit != null && unit !== ""; - const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit; - const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric); - - return createPendingItem({ - query, - candidates: [], - selected_woo_id: best.woo_product_id, - selected_name: best.name, - selected_price: best.price, - selected_unit: displayUnit, - qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1), - unit: normalizeUnit(unit) || displayUnit, - status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - }); - } - - // Multiple candidates, needs selection - // Guardar la cantidad y unidad que pidió el usuario para usarla después de seleccionar - const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; - return createPendingItem({ - query, - candidates: cands.slice(0, 20).map(c => ({ - woo_id: c.woo_product_id, - name: c.name, - price: c.price, - sell_unit: c.sell_unit || null, - display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), - })), - status: PendingStatus.NEEDS_TYPE, - // Guardar cantidad/unidad pedida para usarla al seleccionar - requested_qty: hasQty ? Number(quantity) : null, - requested_unit: normalizeUnit(unit) || null, - }); -} - -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 o mostrar opciones - if (isShowMoreRequest(text) || isShowOptionsRequest(text)) { - const { question } = formatOptionsForDisplay(pendingItem); - return { - plan: { - reply: question, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; - } - - // 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 - const requestedQty = pendingItem.requested_qty; - const requestedUnit = pendingItem.requested_unit || displayUnit; - const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0; - - // Si vende por peso y no tenemos cantidad, preguntar. Si vende por unidad y tiene cantidad, usar esa. - const sellsByWeight = displayUnit !== "unit"; - const needsQuantity = sellsByWeight && !hasRequestedQty; - - // Determinar la cantidad final - const finalQty = hasRequestedQty ? requestedQty : 1; - const finalUnit = requestedUnit || displayUnit; - - const updatedOrder = updatePendingItem(order, pendingItem.id, { - selected_woo_id: selected.woo_id, - selected_name: selected.name, - selected_price: selected.price, - selected_unit: displayUnit, - candidates: [], - status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - qty: needsQuantity ? null : finalQty, - unit: finalUnit, - }); - - // Si necesita cantidad, preguntar - if (needsQuantity) { - const unitQuestion = unitAskFor(displayUnit); - return { - plan: { - reply: `Para ${selected.name}, ${unitQuestion}`, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order: updatedOrder, audit }, - }; - } - - // Listo, mover al cart - const finalOrder = moveReadyToCart(updatedOrder); - // Formatear la cantidad según la unidad - 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}.\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 }, - }; - } - - // 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, had_previous: pendingItem.candidates?.length || 0 }; - - if (newCandidates.length > 0) { - // Encontramos opciones con la nueva búsqueda - const updatedPending = { - ...pendingItem, - query: newQuery, - candidates: newCandidates.slice(0, 20).map(c => ({ - woo_id: c.woo_product_id, - name: c.name, - price: c.price, - sell_unit: c.sell_unit || null, - display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), - })), - }; - - const updatedOrder = { - ...order, - 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: { - reply: question, - next_state: ConversationState.CART, - intent: "add_to_cart", - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order: updatedOrder, audit }, - }; - } - - // 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, - }, - }); - } - - // 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ó (no es número, no es texto largo), volver a preguntar - const { question } = formatOptionsForDisplay(pendingItem); - return { - plan: { - reply: "No entendí. " + question, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["product_selection"], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; - } - - // Si necesita cantidad - if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) { - const qty = nlu?.entities?.quantity; - const unit = nlu?.entities?.unit; - - // Try to parse quantity from text - let parsedQty = qty; - if (parsedQty == null) { - const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || ""); - if (m) { - parsedQty = parseFloat(m[1].replace(",", ".")); - } - } - - if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) { - const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg"; - const updatedOrder = updatePendingItem(order, pendingItem.id, { - qty: parsedQty, - unit: finalUnit, - status: PendingStatus.READY, - }); - - 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}.\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 }, - }; - } - - // Detectar "para X personas" y calcular cantidad automáticamente - const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || - /\bpara\s+(\d+)\b/i.exec(text || "") || - /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); - - if (personasMatch && pendingItem.selected_woo_id) { - const peopleCount = parseInt(personasMatch[1], 10); - - if (peopleCount > 0 && peopleCount <= 100) { - // Buscar reglas de cantidad por persona para este producto - let qtyRules = []; - try { - qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id }); - } catch (e) { - audit.qty_rules_error = e?.message; - } - - // Buscar regla para evento "asado" o genérica (null) - const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") || - qtyRules.find(r => r.event_type === null && r.person_type === "adult") || - qtyRules.find(r => r.person_type === "adult") || - qtyRules[0]; - - let calculatedQty; - let calculatedUnit = pendingItem.selected_unit || "kg"; - - if (rule && rule.qty_per_person > 0) { - // Usar regla de BD - calculatedQty = rule.qty_per_person * peopleCount; - calculatedUnit = rule.unit || calculatedUnit; - audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit }; - } else { - // Fallback: 300g por persona para productos por peso - const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; - calculatedQty = fallbackPerPerson * peopleCount; - audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit }; - } - - // Redondear a 1 decimal para kg, entero para unidades - if (calculatedUnit === "unit") { - calculatedQty = Math.ceil(calculatedQty); - } else { - calculatedQty = Math.round(calculatedQty * 10) / 10; - } - - const updatedOrder = updatePendingItem(order, pendingItem.id, { - qty: calculatedQty, - unit: calculatedUnit, - status: PendingStatus.READY, - }); - - 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}.\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 }, - }; - } - } - - // No entendió cantidad - const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); - return { - plan: { - reply: `No entendí la cantidad. ${unitQuestion}`, - next_state: ConversationState.CART, - intent: "other", - missing_fields: ["quantity"], - order_action: "none", - }, - decision: { actions: [], order, audit }, - }; - } - - return null; -} + // Helpers de carrito + extractProductQueries, + createPendingItemFromSearch, + processPendingClarification, +} from "./stateHandlers/index.js"; diff --git a/src/modules/3-turn-engine/stateHandlers/cart.js b/src/modules/3-turn-engine/stateHandlers/cart.js new file mode 100644 index 0000000..79f099d --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/cart.js @@ -0,0 +1,554 @@ +/** + * Handler para el estado CART + * Maneja: view_cart, remove_from_cart, confirm_order, recommend, price_query, add_to_cart + */ + +import { retrieveCandidates } from "../catalogRetrieval.js"; +import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "../fsm.js"; +import { + createEmptyOrder, + PendingStatus, + moveReadyToCart, + getNextPendingItem, + updatePendingItem, + addPendingItem, + formatCartForDisplay, + formatOptionsForDisplay, + removeCartItem, +} from "../orderModel.js"; +import { handleRecommend } from "../recommendations.js"; +import { getProductQtyRules } from "../../0-ui/db/repo.js"; +import { inferDefaultUnit, unitAskFor } from "./utils.js"; +import { + extractProductQueries, + createPendingItemFromSearch, + processPendingClarification +} from "./cartHelpers.js"; + +/** + * Maneja el estado CART (carrito activo) + */ +export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) { + const intent = nlu?.intent || "other"; + let currentOrder = order || createEmptyOrder(); + + // Intents que tienen prioridad sobre pending items + const priorityIntents = ["view_cart", "confirm_order", "greeting"]; + const isPriorityIntent = priorityIntents.includes(intent); + + // Detectar si el usuario quiere cancelar/saltar el pending item actual + const pendingItem = getNextPendingItem(currentOrder); + 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 - PERO solo si NO es un intent prioritario + if (wantsToSkipPending && pendingItem && !isPriorityIntent) { + return handleSkipPending({ currentOrder, pendingItem, audit }); + } + + // 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación + if (pendingItem && !isPriorityIntent) { + const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit }); + if (result) return result; + } + + // 2) view_cart: mostrar carrito actual + if (intent === "view_cart") { + return handleViewCart({ currentOrder }); + } + + // 2.5) remove_from_cart: quitar productos del carrito + if (intent === "remove_from_cart") { + return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }); + } + + // 3) confirm_order: ir a SHIPPING si hay items + if (intent === "confirm_order") { + return handleConfirmOrder({ currentOrder, audit }); + } + + // 4) recommend + if (intent === "recommend") { + const result = await handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit }); + if (result) return result; + } + + // 4.5) price_query - consulta de precios + if (intent === "price_query") { + return handlePriceQuery({ tenantId, nlu, currentOrder, audit }); + } + + // 5) add_to_cart / browse: buscar productos + if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) { + return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }); + } + + // Default + return { + plan: { + reply: "¿Qué más querés agregar?", + next_state: ConversationState.CART, + intent: "other", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; +} + +/** + * Maneja el skip de un pending item + */ +function handleSkipPending({ currentOrder, pendingItem, audit }) { + const updatedOrder = { + ...currentOrder, + pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id), + }; + audit.skipped_pending = pendingItem.query; + + const nextPending = getNextPendingItem(updatedOrder); + if (nextPending) { + const { question } = formatOptionsForDisplay(nextPending); + return { + plan: { + reply: `Ok, salteo "${pendingItem.query}". ${question}`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order: updatedOrder, audit }, + }; + } + + const cartDisplay = formatCartForDisplay(updatedOrder); + return { + plan: { + reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`, + next_state: ConversationState.CART, + intent: "other", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: updatedOrder, audit }, + }; +} + +/** + * Maneja view_cart + */ +function handleViewCart({ currentOrder }) { + const cartDisplay = formatCartForDisplay(currentOrder); + const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0; + let reply = cartDisplay; + if (pendingCount > 0) { + reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`; + } + reply += "\n\n¿Algo más?"; + + return { + plan: { + reply, + next_state: ConversationState.CART, + intent: "view_cart", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit: {} }, + }; +} + +/** + * Maneja remove_from_cart + */ +async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }) { + const items = nlu?.entities?.items || []; + const removedItems = []; + const addedItems = []; + const notFoundItems = []; + let updatedOrder = currentOrder; + + for (const item of items) { + if (!item.product_query) continue; + + 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); + } + + if (item.quantity && item.quantity > 0) { + addedItems.push({ query: item.product_query, qty: item.quantity, unit: item.unit }); + } + } + + // Si hay items para agregar + 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. `; + } + + const nextPending = getNextPendingItem(updatedOrder); + if (nextPending && 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 + }, + }; + } + + 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 + }, + }; +} + +/** + * Maneja confirm_order + */ +function handleConfirmOrder({ currentOrder, audit }) { + let order = moveReadyToCart(currentOrder); + + if (!hasCartItems(order)) { + return { + plan: { + reply: "Tu carrito está vacío. ¿Qué querés agregar?", + next_state: ConversationState.CART, + intent: "confirm_order", + missing_fields: ["cart_items"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; + } + + if (hasPendingItems(order)) { + const nextPending = getNextPendingItem(order); + const { question } = formatOptionsForDisplay(nextPending); + return { + plan: { + reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`, + next_state: ConversationState.CART, + intent: "confirm_order", + missing_fields: ["pending_items"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; + } + + const { next_state } = safeNextState(ConversationState.CART, order, { confirm_order: true }); + return { + plan: { + reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", + next_state, + intent: "confirm_order", + missing_fields: ["shipping_method"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; +} + +/** + * Maneja recommend + */ +async function handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit }) { + try { + const recoResult = await handleRecommend({ + tenantId, + text, + nlu, + order: currentOrder, + prevContext: { order: currentOrder }, + audit + }); + if (recoResult?.plan?.reply) { + const newOrder = recoResult.decision?.order || currentOrder; + const contextPatch = recoResult.decision?.context_patch || {}; + return { + plan: { + ...recoResult.plan, + next_state: ConversationState.CART, + }, + decision: { + actions: recoResult.decision?.actions || [], + order: newOrder, + audit, + context_patch: contextPatch, + }, + }; + } + } catch (e) { + audit.recommend_error = String(e?.message || e); + } + return null; +} + +/** + * Maneja price_query + */ +async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { + const productQueries = extractProductQueries(nlu); + + if (productQueries.length === 0) { + return { + plan: { + reply: "¿De qué producto querés saber el precio?", + next_state: ConversationState.CART, + intent: "price_query", + missing_fields: ["product_query"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + const priceResults = []; + for (const pq of productQueries.slice(0, 5)) { + const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 }); + const candidates = searchResult?.candidates || []; + audit.price_search = audit.price_search || []; + audit.price_search.push({ query: pq.query, count: candidates.length }); + + for (const c of candidates.slice(0, 2)) { + const unit = inferDefaultUnit({ name: c.name, categories: c.categories }); + const priceStr = c.price != null ? `$${c.price}` : "consultar"; + const unitStr = unit === "unit" ? "/unidad" : "/kg"; + priceResults.push(`• ${c.name}: ${priceStr}${unitStr}`); + } + } + + if (priceResults.length === 0) { + return { + plan: { + reply: "No encontré ese producto. ¿Podés ser más específico?", + next_state: ConversationState.CART, + intent: "price_query", + missing_fields: ["product_query"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?"; + return { + plan: { + reply, + next_state: ConversationState.CART, + intent: "price_query", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; +} + +/** + * Maneja add_to_cart / browse + */ +async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }) { + const productQueries = extractProductQueries(nlu); + + if (productQueries.length === 0) { + return { + plan: { + reply: "¿Qué producto querés agregar?", + next_state: ConversationState.CART, + intent, + missing_fields: ["product_query"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + let order = currentOrder; + + // Buscar candidatos para cada query + for (const pq of productQueries) { + const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 }); + const candidates = searchResult?.candidates || []; + audit.catalog_search = audit.catalog_search || []; + audit.catalog_search.push({ query: pq.query, count: candidates.length }); + + const pendingItem = createPendingItemFromSearch({ + query: pq.query, + quantity: pq.quantity, + unit: pq.unit, + candidates, + }); + + order = addPendingItem(order, pendingItem); + } + + order = moveReadyToCart(order); + + // Si hay pending items, pedir clarificación del primero + const nextPending = getNextPendingItem(order); + if (nextPending) { + if (nextPending.status === PendingStatus.NEEDS_TYPE) { + const { question } = formatOptionsForDisplay(nextPending); + return { + plan: { + reply: question, + next_state: ConversationState.CART, + intent, + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; + } + if (nextPending.status === PendingStatus.NEEDS_QUANTITY) { + return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }); + } + } + + // Todo resuelto, confirmar agregado + const lastAdded = order.cart[order.cart.length - 1]; + if (lastAdded) { + const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`; + const cartSummary = formatCartForDisplay(order); + return { + plan: { + 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: [], + order_action: "add_to_cart", + }, + decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order, audit }, + }; + } + + return { + plan: { + reply: "¿Qué más querés agregar?", + next_state: ConversationState.CART, + intent: "other", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; +} + +/** + * Maneja cuando se necesita cantidad + */ +async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }) { + // Detectar "para X personas" en el texto original + const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || + /\bpara\s+(\d+)\b/i.exec(text || "") || + /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); + + if (personasMatch && nextPending.selected_woo_id) { + const peopleCount = parseInt(personasMatch[1], 10); + + if (peopleCount > 0 && peopleCount <= 100) { + let qtyRules = []; + try { + qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id }); + } catch (e) { + audit.qty_rules_error = e?.message; + } + + let calculatedQty; + let calculatedUnit = nextPending.selected_unit || "kg"; + const rule = qtyRules[0]; + + if (rule && rule.qty_per_person > 0) { + calculatedQty = rule.qty_per_person * peopleCount; + calculatedUnit = rule.unit || calculatedUnit; + audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount }; + } else { + calculatedQty = 0.3 * peopleCount; + audit.qty_fallback = { default_per_person: 0.3, people: peopleCount }; + } + + const updatedOrder = updatePendingItem(order, nextPending.id, { + qty: calculatedQty, + unit: calculatedUnit, + status: PendingStatus.READY, + }); + + 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}.\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 }, + }; + } + } + + // Si no hay "para X personas", preguntar cantidad normalmente + const unitQuestion = unitAskFor(nextPending.selected_unit || "kg"); + return { + plan: { + reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`, + next_state: ConversationState.CART, + intent, + missing_fields: ["quantity"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js new file mode 100644 index 0000000..73acc53 --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js @@ -0,0 +1,538 @@ +/** + * Helpers específicos para el manejo del carrito + * - Extracción de queries de productos + * - Creación de pending items + * - Procesamiento de clarificaciones + */ + +import { retrieveCandidates } from "../catalogRetrieval.js"; +import { ConversationState } from "../fsm.js"; +import { + createPendingItem, + PendingStatus, + moveReadyToCart, + updatePendingItem, + formatCartForDisplay, + formatOptionsForDisplay, +} from "../orderModel.js"; +import { getProductQtyRules } from "../../0-ui/db/repo.js"; +import { createHumanTakeoverResponse } from "../nlu/humanFallback.js"; +import { + inferDefaultUnit, + parseIndexSelection, + isShowMoreRequest, + isShowOptionsRequest, + findMatchingCandidate, + isEscapeRequest, + normalizeUnit, + unitAskFor, +} from "./utils.js"; + +/** + * Extrae queries de productos del resultado NLU + */ +export function extractProductQueries(nlu) { + const queries = []; + + // Multi-items + if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) { + for (const item of nlu.entities.items) { + if (item.product_query) { + queries.push({ + query: item.product_query, + quantity: item.quantity, + unit: item.unit, + }); + } + } + return queries; + } + + // Single item + if (nlu?.entities?.product_query) { + queries.push({ + query: nlu.entities.product_query, + quantity: nlu.entities.quantity, + unit: nlu.entities.unit, + }); + } + + return queries; +} + +/** + * Crea un pending item a partir de los resultados de búsqueda + */ +export function createPendingItemFromSearch({ query, quantity, unit, candidates }) { + const cands = (candidates || []).filter(c => c && c.woo_product_id); + + if (cands.length === 0) { + return createPendingItem({ + query, + candidates: [], + status: PendingStatus.NEEDS_TYPE, + }); + } + + // Check for strong match + const best = cands[0]; + const second = cands[1]; + const isStrong = cands.length === 1 || + (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2)); + + if (isStrong) { + const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); + const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; + const sellsByWeight = displayUnit !== "unit"; + const hasExplicitUnit = unit != null && unit !== ""; + const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit; + const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric); + + return createPendingItem({ + query, + candidates: [], + selected_woo_id: best.woo_product_id, + selected_name: best.name, + selected_price: best.price, + selected_unit: displayUnit, + qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1), + unit: normalizeUnit(unit) || displayUnit, + status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, + }); + } + + // Multiple candidates, needs selection + const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; + return createPendingItem({ + query, + candidates: cands.slice(0, 20).map(c => ({ + woo_id: c.woo_product_id, + name: c.name, + price: c.price, + sell_unit: c.sell_unit || null, + display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), + })), + status: PendingStatus.NEEDS_TYPE, + requested_qty: hasQty ? Number(quantity) : null, + requested_unit: normalizeUnit(unit) || null, + }); +} + +/** + * Procesa la clarificación de un pending item + * Retorna un resultado si se pudo procesar, null si debe escapar al handler principal + */ +export 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; + } + + // 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) { + return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }); + } + + // Si necesita cantidad + if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) { + return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }); + } + + return null; +} + +/** + * Procesa la selección de tipo de producto + */ +async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }) { + const idx = parseIndexSelection(text); + + // Show more o mostrar opciones + if (isShowMoreRequest(text) || isShowOptionsRequest(text)) { + const { question } = formatOptionsForDisplay(pendingItem); + return { + plan: { + reply: question, + next_state: ConversationState.CART, + intent: "other", + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; + } + + // Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar + const textMatch = !idx && pendingItem.candidates?.length > 0 + ? findMatchingCandidate(pendingItem.candidates, text) + : null; + + const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null); + + // Selection by index (o por match de texto) + if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) { + return processIndexSelection({ order, pendingItem, effectiveIdx, audit }); + } + + // Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar + const isNumberSelection = idx !== null; + const hadTextMatch = effectiveIdx !== null && !idx; + const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2; + + if (isTextClarification) { + return processTextClarification({ tenantId, text, order, pendingItem, audit }); + } + + // No entendió (no es número, no es texto largo), volver a preguntar + const { question } = formatOptionsForDisplay(pendingItem); + return { + plan: { + reply: "No entendí. " + question, + next_state: ConversationState.CART, + intent: "other", + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; +} + +/** + * Procesa selección por índice + */ +function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) { + const selected = pendingItem.candidates[effectiveIdx - 1]; + const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); + + const requestedQty = pendingItem.requested_qty; + const requestedUnit = pendingItem.requested_unit || displayUnit; + const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0; + + const sellsByWeight = displayUnit !== "unit"; + const needsQuantity = sellsByWeight && !hasRequestedQty; + + const finalQty = hasRequestedQty ? requestedQty : 1; + const finalUnit = requestedUnit || displayUnit; + + const updatedOrder = updatePendingItem(order, pendingItem.id, { + selected_woo_id: selected.woo_id, + selected_name: selected.name, + selected_price: selected.price, + selected_unit: displayUnit, + candidates: [], + status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, + qty: needsQuantity ? null : finalQty, + unit: finalUnit, + }); + + if (needsQuantity) { + const unitQuestion = unitAskFor(displayUnit); + return { + plan: { + reply: `Para ${selected.name}, ${unitQuestion}`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: ["quantity"], + order_action: "none", + }, + decision: { actions: [], order: updatedOrder, audit }, + }; + } + + const finalOrder = moveReadyToCart(updatedOrder); + 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}.\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 }, + }; +} + +/** + * Procesa clarificación por texto libre (re-búsqueda) + */ +async function processTextClarification({ tenantId, text, order, pendingItem, audit }) { + 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, had_previous: pendingItem.candidates?.length || 0 }; + + if (newCandidates.length > 0) { + const updatedPending = { + ...pendingItem, + query: newQuery, + candidates: newCandidates.slice(0, 20).map(c => ({ + woo_id: c.woo_product_id, + name: c.name, + price: c.price, + sell_unit: c.sell_unit || null, + display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), + })), + }; + + const updatedOrder = { + ...order, + pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p), + }; + + // Si hay match fuerte, seleccionar automáticamente + if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) { + return processStrongMatch({ updatedOrder, pendingItem, best: newCandidates[0], audit }); + } + + // Múltiples candidatos, mostrar opciones + const { question } = formatOptionsForDisplay(updatedPending); + return { + plan: { + reply: question, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order: updatedOrder, audit }, + }; + } + + // No encontró nada con la nueva búsqueda + 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, + }, + }); + } + + // 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 }, + }; +} + +/** + * Procesa un match fuerte (selección automática) + */ +function processStrongMatch({ updatedOrder, pendingItem, best, audit }) { + 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 }, + }; +} + +/** + * Procesa input de cantidad + */ +async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }) { + const qty = nlu?.entities?.quantity; + const unit = nlu?.entities?.unit; + + // Try to parse quantity from text + let parsedQty = qty; + if (parsedQty == null) { + const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || ""); + if (m) { + parsedQty = parseFloat(m[1].replace(",", ".")); + } + } + + if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) { + const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg"; + const updatedOrder = updatePendingItem(order, pendingItem.id, { + qty: parsedQty, + unit: finalUnit, + status: PendingStatus.READY, + }); + + 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}.\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 }, + }; + } + + // Detectar "para X personas" y calcular cantidad automáticamente + const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || + /\bpara\s+(\d+)\b/i.exec(text || "") || + /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); + + if (personasMatch && pendingItem.selected_woo_id) { + return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch }); + } + + // No entendió cantidad + const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); + return { + plan: { + reply: `No entendí la cantidad. ${unitQuestion}`, + next_state: ConversationState.CART, + intent: "other", + missing_fields: ["quantity"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; +} + +/** + * Procesa cantidad para X personas + */ +async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch }) { + const peopleCount = parseInt(personasMatch[1], 10); + + if (peopleCount <= 0 || peopleCount > 100) { + const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); + return { + plan: { + reply: `No entendí la cantidad. ${unitQuestion}`, + next_state: ConversationState.CART, + intent: "other", + missing_fields: ["quantity"], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; + } + + // Buscar reglas de cantidad por persona para este producto + let qtyRules = []; + try { + qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id }); + } catch (e) { + audit.qty_rules_error = e?.message; + } + + const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") || + qtyRules.find(r => r.event_type === null && r.person_type === "adult") || + qtyRules.find(r => r.person_type === "adult") || + qtyRules[0]; + + let calculatedQty; + let calculatedUnit = pendingItem.selected_unit || "kg"; + + if (rule && rule.qty_per_person > 0) { + calculatedQty = rule.qty_per_person * peopleCount; + calculatedUnit = rule.unit || calculatedUnit; + audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit }; + } else { + const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; + calculatedQty = fallbackPerPerson * peopleCount; + audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit }; + } + + // Redondear + if (calculatedUnit === "unit") { + calculatedQty = Math.ceil(calculatedQty); + } else { + calculatedQty = Math.round(calculatedQty * 10) / 10; + } + + const updatedOrder = updatePendingItem(order, pendingItem.id, { + qty: calculatedQty, + unit: calculatedUnit, + status: PendingStatus.READY, + }); + + 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}.\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 }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers/idle.js b/src/modules/3-turn-engine/stateHandlers/idle.js new file mode 100644 index 0000000..78939cd --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/idle.js @@ -0,0 +1,44 @@ +/** + * Handler para el estado IDLE + */ + +import { ConversationState } from "../fsm.js"; +import { handleCartState } from "./cart.js"; + +/** + * Maneja el estado IDLE (inicio de conversación) + */ +export async function handleIdleState({ tenantId, text, nlu, order, audit }) { + const intent = nlu?.intent || "other"; + + // Greeting + if (intent === "greeting") { + return { + plan: { + reply: "¡Hola! ¿En qué te puedo ayudar hoy?", + next_state: ConversationState.IDLE, + intent: "greeting", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; + } + + // Cualquier intent relacionado con productos → ir a CART + if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) { + return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true }); + } + + // Other + return { + plan: { + reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.", + next_state: ConversationState.IDLE, + intent: "other", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order, audit }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers/index.js b/src/modules/3-turn-engine/stateHandlers/index.js new file mode 100644 index 0000000..2ff2a92 --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/index.js @@ -0,0 +1,32 @@ +/** + * State Handlers - Punto de entrada + * + * Re-exporta todos los handlers y utilidades para mantener + * compatibilidad con imports existentes. + */ + +// Handlers por estado +export { handleIdleState } from "./idle.js"; +export { handleCartState } from "./cart.js"; +export { handleShippingState } from "./shipping.js"; +export { handlePaymentState } from "./payment.js"; +export { handleWaitingState } from "./waiting.js"; + +// Utilidades (para uso interno principalmente) +export { + inferDefaultUnit, + parseIndexSelection, + isShowMoreRequest, + isShowOptionsRequest, + findMatchingCandidate, + isEscapeRequest, + normalizeUnit, + unitAskFor, +} from "./utils.js"; + +// Helpers de carrito (para uso interno principalmente) +export { + extractProductQueries, + createPendingItemFromSearch, + processPendingClarification, +} from "./cartHelpers.js"; diff --git a/src/modules/3-turn-engine/stateHandlers/payment.js b/src/modules/3-turn-engine/stateHandlers/payment.js new file mode 100644 index 0000000..7bd7060 --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/payment.js @@ -0,0 +1,87 @@ +/** + * Handler para el estado PAYMENT + */ + +import { ConversationState, safeNextState } from "../fsm.js"; +import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; +import { parseIndexSelection } from "./utils.js"; + +/** + * Maneja el estado PAYMENT (selección de método de pago) + */ +export async function handlePaymentState({ tenantId, text, nlu, order, audit }) { + const intent = nlu?.intent || "other"; + let currentOrder = order || createEmptyOrder(); + const actions = []; + + // Detectar selección de pago + let paymentMethod = nlu?.entities?.payment_method; + + if (!paymentMethod) { + const t = String(text || "").toLowerCase(); + const idx = parseIndexSelection(text); + if (idx === 1 || /efectivo|cash|plata/i.test(t)) { + paymentMethod = "cash"; + } else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) { + paymentMethod = "link"; + } + } + + if (paymentMethod) { + currentOrder = { ...currentOrder, payment_type: paymentMethod }; + actions.push({ type: "create_order", payload: { payment: paymentMethod } }); + + if (paymentMethod === "link") { + actions.push({ type: "send_payment_link", payload: {} }); + } + + const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true }); + + const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago"; + const deliveryInfo = currentOrder.is_delivery + ? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.` + : "Retiro en sucursal."; + + const paymentInfo = paymentMethod === "link" + ? "Te paso el link de pago en un momento." + : "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + "."; + + return { + plan: { + reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`, + next_state, + intent: "select_payment", + missing_fields: [], + order_action: "create_order", + }, + decision: { actions, order: currentOrder, audit }, + }; + } + + // view_cart + if (intent === "view_cart") { + const cartDisplay = formatCartForDisplay(currentOrder); + return { + plan: { + reply: cartDisplay + "\n\n¿Cómo preferís pagar?", + next_state: ConversationState.PAYMENT, + intent: "view_cart", + missing_fields: ["payment_method"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // Default + return { + plan: { + reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", + next_state: ConversationState.PAYMENT, + intent: "other", + missing_fields: ["payment_method"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers/shipping.js b/src/modules/3-turn-engine/stateHandlers/shipping.js new file mode 100644 index 0000000..ef29810 --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/shipping.js @@ -0,0 +1,120 @@ +/** + * Handler para el estado SHIPPING + */ + +import { ConversationState, safeNextState } from "../fsm.js"; +import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; +import { parseIndexSelection } from "./utils.js"; + +/** + * Maneja el estado SHIPPING (selección de envío) + */ +export async function handleShippingState({ tenantId, text, nlu, order, audit }) { + const intent = nlu?.intent || "other"; + let currentOrder = order || createEmptyOrder(); + + // Detectar selección de shipping (delivery/pickup) + let shippingMethod = nlu?.entities?.shipping_method; + + // Detectar por número o texto + if (!shippingMethod) { + const t = String(text || "").toLowerCase(); + const idx = parseIndexSelection(text); + if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) { + shippingMethod = "delivery"; + } else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) { + shippingMethod = "pickup"; + } + } + + if (shippingMethod) { + currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; + + if (shippingMethod === "pickup") { + const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); + return { + plan: { + reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", + next_state, + intent: "select_shipping", + missing_fields: ["payment_method"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // Delivery: pedir dirección si no la tiene + if (!currentOrder.shipping_address) { + return { + plan: { + reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?", + next_state: ConversationState.SHIPPING, + intent: "select_shipping", + missing_fields: ["address"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + } + + // Si ya eligió delivery y ahora da dirección + if (currentOrder.is_delivery === true && !currentOrder.shipping_address) { + const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null); + + if (address) { + currentOrder = { ...currentOrder, shipping_address: address }; + const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); + + return { + plan: { + reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`, + next_state, + intent: "provide_address", + missing_fields: ["payment_method"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + return { + plan: { + reply: "Necesito la dirección de entrega. ¿Me la pasás?", + next_state: ConversationState.SHIPPING, + intent: "other", + missing_fields: ["address"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // view_cart + if (intent === "view_cart") { + const cartDisplay = formatCartForDisplay(currentOrder); + return { + plan: { + reply: cartDisplay + "\n\n¿Es para delivery o retiro?", + next_state: ConversationState.SHIPPING, + intent: "view_cart", + missing_fields: ["shipping_method"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // Default: preguntar de nuevo + return { + plan: { + reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", + next_state: ConversationState.SHIPPING, + intent: "other", + missing_fields: ["shipping_method"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers/utils.js b/src/modules/3-turn-engine/stateHandlers/utils.js new file mode 100644 index 0000000..b3bc60b --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/utils.js @@ -0,0 +1,147 @@ +/** + * Utilidades compartidas para los state handlers + */ + +/** + * Infiere la unidad por defecto basándose en el nombre y categorías del producto + */ +export function inferDefaultUnit({ name, categories }) { + const n = String(name || "").toLowerCase(); + const cats = Array.isArray(categories) ? categories : []; + const hay = (re) => + cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); + if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) { + return "unit"; + } + if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) { + return "unit"; + } + if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { + return "unit"; + } + return "kg"; +} + +/** + * Parsea selección por índice del texto (números o palabras como "primero", "segundo") + */ +export function parseIndexSelection(text) { + const t = String(text || "").toLowerCase(); + const m = /\b(\d{1,2})\b/.exec(t); + if (m) return parseInt(m[1], 10); + if (/\bprimera\b|\bprimero\b/.test(t)) return 1; + if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2; + if (/\btercera\b|\btercero\b/.test(t)) return 3; + if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4; + if (/\bquinta\b|\bquinto\b/.test(t)) return 5; + if (/\bsexta\b|\bsexto\b/.test(t)) return 6; + if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7; + if (/\boctava\b|\boctavo\b/.test(t)) return 8; + if (/\bnovena\b|\bnoveno\b/.test(t)) return 9; + if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10; + return null; +} + +/** + * Detecta si el usuario pide ver más opciones + */ +export function isShowMoreRequest(text) { + const t = String(text || "").toLowerCase(); + return ( + /\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) || + /\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) + ); +} + +/** + * Detecta si el usuario pide ver las opciones disponibles + */ +export 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) + ); +} + +/** + * Busca un candidato que coincida con el texto del usuario (fuzzy match) + */ +export 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; +} + +/** + * Detecta si el texto indica un intent de escape (ver carrito, confirmar, etc.) + */ +export 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) + ); +} + +/** + * Normaliza unidades a formato estándar + */ +export function normalizeUnit(unit) { + if (!unit) return null; + const u = String(unit).toLowerCase(); + if (u === "kg" || u === "kilo" || u === "kilos") return "kg"; + if (u === "g" || u === "gramo" || u === "gramos") return "g"; + if (u === "unidad" || u === "unidades" || u === "unit") return "unit"; + return null; +} + +/** + * Genera la pregunta para pedir cantidad según la unidad + */ +export function unitAskFor(displayUnit) { + if (displayUnit === "unit") return "¿Cuántas unidades querés?"; + if (displayUnit === "g") return "¿Cuántos gramos querés?"; + return "¿Cuántos kilos querés?"; +} diff --git a/src/modules/3-turn-engine/stateHandlers/waiting.js b/src/modules/3-turn-engine/stateHandlers/waiting.js new file mode 100644 index 0000000..8e23c02 --- /dev/null +++ b/src/modules/3-turn-engine/stateHandlers/waiting.js @@ -0,0 +1,46 @@ +/** + * Handler para el estado WAITING_WEBHOOKS + */ + +import { ConversationState } from "../fsm.js"; +import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; + +/** + * Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago) + */ +export async function handleWaitingState({ tenantId, text, nlu, order, audit }) { + const intent = nlu?.intent || "other"; + const currentOrder = order || createEmptyOrder(); + + // view_cart + if (intent === "view_cart") { + const cartDisplay = formatCartForDisplay(currentOrder); + const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago..."; + return { + plan: { + reply: `${cartDisplay}\n\nEstado: ${status}`, + next_state: ConversationState.WAITING_WEBHOOKS, + intent: "view_cart", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // Default + const reply = currentOrder.payment_type === "link" + ? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago." + : "Tu pedido está listo. Avisame si necesitás algo más."; + + return { + plan: { + reply, + next_state: ConversationState.WAITING_WEBHOOKS, + intent: "other", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; +}