productos, equivalencias, cross-sell y cantidades

This commit is contained in:
Lucas Tettamanti
2026-01-18 18:28:28 -03:00
parent 8cc4744c49
commit c7c56ddbfc
32 changed files with 4083 additions and 2073 deletions

View File

@@ -6,6 +6,7 @@ import {
searchProductAliases,
getProductEmbedding,
upsertProductEmbedding,
getAllAliasProductMappings,
} from "../2-identity/db/repo.js";
function getOpenAiKey() {
@@ -141,6 +142,37 @@ export async function retrieveCandidates({
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
const aliasBoostByProduct = new Map();
const aliasProductIds = new Set();
// También buscar en alias_product_mappings (multi-producto)
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
const normalizedQuery = normalizeText(q);
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
// Buscar mappings cuyos aliases matcheen la query
for (const mapping of allMappings) {
const aliasNorm = normalizeText(mapping.alias);
// Match exacto o parcial del alias
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
aliasProductIds.add(id);
} else {
// Check word overlap
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
for (const word of queryWords) {
if (aliasWords.has(word)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
aliasProductIds.add(id);
break;
}
}
}
}
// También incluir aliases legacy (product_aliases.woo_product_id)
for (const a of aliases) {
if (a?.woo_product_id) {
const id = Number(a.woo_product_id);
@@ -150,6 +182,7 @@ export async function retrieveCandidates({
}
}
audit.sources.aliases = aliases.length;
audit.sources.alias_mappings = aliasProductIds.size;
// 2) Buscar productos por nombre/slug (búsqueda literal)
const { items: wooItems, source: wooSource } = await searchSnapshotItems({

View File

@@ -1,219 +1,193 @@
/**
* FSM autoritativa (server-side) para el flujo conversacional.
* FSM simplificada para el flujo conversacional.
*
* Principios:
* - El LLM NO decide estados. Solo NLU.
* - El backend deriva el estado objetivo a partir del contexto + acciones.
* - Validamos transiciones y, si algo queda inconsistente, caemos a ERROR_RECOVERY.
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*/
export const ConversationState = Object.freeze({
IDLE: "IDLE",
BROWSING: "BROWSING",
CLARIFYING_ITEMS: "CLARIFYING_ITEMS", // Clarificando items pendientes uno por uno
AWAITING_QUANTITY: "AWAITING_QUANTITY",
CART_ACTIVE: "CART_ACTIVE",
CLARIFYING_PAYMENT: "CLARIFYING_PAYMENT", // Preguntando método de pago (efectivo/link)
CLARIFYING_SHIPPING: "CLARIFYING_SHIPPING", // Preguntando delivery o retiro
AWAITING_ADDRESS: "AWAITING_ADDRESS",
AWAITING_PAYMENT: "AWAITING_PAYMENT",
COMPLETED: "COMPLETED",
ERROR_RECOVERY: "ERROR_RECOVERY",
CART: "CART",
SHIPPING: "SHIPPING",
PAYMENT: "PAYMENT",
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
});
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
function hasBasketItems(ctx) {
const items = ctx?.basket?.items || ctx?.order_basket?.items;
return Array.isArray(items) && items.length > 0;
}
// Intents válidos por estado
export const INTENTS_BY_STATE = Object.freeze({
[ConversationState.IDLE]: [
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
],
[ConversationState.CART]: [
"add_to_cart", "remove_from_cart", "browse", "price_query",
"recommend", "view_cart", "confirm_order", "other"
],
[ConversationState.SHIPPING]: [
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
],
[ConversationState.PAYMENT]: [
"select_payment", "add_to_cart", "view_cart", "other"
],
[ConversationState.WAITING_WEBHOOKS]: [
"add_to_cart", "view_cart", "other"
],
});
function hasPendingClarification(ctx) {
const pc = ctx?.pending_clarification;
return Boolean(pc?.candidates?.length) || Boolean(pc?.options?.length);
}
function hasPendingItem(ctx) {
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
/**
* Verifica si el usuario quiere agregar productos (debe volver a CART).
*/
export function shouldReturnToCart(state, nlu) {
if (state === ConversationState.CART || state === ConversationState.IDLE) {
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
}
const intent = nlu?.intent;
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
return true;
}
// Si hay menciones de producto en entities
if (nlu?.entities?.product_query) return true;
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) return true;
return false;
}
/**
* Verifica si hay items pendientes de clarificar (nuevo modelo acumulativo).
* Un item pendiente tiene status "needs_type" o "needs_quantity".
* Helpers para verificar estado de la orden
*/
function hasPendingItems(ctx) {
const items = ctx?.pending_items;
if (!Array.isArray(items) || items.length === 0) return false;
return items.some(i => i.status === "needs_type" || i.status === "needs_quantity");
export function hasCartItems(order) {
return Array.isArray(order?.cart) && order.cart.length > 0;
}
function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
export function hasPendingItems(order) {
if (!Array.isArray(order?.pending) || order.pending.length === 0) return false;
return order.pending.some(i => i.status === "NEEDS_TYPE" || i.status === "NEEDS_QUANTITY");
}
function hasWooOrder(ctx) {
return Boolean(ctx?.woo_order_id || ctx?.last_order_id);
export function hasReadyPendingItems(order) {
if (!Array.isArray(order?.pending)) return false;
return order.pending.some(i => i.status === "READY");
}
function hasPaymentLink(ctx) {
return Boolean(ctx?.mp?.init_point || ctx?.payment?.init_point || ctx?.payment_link);
export function hasShippingInfo(order) {
if (order?.is_delivery === false) return true; // Pickup no necesita dirección
if (order?.is_delivery === true && order?.shipping_address) return true;
return false;
}
function isPaid(ctx) {
const st =
ctx?.mp?.payment_status ||
ctx?.payment?.status ||
ctx?.payment_status ||
null;
return st === "approved" || st === "paid";
export function hasPaymentInfo(order) {
return order?.payment_type === "cash" || order?.payment_type === "link";
}
export function isPaid(order) {
return order?.is_paid === true;
}
/**
* Verifica si estamos clarificando método de pago.
* Deriva el siguiente estado basado en el contexto y signals.
*
* signals: {
* confirm_order: boolean, // Usuario quiere cerrar pedido
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
* payment_selected: boolean, // Usuario seleccionó método de pago
* return_to_cart: boolean, // Forzar volver a CART
* }
*/
function isClarifyingPayment(ctx) {
return ctx?.checkout_step === "payment_method";
}
/**
* Verifica si estamos clarificando shipping (delivery/retiro).
*/
function isClarifyingShipping(ctx) {
return ctx?.checkout_step === "shipping_method";
}
/**
* Verifica si ya se eligió método de pago.
*/
function hasPaymentMethod(ctx) {
return Boolean(ctx?.payment_method); // "cash" | "link"
}
/**
* Verifica si ya se eligió método de envío.
*/
function hasShippingMethod(ctx) {
return Boolean(ctx?.shipping_method); // "delivery" | "pickup"
}
/**
* Deriva el estado objetivo según el contexto actual y señales del turno.
* `signals` es información determinística del motor del turno (no del LLM),
* por ejemplo: { requested_checkout: true }.
*/
export function deriveNextState(prevState, ctx = {}, signals = {}) {
// Regla 1: pago confirmado gana siempre
if (isPaid(ctx)) return ConversationState.COMPLETED;
// Regla 2: si ya existe orden + link de pago, estamos esperando pago
if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT;
// Regla 3: si estamos clarificando método de pago
if (isClarifyingPayment(ctx)) {
return ConversationState.CLARIFYING_PAYMENT;
}
// Regla 4: si estamos clarificando shipping
if (isClarifyingShipping(ctx)) {
return ConversationState.CLARIFYING_SHIPPING;
}
// Regla 5: si intentó checkout, tiene shipping=delivery, pero falta dirección
if (signals.requested_address || (hasShippingMethod(ctx) && ctx.shipping_method === "delivery" && !hasAddress(ctx))) {
return ConversationState.AWAITING_ADDRESS;
}
// Regla 6: si hay items pendientes de clarificar (nuevo modelo acumulativo)
if (hasPendingItems(ctx)) {
return ConversationState.CLARIFYING_ITEMS;
}
// Regla 7: si hay item pendiente sin completar cantidad (modelo legacy)
if (hasPendingItem(ctx) && !signals.pending_item_completed) {
return ConversationState.AWAITING_QUANTITY;
}
// Regla 8: si hay carrito activo
if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE;
// Regla 9: si estamos mostrando opciones / esperando selección (modelo legacy)
if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) {
return ConversationState.BROWSING;
}
return ConversationState.IDLE;
export function deriveNextState(prevState, order = {}, signals = {}) {
// Regla 0: Si se fuerza volver a CART
if (signals.return_to_cart) {
return ConversationState.CART;
}
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
if (isPaid(order)) {
return ConversationState.IDLE;
}
// Regla 2: Si tiene woo_order_id y espera pago
if (order?.woo_order_id && !isPaid(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
// Desde IDLE
if (prevState === ConversationState.IDLE) {
// Si hay cart o pending items, ir a CART
if (hasCartItems(order) || hasPendingItems(order)) {
return ConversationState.CART;
}
return ConversationState.IDLE;
}
// Desde CART
if (prevState === ConversationState.CART) {
// Si hay pending items sin resolver, quedarse en CART
if (hasPendingItems(order)) {
return ConversationState.CART;
}
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
if (signals.confirm_order && hasCartItems(order)) {
return ConversationState.SHIPPING;
}
return ConversationState.CART;
}
// Desde SHIPPING
if (prevState === ConversationState.SHIPPING) {
// Si ya tiene shipping info completa, ir a PAYMENT
if (hasShippingInfo(order)) {
return ConversationState.PAYMENT;
}
return ConversationState.SHIPPING;
}
// Desde PAYMENT
if (prevState === ConversationState.PAYMENT) {
// Si ya tiene payment info, ir a WAITING_WEBHOOKS
if (signals.payment_selected || hasPaymentInfo(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
return ConversationState.PAYMENT;
}
// Desde WAITING_WEBHOOKS
if (prevState === ConversationState.WAITING_WEBHOOKS) {
if (isPaid(order)) {
return ConversationState.IDLE;
}
return ConversationState.WAITING_WEBHOOKS;
}
// Default
return prevState || ConversationState.IDLE;
}
// Transiciones permitidas (para validación)
const ALLOWED = Object.freeze({
[ConversationState.IDLE]: [
ConversationState.IDLE,
ConversationState.BROWSING,
ConversationState.CLARIFYING_ITEMS,
ConversationState.AWAITING_QUANTITY,
ConversationState.CART_ACTIVE,
ConversationState.ERROR_RECOVERY,
ConversationState.CART,
],
[ConversationState.BROWSING]: [
ConversationState.BROWSING,
ConversationState.CLARIFYING_ITEMS,
ConversationState.AWAITING_QUANTITY,
ConversationState.CART_ACTIVE,
ConversationState.IDLE,
ConversationState.ERROR_RECOVERY,
[ConversationState.CART]: [
ConversationState.CART,
ConversationState.SHIPPING,
ConversationState.IDLE, // Si vacía el carrito
],
[ConversationState.CLARIFYING_ITEMS]: [
ConversationState.CLARIFYING_ITEMS,
ConversationState.CART_ACTIVE,
ConversationState.BROWSING,
ConversationState.IDLE,
ConversationState.ERROR_RECOVERY,
[ConversationState.SHIPPING]: [
ConversationState.SHIPPING,
ConversationState.PAYMENT,
ConversationState.CART, // Volver a agregar productos
],
[ConversationState.AWAITING_QUANTITY]: [
ConversationState.AWAITING_QUANTITY,
ConversationState.CLARIFYING_ITEMS,
ConversationState.CART_ACTIVE,
ConversationState.BROWSING,
ConversationState.ERROR_RECOVERY,
[ConversationState.PAYMENT]: [
ConversationState.PAYMENT,
ConversationState.WAITING_WEBHOOKS,
ConversationState.CART, // Volver a agregar productos
],
[ConversationState.CART_ACTIVE]: [
ConversationState.CART_ACTIVE,
ConversationState.CLARIFYING_ITEMS,
ConversationState.CLARIFYING_PAYMENT,
ConversationState.AWAITING_ADDRESS,
ConversationState.AWAITING_PAYMENT,
ConversationState.ERROR_RECOVERY,
ConversationState.BROWSING,
[ConversationState.WAITING_WEBHOOKS]: [
ConversationState.WAITING_WEBHOOKS,
ConversationState.IDLE, // Pago completado
ConversationState.CART, // Agregar más productos
],
[ConversationState.CLARIFYING_PAYMENT]: [
ConversationState.CLARIFYING_PAYMENT,
ConversationState.CLARIFYING_SHIPPING,
ConversationState.CART_ACTIVE, // Volver si cancela
ConversationState.ERROR_RECOVERY,
],
[ConversationState.CLARIFYING_SHIPPING]: [
ConversationState.CLARIFYING_SHIPPING,
ConversationState.AWAITING_ADDRESS, // Si elige delivery
ConversationState.AWAITING_PAYMENT, // Si elige retiro (directo a crear orden)
ConversationState.CLARIFYING_PAYMENT, // Volver a cambiar pago
ConversationState.ERROR_RECOVERY,
],
[ConversationState.AWAITING_ADDRESS]: [
ConversationState.AWAITING_ADDRESS,
ConversationState.AWAITING_PAYMENT,
ConversationState.CART_ACTIVE,
ConversationState.ERROR_RECOVERY,
],
[ConversationState.AWAITING_PAYMENT]: [
ConversationState.AWAITING_PAYMENT,
ConversationState.COMPLETED,
ConversationState.ERROR_RECOVERY,
],
[ConversationState.COMPLETED]: [
ConversationState.COMPLETED,
ConversationState.IDLE, // nueva conversación / reinicio natural
ConversationState.ERROR_RECOVERY,
],
[ConversationState.ERROR_RECOVERY]: ALL_STATES,
});
export function validateTransition(prevState, nextState) {
@@ -225,10 +199,11 @@ export function validateTransition(prevState, nextState) {
return ok ? { ok: true } : { ok: false, reason: "invalid_transition", prev: p, next: n };
}
export function safeNextState(prevState, ctx, signals) {
const desired = deriveNextState(prevState, ctx, signals);
export function safeNextState(prevState, order, signals) {
const desired = deriveNextState(prevState, order, signals);
const v = validateTransition(prevState, desired);
if (v.ok) return { next_state: desired, validation: v };
return { next_state: ConversationState.ERROR_RECOVERY, validation: v };
// Si la transición no es válida, forzar a un estado seguro
// En el nuevo modelo, siempre podemos ir a CART
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
}

View File

@@ -312,10 +312,42 @@ export async function llmNluV3({ input, model } = {}) {
"- IMPORTANTE: Si NO hay opciones mostradas (last_shown_options vacío) y el usuario dice un número + producto (ej: '2 provoletas'), eso es quantity+product_query, NO selection.\n" +
"- selection SOLO aplica cuando hay opciones visibles para seleccionar (last_shown_options tiene elementos).\n" +
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n" +
"- PREGUNTAS SOBRE DISPONIBILIDAD: Si el usuario pregunta si hay/venden/tienen un producto (ej: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'), usá intent='browse' con product_query=ese producto. needs.catalog_lookup=true.\n" +
"- RECOMENDACIONES: SOLO usá intent='recommend' si el usuario pide sugerencias SIN mencionar ningún producto (ej: 'qué me recomendás?', 'qué me sugerís?'). Si menciona CUALQUIER producto, usá intent='add_to_cart' con product_query=ese producto. Ejemplos que son add_to_cart: 'me recomendás un vino?', 'recomendame un vino', 'qué vino me recomendás?', 'tenés algún vino bueno?' → TODOS son add_to_cart con product_query='vino'.\n" +
"- COMPRAR/PEDIR PRODUCTOS: Si el usuario quiere comprar/pedir/llevar productos (ej: 'quiero comprar X', 'quiero X', 'dame X', 'necesito X', 'anotame X'), usá intent='add_to_cart'. needs.catalog_lookup=true. Aunque incluya un saludo o pida recomendación, si menciona productos específicos es add_to_cart.\n" +
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart|recommend.\n" +
"\n" +
"JERARQUÍA DE DECISIÓN (en orden de prioridad):\n" +
"1. PREGUNTAS DE PLANIFICACIÓN/CONSEJO → recommend\n" +
" Si el usuario PREGUNTA qué comprar/llevar/necesitar para un evento o situación.\n" +
" Señales: 'qué me recomendás', 'qué llevo', 'qué necesito', 'para X personas', 'para un asado/cumple/evento'.\n" +
" El producto mencionado es CONTEXTO, no algo para agregar directamente.\n" +
" Ejemplos → recommend:\n" +
" - 'quiero hacer un asado para 6, qué me recomendás?' (planificación)\n" +
" - 'para una parrillada de 10 personas qué llevo?' (planificación)\n" +
" - 'qué cortes van bien para 6?' (consejo)\n" +
" - 'qué necesito para un asado?' (planificación)\n" +
" - 'qué vino va bien con carne?' (maridaje/consejo)\n" +
"\n" +
"2. PREGUNTAS SOBRE DISPONIBILIDAD → browse\n" +
" Si el usuario pregunta si hay/venden/tienen un producto.\n" +
" Ejemplos → browse: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'\n" +
"\n" +
"3. PEDIDOS DIRECTOS → add_to_cart\n" +
" Si el usuario AFIRMA que quiere/pide/necesita un producto específico con intención de comprarlo.\n" +
" Señales: 'quiero X', 'dame X', 'anotame X', 'poneme X', cantidad + producto.\n" +
" Ejemplos → add_to_cart:\n" +
" - 'quiero 2kg de asado' (pedido directo con cantidad)\n" +
" - 'dame un vino' (pedido directo)\n" +
" - 'anotame 3 provoletas' (pedido directo)\n" +
" - 'necesito chimichurri' (pedido directo)\n" +
"\n" +
"EJEMPLOS CONTRASTIVOS (importante distinguir):\n" +
"- 'quiero asado' → add_to_cart (afirmación directa de compra)\n" +
"- 'quiero hacer un asado, qué llevo?' → recommend (planificación, pregunta)\n" +
"- 'dame vino' → add_to_cart (pedido directo)\n" +
"- 'qué vino me recomendás?' → recommend (pide consejo)\n" +
"- 'tenés vino?' → browse (pregunta disponibilidad)\n" +
"- '2kg de vacío' → add_to_cart (pedido con cantidad)\n" +
"- 'para 6 personas cuánto vacío necesito?' → recommend (pregunta de planificación)\n" +
"\n" +
"- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" +
"- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" +
"- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" +
@@ -438,3 +470,140 @@ export async function llmRecommendWriter({
validation: { ok: false, errors: validateRecommendWriter.errors || [] },
};
}
// --- Planning Recommendation LLM ---
const PlanningRecommendSchema = {
$id: "PlanningRecommend",
type: "object",
additionalProperties: false,
required: ["reply", "suggested_items"],
properties: {
reply: { type: "string", minLength: 1 },
suggested_items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["product_query", "suggested_qty", "unit", "reason"],
properties: {
product_query: { type: "string", minLength: 1 },
suggested_qty: { anyOf: [{ type: "number" }, { type: "null" }] },
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
reason: { type: "string" },
},
},
},
},
};
const validatePlanningRecommend = ajv.compile(PlanningRecommendSchema);
/**
* LLM para recomendaciones de planificación (eventos, asados, etc.)
* Genera sugerencias de productos y cantidades basadas en el contexto.
*/
export async function llmPlanningRecommend({
user_message,
event_type = null,
people_count = null,
cooking_method = null,
mentioned_products = [],
available_categories = [],
locale = "es-AR",
model,
} = {}) {
const system =
"Sos un experto en carnicería y asados argentinos (es-AR). Tu rol es recomendar productos y cantidades.\n\n" +
"CONTEXTO:\n" +
"- Trabajás en una carnicería online.\n" +
"- El cliente te pide ayuda para planificar una comida/evento.\n" +
"- Debés sugerir productos disponibles y cantidades razonables.\n\n" +
"REGLAS DE CANTIDADES (por persona, aproximado):\n" +
"- Asado/Parrilla: 400-500g de carne total por persona\n" +
"- Horno: 300-400g de carne por persona\n" +
"- Mezcla sugerida para asado:\n" +
" * 200g de asado de tira o costilla\n" +
" * 150g de vacío o entraña\n" +
" * 50-100g de chorizo/morcilla (1 unidad c/u cada 2-3 personas)\n" +
" * 1 provoleta cada 3-4 personas\n" +
" * Chimichurri: 1 frasco cada 6-8 personas\n" +
"- Vino: 1 botella cada 2-3 personas\n\n" +
"REGLAS DE RESPUESTA:\n" +
"- Usá product_query con términos genéricos que el catálogo pueda buscar (ej: 'asado', 'vacío', 'chorizo').\n" +
"- NO inventes productos específicos, usá nombres genéricos.\n" +
"- Incluí un 'reason' breve para cada sugerencia.\n" +
"- IMPORTANTE: En 'reply' escribí un mensaje COMPLETO que incluya la lista de productos con cantidades.\n" +
" El reply debe ser autosuficiente, con formato:\n" +
" 'Para [X] personas te recomiendo:\\n- 2kg de asado de tira\\n- 1kg de vacío\\n- 2 chorizos\\n...'\n" +
"- Si el cliente pregunta por PRECIOS, respondé que vas a buscar los productos para mostrarle los precios.\n" +
"- Si pregunta por método de cocción (horno vs parrilla), explicá brevemente y sugerí cortes apropiados.\n\n" +
"FORMATO JSON ESTRICTO:\n" +
"{\n" +
" \"reply\": \"Para 6 personas te recomiendo:\\n- 2kg de asado de tira\\n- 1.5kg de vacío\\n- 3 chorizos\\n- 3 morcillas\\n- 2 provoletas\\n\\n¿Querés que te arme el pedido con esto?\",\n" +
" \"suggested_items\": [\n" +
" {\"product_query\": \"asado\", \"suggested_qty\": 2, \"unit\": \"kg\", \"reason\": \"base del asado\"},\n" +
" {\"product_query\": \"vacío\", \"suggested_qty\": 1.5, \"unit\": \"kg\", \"reason\": \"corte tierno\"}\n" +
" ]\n" +
"}\n" +
"suggested_items se usa para buscar en el catálogo. Si no hay items, usá suggested_items: [].\n";
const userPayload = {
locale,
user_message,
context: {
event_type,
people_count,
cooking_method,
mentioned_products,
},
available_categories: available_categories.slice(0, 30),
};
const first = await jsonCompletion({ system, user: JSON.stringify(userPayload), model });
if (validatePlanningRecommend(first.parsed)) {
return {
reply: first.parsed.reply,
suggested_items: first.parsed.suggested_items || [],
raw_text: first.raw_text,
model: first.model,
usage: first.usage,
validation: { ok: true },
};
}
// Retry con errores
const errors = validatePlanningRecommend.errors || [];
const systemRetry =
system +
"\nTu respuesta anterior no validó. Corregí el JSON.\n" +
`Errores: ${JSON.stringify(errors).slice(0, 1000)}\n`;
try {
const second = await jsonCompletion({ system: systemRetry, user: JSON.stringify(userPayload), model });
if (validatePlanningRecommend(second.parsed)) {
return {
reply: second.parsed.reply,
suggested_items: second.parsed.suggested_items || [],
raw_text: second.raw_text,
model: second.model,
usage: second.usage,
validation: { ok: true, retried: true },
};
}
} catch (e) {
// Fallback
}
// Fallback: usar el reply si existe
const fallbackReply = first.parsed?.reply || "Dejame buscar algunas opciones para vos.";
return {
reply: fallbackReply,
suggested_items: [],
raw_text: first.raw_text,
model: first.model,
usage: first.usage,
validation: { ok: false, errors },
};
}

View File

@@ -0,0 +1,251 @@
/**
* Modelo unificado de orden para el contexto de conversación.
*
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item,
* checkout_step, payment_method, shipping_method, delivery_address
*/
// Status de items pendientes
export const PendingStatus = Object.freeze({
NEEDS_TYPE: "NEEDS_TYPE", // Necesita seleccionar producto de opciones
NEEDS_QUANTITY: "NEEDS_QUANTITY", // Necesita especificar cantidad
READY: "READY", // Listo para mover a cart
});
/**
* Crea una orden vacía
*/
export function createEmptyOrder() {
return {
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
pending: [], // Items por clarificar
payment_type: null, // "link" | "cash" | null
is_delivery: null, // true | false | null
shipping_address: null,
woo_order_id: null,
is_paid: false,
};
}
/**
* Crea un item de carrito confirmado
*/
export function createCartItem({ woo_id, qty, unit, name = null, price = null }) {
return {
woo_id,
qty: Number(qty) || 1,
unit: unit || "unit", // "kg" | "g" | "unit"
name,
price,
};
}
/**
* Crea un item pendiente de clarificación
*/
export function createPendingItem({
id,
query,
candidates = [],
selected_woo_id = null,
selected_name = null,
selected_price = null,
selected_unit = null,
qty = null,
unit = null,
status = PendingStatus.NEEDS_TYPE
}) {
return {
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
query,
candidates, // [{ woo_id, name, price, display_unit }]
selected_woo_id, // Producto elegido (null si NEEDS_TYPE)
selected_name,
selected_price,
selected_unit, // Unidad del producto seleccionado
qty, // Cantidad (null si NEEDS_QUANTITY)
unit, // Unidad elegida por usuario
status,
};
}
/**
* Mueve items READY de pending a cart
*/
export function moveReadyToCart(order) {
if (!order) return createEmptyOrder();
const newCart = [...(order.cart || [])];
const newPending = [];
for (const item of (order.pending || [])) {
if (item.status === PendingStatus.READY && item.selected_woo_id && item.qty) {
// Buscar si ya existe en cart
const existingIdx = newCart.findIndex(c => c.woo_id === item.selected_woo_id);
const cartItem = createCartItem({
woo_id: item.selected_woo_id,
qty: item.qty,
unit: item.unit || item.selected_unit || "unit",
name: item.selected_name,
price: item.selected_price,
});
if (existingIdx >= 0) {
// Actualizar cantidad existente
newCart[existingIdx] = {
...newCart[existingIdx],
qty: cartItem.qty,
unit: cartItem.unit,
};
} else {
newCart.push(cartItem);
}
} else {
newPending.push(item);
}
}
return {
...order,
cart: newCart,
pending: newPending,
};
}
/**
* Obtiene el primer item pendiente que necesita clarificación
*/
export function getNextPendingItem(order) {
if (!Array.isArray(order?.pending)) return null;
return order.pending.find(i =>
i.status === PendingStatus.NEEDS_TYPE ||
i.status === PendingStatus.NEEDS_QUANTITY
) || null;
}
/**
* Actualiza un item pendiente por ID
*/
export function updatePendingItem(order, itemId, updates) {
if (!order?.pending) return order;
const newPending = order.pending.map(item => {
if (item.id === itemId) {
return { ...item, ...updates };
}
return item;
});
return { ...order, pending: newPending };
}
/**
* Agrega un nuevo item pendiente
*/
export function addPendingItem(order, pendingItem) {
const current = order || createEmptyOrder();
return {
...current,
pending: [...(current.pending || []), pendingItem],
};
}
/**
* Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato
*/
export function migrateOldContext(ctx) {
if (!ctx) return createEmptyOrder();
// Si ya tiene el nuevo formato
if (ctx.order && (Array.isArray(ctx.order.cart) || Array.isArray(ctx.order.pending))) {
return ctx.order;
}
const order = createEmptyOrder();
// Migrar order_basket
if (ctx.order_basket?.items) {
order.cart = ctx.order_basket.items.map(item => createCartItem({
woo_id: item.product_id || item.woo_id || item.woo_product_id,
qty: item.quantity || item.qty || 1,
unit: item.unit || "unit",
name: item.label || item.name,
price: item.price,
}));
}
// Migrar pending_items
if (Array.isArray(ctx.pending_items)) {
order.pending = ctx.pending_items.map(item => createPendingItem({
id: item.id,
query: item.query,
candidates: (item.candidates || []).map(c => ({
woo_id: c.woo_product_id || c.woo_id,
name: c.name,
price: c.price,
display_unit: c.display_unit,
})),
selected_woo_id: item.resolved_product?.woo_product_id || item.resolved_product?.woo_id,
selected_name: item.resolved_product?.name,
selected_price: item.resolved_product?.price,
selected_unit: item.resolved_product?.display_unit,
qty: item.quantity,
unit: item.unit,
status: item.status === "needs_type" ? PendingStatus.NEEDS_TYPE :
item.status === "needs_quantity" ? PendingStatus.NEEDS_QUANTITY :
item.status === "ready" ? PendingStatus.READY :
item.status?.toUpperCase() || PendingStatus.NEEDS_TYPE,
}));
}
// Migrar checkout info
order.payment_type = ctx.payment_method || null;
order.is_delivery = ctx.shipping_method === "delivery" ? true :
ctx.shipping_method === "pickup" ? false : null;
order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || null;
order.woo_order_id = ctx.woo_order_id || ctx.last_order_id || null;
order.is_paid = ctx.mp?.payment_status === "approved" ||
ctx.payment?.status === "paid" ||
ctx.payment_status === "approved" || false;
return order;
}
/**
* Formatea el carrito para mostrar al usuario
*/
export function formatCartForDisplay(order, locale = "es-AR") {
if (!order?.cart?.length) {
return "Tu carrito está vacío.";
}
const lines = order.cart.map(item => {
const qtyStr = item.unit === "kg" ? `${item.qty}kg` :
item.unit === "g" ? `${item.qty}g` :
`${item.qty}`;
return `- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`;
});
return "Tenés anotado:\n" + lines.join("\n");
}
/**
* Formatea opciones de un pending item para mostrar al usuario
*/
export function formatOptionsForDisplay(pendingItem, pageSize = 9) {
if (!pendingItem?.candidates?.length) {
return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] };
}
const options = pendingItem.candidates.slice(0, pageSize);
const hasMore = pendingItem.candidates.length > pageSize;
const lines = options.map((c, i) => `${i + 1}) ${c.name}`);
if (hasMore) {
lines.push(`${pageSize + 1}) Mostrame más...`);
}
const question = `Para "${pendingItem.query}", ¿cuál de estos querés?\n` + lines.join("\n") + "\n\nRespondé con el número.";
return { question, options };
}

View File

@@ -1,6 +1,8 @@
import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js";
import { getSnapshotItemsByIds } from "../shared/wooSnapshot.js";
import { getRecoRules, getRecoRulesByProductIds, getProductQtyRulesByEvent } from "../2-identity/db/repo.js";
import { getSnapshotItemsByIds, searchSnapshotItems } from "../shared/wooSnapshot.js";
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
import { llmPlanningRecommend } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
/**
* Extrae los IDs de productos del carrito.
@@ -8,7 +10,7 @@ import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
function getBasketProductIds(basket_items) {
const items = Array.isArray(basket_items) ? basket_items : [];
return items
.map(item => item.product_id || item.woo_product_id)
.map(item => item.product_id || item.woo_product_id || item.woo_id)
.filter(id => id != null)
.map(Number);
}
@@ -30,29 +32,356 @@ function collectRecommendedIds(rules, excludeIds = []) {
return [...ids];
}
export async function handleRecommend({
tenantId,
text,
prev_context = {},
basket_items = [],
limit = 9,
} = {}) {
/**
* Detecta si el mensaje es una solicitud de planificación/consejo.
*/
function detectPlanningRequest(text, nlu) {
const t = String(text || "").toLowerCase();
// Patrones de planificación
const planningPatterns = [
/\bpara\s+(\d+)\s*(personas?|comensales?|invitados?)\b/i,
/\bqu[eé]\s+(me\s+)?recomend[aá]s?\b/i,
/\bqu[eé]\s+(necesito|llevo|compro)\b/i,
/\bcu[aá]nto\s+(necesito|llevo|compro)\b/i,
/\bpara\s+(un|una|el|la)\s+(asado|parrilla|parrillada|horno|sangu[ií]?che?s?|reuni[oó]n|evento|juntada|fiesta)\b/i,
/\bqu[eé]\s+cortes?\b/i,
/\bqu[eé]\s+vino?\s+(va|combina|queda)\b/i,
/\bmaridaje\b/i,
/\bqu[eé]\s+(llevo|necesito|compro)\s+para\b/i,
/\bcomo\s+para\s+\d+/i,
];
for (const pattern of planningPatterns) {
if (pattern.test(t)) return true;
}
// Si el NLU es recommend y no hay productos específicos en el carrito, es planificación
if (nlu?.intent === "recommend") {
return true;
}
return false;
}
/**
* Extrae información de planificación del texto.
*/
function extractPlanningInfo(text) {
const t = String(text || "").toLowerCase();
const info = {
people_count: null,
adults_count: null,
children_count: null,
event_type: null,
cooking_method: null,
mentioned_products: [],
};
// Cantidad de adultos
const adultsMatch = t.match(/\b(\d+)\s*(adultos?|grandes?|mayores?)\b/i);
if (adultsMatch) {
info.adults_count = parseInt(adultsMatch[1], 10);
}
// Cantidad de niños
const childrenMatch = t.match(/\b(\d+)\s*(ni[nñ]os?|chicos?|menores?|peques?|hijos?)\b/i);
if (childrenMatch) {
info.children_count = parseInt(childrenMatch[1], 10);
}
// Cantidad total de personas (si no especificó adultos/niños)
const peopleMatch = t.match(/\b(\d+)\s*(personas?|comensales?|invitados?)\b/i) ||
t.match(/\bpara\s+(\d+)\b/) ||
t.match(/\bcomo\s+para\s+(\d+)\b/i);
if (peopleMatch) {
info.people_count = parseInt(peopleMatch[1], 10);
}
// Si especificó adultos y niños pero no total, calcularlo
if (info.adults_count !== null || info.children_count !== null) {
info.people_count = (info.adults_count || 0) + (info.children_count || 0);
}
// Si solo especificó total, asumir todos adultos
if (info.people_count && info.adults_count === null && info.children_count === null) {
info.adults_count = info.people_count;
info.children_count = 0;
}
// Tipo de evento (asado, horno, sanguches)
if (/\basado\b|\bparrilla(da)?\b/i.test(t)) info.event_type = "asado";
else if (/\bhorno\b/i.test(t)) info.event_type = "horno";
else if (/\bsangu[ií]?che?s?\b|\bsandwich(es)?\b/i.test(t)) info.event_type = "sanguches";
// Método de cocción
if (/\bparrilla\b|\bbrasa\b|\bcarbón\b/i.test(t)) info.cooking_method = "parrilla";
else if (/\bhorno\b/i.test(t)) info.cooking_method = "horno";
else if (/\bplancha\b/i.test(t)) info.cooking_method = "plancha";
// Productos mencionados (keywords comunes)
const productKeywords = ["asado", "vacío", "vacio", "entraña", "entrania", "chorizo", "morcilla",
"provoleta", "chimichurri", "vino", "tira", "costilla", "bife", "lomo", "matambre",
"pollo", "cerdo", "bondiola", "carne"];
for (const kw of productKeywords) {
if (t.includes(kw)) {
info.mentioned_products.push(kw);
}
}
return info;
}
/**
* Maneja recomendaciones de planificación usando reglas de BD o LLM como fallback.
*/
async function handlePlanningRecommend({ tenantId, text, nlu, order, audit }) {
const planningInfo = extractPlanningInfo(text);
audit.planning_info = planningInfo;
const adultsCount = planningInfo.adults_count || planningInfo.people_count || 1;
const childrenCount = planningInfo.children_count || 0;
const totalPeople = adultsCount + childrenCount;
const eventType = planningInfo.event_type || "asado"; // Default asado
// 1) Buscar reglas de cantidad desde la nueva tabla product_qty_rules
const qtyRules = await getProductQtyRulesByEvent({ tenant_id: tenantId, event_type: eventType });
audit.qty_rules_found = qtyRules.length;
// Si hay reglas configuradas, usarlas en lugar del LLM
if (qtyRules.length > 0) {
audit.using_rules = { event: eventType, count: qtyRules.length };
// Agrupar por producto y calcular cantidades según tipo de persona
const productQtyMap = new Map(); // woo_product_id -> { qty, unit, product }
for (const rule of qtyRules) {
const qtyPerPerson = Number(rule.qty_per_person) || 0;
const personType = rule.person_type || "adult";
// Calcular cantidad según tipo de persona
let calculatedQty = 0;
if (personType === "adult") {
calculatedQty = qtyPerPerson * adultsCount;
} else if (personType === "child") {
calculatedQty = qtyPerPerson * childrenCount;
}
if (calculatedQty <= 0) continue;
const key = rule.woo_product_id;
if (productQtyMap.has(key)) {
// Sumar cantidad al existente
const existing = productQtyMap.get(key);
existing.qty += calculatedQty;
} else {
productQtyMap.set(key, {
woo_product_id: rule.woo_product_id,
qty: calculatedQty,
unit: rule.unit || "kg",
});
}
}
// Obtener info de productos del catálogo
const productIds = [...productQtyMap.keys()];
const { items: products } = await getSnapshotItemsByIds({
tenantId,
wooProductIds: productIds,
});
const productMap = new Map();
for (const p of products) {
productMap.set(p.woo_product_id, p);
}
// Convertir a pendingItems
const pendingItems = [];
for (const [wooId, data] of productQtyMap) {
const product = productMap.get(wooId);
if (!product) continue;
const roundedQty = Math.round(data.qty * 100) / 100; // Redondear a 2 decimales
pendingItems.push({
query: product.name,
suggested_qty: roundedQty,
suggested_unit: data.unit,
reason: "",
candidates: [{
woo_id: product.woo_product_id,
name: product.name,
price: product.price,
}],
});
}
audit.pending_items_created = pendingItems.length;
// Construir respuesta con detalle de adultos/niños si aplica
let headerLine = "";
if (childrenCount > 0) {
headerLine = `Para ${adultsCount} adulto${adultsCount > 1 ? "s" : ""} y ${childrenCount} niño${childrenCount > 1 ? "s" : ""}, te recomiendo:`;
} else {
headerLine = `Para ${totalPeople} persona${totalPeople > 1 ? "s" : ""}, te recomiendo:`;
}
let reply = headerLine + "\n\n";
const lines = pendingItems.map(item => {
const qtyStr = item.suggested_unit === "unidad"
? `${item.suggested_qty} unidad${item.suggested_qty > 1 ? "es" : ""}`
: `${item.suggested_qty}${item.suggested_unit}`;
return `${item.candidates[0]?.name}: ${qtyStr}`;
});
reply += lines.join("\n");
// Agregar precios si están disponibles
const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null);
if (itemsWithPrices.length > 0) {
reply += "\n\n¿Querés que te arme el pedido?";
const priceLines = itemsWithPrices.map(item => {
const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit}`;
return `${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`;
}).join("\n");
reply += "\n\n📋 *Precios actuales:*\n" + priceLines;
}
return {
plan: {
reply,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [],
order,
audit,
context_patch: {
planning_suggestions: pendingItems,
},
},
};
}
// 2) Fallback: usar LLM si no hay reglas configuradas
audit.fallback_to_llm = true;
// Obtener categorías disponibles para contexto
const categoryResult = await searchSnapshotItems({ tenantId, q: "", limit: 50 });
const categories = new Set();
for (const item of (categoryResult?.items || [])) {
for (const cat of (item.categories || [])) {
if (cat?.name) categories.add(cat.name);
}
}
// Llamar al LLM de planificación
const llmResult = await llmPlanningRecommend({
user_message: text,
event_type: planningInfo.event_type,
people_count: planningInfo.people_count,
cooking_method: planningInfo.cooking_method,
mentioned_products: planningInfo.mentioned_products,
available_categories: [...categories],
});
audit.planning_llm = {
model: llmResult.model,
usage: llmResult.usage,
suggested_count: llmResult.suggested_items?.length || 0,
validation: llmResult.validation,
};
// Si hay items sugeridos, buscar en el catálogo y crear pending items
const suggestedItems = llmResult.suggested_items || [];
const pendingItems = [];
for (const suggestion of suggestedItems.slice(0, 8)) {
const searchResult = await retrieveCandidates({
tenantId,
query: suggestion.product_query,
limit: 5
});
const candidates = searchResult?.candidates || [];
if (candidates.length > 0) {
pendingItems.push({
query: suggestion.product_query,
suggested_qty: suggestion.suggested_qty,
suggested_unit: suggestion.unit,
reason: suggestion.reason,
candidates: candidates.slice(0, 5).map(c => ({
woo_id: c.woo_product_id,
name: c.name,
price: c.price,
})),
});
}
}
audit.pending_items_created = pendingItems.length;
// Usar el reply del LLM directamente (ya incluye la lista de productos)
let reply = llmResult.reply || "Te ayudo con eso.";
// Si encontramos items en el catálogo, agregar precios reales
if (pendingItems.length > 0) {
// Solo agregar info de precios si el catálogo tiene datos
const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null);
if (itemsWithPrices.length > 0) {
// Si el reply NO termina con pregunta de si quiere agregar, añadirla
if (!/\?[\s]*$/.test(reply)) {
reply += "\n\n¿Querés que te arme el pedido?";
}
// Agregar precios reales del catálogo
const priceLines = itemsWithPrices.map(item => {
const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit || "kg"}`;
return `${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`;
}).join("\n");
reply += "\n\n📋 *Precios actuales:*\n" + priceLines;
}
}
return {
plan: {
reply,
next_state: null, // Se determinará por el caller
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [],
order,
audit,
context_patch: {
planning_suggestions: pendingItems,
},
},
};
}
/**
* Maneja recomendaciones de cross-sell basadas en el carrito.
*/
async function handleCrossSellRecommend({ tenantId, text, order, basket_items, limit, audit }) {
const context_patch = {};
const audit = { basket_product_ids: [], rules_used: [], recommended_ids: [] };
// 1. Obtener IDs de productos en el carrito
const basketProductIds = getBasketProductIds(basket_items);
audit.basket_product_ids = basketProductIds;
if (!basketProductIds.length) {
return {
reply: "Primero agregá algo al carrito y después te sugiero complementos perfectos.",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
};
// No hay items, delegar a planificación
return null;
}
// 2. Buscar reglas que matcheen con los productos del carrito
@@ -63,12 +392,14 @@ export async function handleRecommend({
// Fallback: no hay reglas configuradas para estos productos
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", ");
return {
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
plan: {
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
@@ -78,26 +409,31 @@ export async function handleRecommend({
if (!recommendedIds.length) {
return {
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
plan: {
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
// 4. Obtener detalles de los productos recomendados
const recommendedProducts = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
const recommendedResult = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
const recommendedProducts = recommendedResult?.items || [];
if (!recommendedProducts.length) {
return {
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
plan: {
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
@@ -116,11 +452,60 @@ export async function handleRecommend({
context_patch.pending_item = null;
return {
reply,
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
context_patch,
audit,
asked_slot: null,
candidates: recommendedProducts.slice(0, limit),
plan: {
reply,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
order,
audit,
context_patch,
},
};
}
/**
* Handler principal de recomendaciones.
* Detecta si es planificación o cross-sell y delega al handler apropiado.
*/
export async function handleRecommend({
tenantId,
text,
nlu,
order,
prevContext = {},
basket_items = [],
limit = 9,
audit = {},
} = {}) {
audit.recommendation_type = null;
// Extraer basket_items del order si no se pasan explícitamente
const cartItems = basket_items.length > 0
? basket_items
: (order?.cart || []).map(item => ({
woo_product_id: item.woo_id,
name: item.name,
label: item.name,
}));
// Detectar si es planificación
const isPlanningRequest = detectPlanningRequest(text, nlu);
// Si hay items en el carrito y no es claramente planificación, intentar cross-sell primero
if (cartItems.length > 0 && !isPlanningRequest) {
audit.recommendation_type = "cross_sell";
const crossSellResult = await handleCrossSellRecommend({
tenantId, text, order, basket_items: cartItems, limit, audit
});
if (crossSellResult) return crossSellResult;
}
// Planificación (carrito vacío o solicitud explícita de consejo)
audit.recommendation_type = "planning";
return handlePlanningRecommend({ tenantId, text, nlu, order, audit });
}

View File

@@ -0,0 +1,858 @@
/**
* Handlers por estado para el flujo conversacional simplificado.
* Cada handler recibe params y retorna { plan, decision }
*/
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,
} from "./orderModel.js";
import { handleRecommend } from "./recommendations.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)
);
}
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 = [];
// 1) Si hay pending items sin resolver, procesar clarificación
const pendingItem = getNextPendingItem(currentOrder);
if (pendingItem) {
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 },
};
}
// 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) {
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}`;
return {
plan: {
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}. ¿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) {
const displayUnit = 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
return createPendingItem({
query,
candidates: cands.slice(0, 20).map(c => ({
woo_id: c.woo_product_id,
name: c.name,
price: c.price,
display_unit: inferDefaultUnit({ name: c.name, categories: c.categories }),
})),
status: PendingStatus.NEEDS_TYPE,
});
}
async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) {
// Si necesita seleccionar tipo
if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
const idx = parseIndexSelection(text);
// Show more
if (isShowMoreRequest(text)) {
// TODO: implement pagination
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 },
};
}
// Selection by index
if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) {
const selected = pendingItem.candidates[idx - 1];
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
const needsQuantity = displayUnit !== "unit";
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 : 1,
unit: displayUnit,
});
// 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);
return {
plan: {
reply: `Perfecto, anoto 1 ${selected.name}. ¿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ó, 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}`;
return {
plan: {
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}. ¿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;
}

File diff suppressed because it is too large Load Diff