borrado de articulos del carrito

This commit is contained in:
Lucas Tettamanti
2026-01-25 23:43:00 -03:00
parent bd63d92c50
commit debad78781
4 changed files with 448 additions and 50 deletions

View File

@@ -128,9 +128,11 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha
mark("start"); mark("start");
const stageDebug = dbg.perf; const stageDebug = dbg.perf;
mark("after_touchConversationState"); mark("after_touchConversationState");
// Detectar conversación nueva (más de 24 horas sin actividad)
const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas
const isStale = const isStale =
prev?.state_updated_at && prev?.state_updated_at &&
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs;
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
let externalCustomerId = await getExternalCustomerIdByChat({ let externalCustomerId = await getExternalCustomerIdByChat({
tenant_id: tenantId, tenant_id: tenantId,
@@ -161,7 +163,21 @@ let externalCustomerId = await getExternalCustomerIdByChat({
logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state }); logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state });
// Cargar contexto previo, pero resetearlo si la conversación está "stale"
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {}; let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
if (isStale) {
// Conversación nueva: resetear carrito pero mantener datos del cliente
reducedContext = {
external_customer_id: reducedContext.external_customer_id,
// Resetear order y pending
order: null,
order_basket: null,
pending_items: null,
// Marcar que fue reseteado
_reset_reason: "stale",
_reset_at: new Date().toISOString(),
};
}
let decision; let decision;
let plan; let plan;
let llmMeta; let llmMeta;

View File

@@ -2,6 +2,11 @@ Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje
REGLAS CRÍTICAS (seguir estrictamente): REGLAS CRÍTICAS (seguir estrictamente):
0. EXTRAER TODOS LOS PRODUCTOS - NUNCA OMITIR NINGUNO
Si el mensaje menciona 5 productos, el array items DEBE tener 5 elementos.
NUNCA omitas productos, incluso si no estás seguro del nombre exacto.
Extraé cada producto mencionado, separado por comas, "y", saltos de línea, etc.
1. SIEMPRE USAR ARRAY "items" 1. SIEMPRE USAR ARRAY "items"
Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento. Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento.
Cada item tiene: product_query, quantity, unit Cada item tiene: product_query, quantity, unit
@@ -10,10 +15,13 @@ REGLAS CRÍTICAS (seguir estrictamente):
El campo "product_query" debe ser el texto EXACTO que usó el cliente. El campo "product_query" debe ser el texto EXACTO que usó el cliente.
- Si dice "asado de tira" → product_query: "asado de tira" - Si dice "asado de tira" → product_query: "asado de tira"
- Si dice "vacío" → product_query: "vacío" - Si dice "vacío" → product_query: "vacío"
- Si dice "carre de cerdo" → product_query: "carre de cerdo"
- Si dice "provoletas wapi" → product_query: "provoletas wapi"
- NUNCA modifiques, combines ni inventes nombres - NUNCA modifiques, combines ni inventes nombres
3. EXTRAER CANTIDADES 3. EXTRAER CANTIDADES (pueden estar antes o después del producto)
- "2kg de X" → quantity: 2, unit: "kg" - "2kg de X" → quantity: 2, unit: "kg"
- "X 1kg" → quantity: 1, unit: "kg" (cantidad después del producto)
- "3 provoletas" → quantity: 3, unit: "unidad" - "3 provoletas" → quantity: 3, unit: "unidad"
- "medio kilo" → quantity: 0.5, unit: "kg" - "medio kilo" → quantity: 0.5, unit: "kg"
- Sin cantidad → quantity: null - Sin cantidad → quantity: null
@@ -24,13 +32,27 @@ REGLAS CRÍTICAS (seguir estrictamente):
- unidad: unidades, u (para productos que no se pesan) - unidad: unidades, u (para productos que no se pesan)
5. INTENTS 5. INTENTS
- add_to_cart: agregar productos (quiero, dame, anotame, poneme) - add_to_cart: agregar productos (quiero, dame, anotame, poneme, hola quiero)
- remove_from_cart: quitar productos (sacame, quitame) - remove_from_cart: quitar productos (sacame, quitame)
- view_cart: ver carrito (qué tengo, qué anoté, mi pedido) - view_cart: ver carrito (qué tengo, qué anoté, mi pedido)
- confirm_order: cerrar pedido (listo, eso es todo, cerrar) - confirm_order: cerrar pedido (listo, eso es todo, cerrar)
EJEMPLOS: EJEMPLOS:
Input: "hola, quiero 1kg de asado, vacio, carre de cerdo 1kg, chorizo mixto 1kg y 3 provoletas wapi"
Output:
{
"intent": "add_to_cart",
"confidence": 0.95,
"items": [
{"product_query": "asado", "quantity": 1, "unit": "kg"},
{"product_query": "vacio", "quantity": null, "unit": null},
{"product_query": "carre de cerdo", "quantity": 1, "unit": "kg"},
{"product_query": "chorizo mixto", "quantity": 1, "unit": "kg"},
{"product_query": "provoletas wapi", "quantity": 3, "unit": "unidad"}
]
}
Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas" Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas"
Output: Output:
{ {

View File

@@ -154,6 +154,97 @@ export function addPendingItem(order, pendingItem) {
}; };
} }
/**
* Remueve items del carrito que coincidan con el query
* Usa fuzzy matching para encontrar el producto
*/
export function removeCartItem(order, productQuery) {
if (!order?.cart?.length || !productQuery) return { order, removed: null };
const query = String(productQuery).toLowerCase().trim();
const words = query.split(/\s+/).filter(w => w.length > 2);
// Buscar el item que mejor matchee
let bestIdx = -1;
let bestScore = 0;
for (let i = 0; i < order.cart.length; i++) {
const item = order.cart[i];
const name = String(item.name || "").toLowerCase();
// Score basado en coincidencia de palabras
let score = 0;
for (const word of words) {
if (name.includes(word)) score += 1;
}
// Bonus si el query entero está en el nombre
if (name.includes(query)) score += 2;
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
if (bestIdx >= 0 && bestScore > 0) {
const removed = order.cart[bestIdx];
const newCart = [...order.cart];
newCart.splice(bestIdx, 1);
return {
order: { ...order, cart: newCart },
removed,
};
}
return { order, removed: null };
}
/**
* Actualiza la cantidad de un item en el carrito
*/
export function updateCartItemQuantity(order, productQuery, newQty, newUnit = null) {
if (!order?.cart?.length || !productQuery) return { order, updated: null };
const query = String(productQuery).toLowerCase().trim();
const words = query.split(/\s+/).filter(w => w.length > 2);
// Buscar el item que mejor matchee
let bestIdx = -1;
let bestScore = 0;
for (let i = 0; i < order.cart.length; i++) {
const item = order.cart[i];
const name = String(item.name || "").toLowerCase();
let score = 0;
for (const word of words) {
if (name.includes(word)) score += 1;
}
if (name.includes(query)) score += 2;
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
if (bestIdx >= 0 && bestScore > 0) {
const updated = { ...order.cart[bestIdx] };
const newCart = [...order.cart];
newCart[bestIdx] = {
...updated,
qty: newQty,
unit: newUnit || updated.unit,
};
return {
order: { ...order, cart: newCart },
updated: newCart[bestIdx],
};
}
return { order, updated: null };
}
/** /**
* Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato * Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato
*/ */
@@ -217,18 +308,35 @@ export function migrateOldContext(ctx) {
/** /**
* Formatea el carrito para mostrar al usuario * Formatea el carrito para mostrar al usuario
* Incluye items del carrito + items pendientes (incompletos)
*/ */
export function formatCartForDisplay(order, locale = "es-AR") { export function formatCartForDisplay(order, locale = "es-AR") {
if (!order?.cart?.length) { const lines = [];
return "Tu carrito está vacío.";
// Items confirmados en el carrito
for (const item of (order?.cart || [])) {
if (item.qty != null) {
const qtyStr = item.unit === "kg" ? `${item.qty}kg` :
item.unit === "g" ? `${item.qty}g` :
item.unit === "unit" ? `${item.qty}` : `${item.qty}`;
lines.push(`- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`);
} else {
lines.push(`- ${item.name || `Producto #${item.woo_id}`} (falta cantidad)`);
}
} }
const lines = order.cart.map(item => { // Items pendientes (mostrar como provisionales)
const qtyStr = item.unit === "kg" ? `${item.qty}kg` : for (const p of (order?.pending || [])) {
item.unit === "g" ? `${item.qty}g` : if (p.status === PendingStatus.NEEDS_TYPE) {
`${item.qty}`; lines.push(`- "${p.query}" (pendiente de elegir)`);
return `- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`; } else if (p.status === PendingStatus.NEEDS_QUANTITY && p.selected_name) {
}); lines.push(`- ${p.selected_name} (falta cantidad)`);
}
}
if (lines.length === 0) {
return "Tu carrito está vacío.";
}
return "Tenés anotado:\n" + lines.join("\n"); return "Tenés anotado:\n" + lines.join("\n");
} }
@@ -236,7 +344,7 @@ export function formatCartForDisplay(order, locale = "es-AR") {
/** /**
* Formatea opciones de un pending item para mostrar al usuario * Formatea opciones de un pending item para mostrar al usuario
*/ */
export function formatOptionsForDisplay(pendingItem, pageSize = 9) { export function formatOptionsForDisplay(pendingItem, pageSize = 12) {
if (!pendingItem?.candidates?.length) { if (!pendingItem?.candidates?.length) {
return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] }; return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] };
} }

View File

@@ -17,6 +17,8 @@ import {
migrateOldContext, migrateOldContext,
formatCartForDisplay, formatCartForDisplay,
formatOptionsForDisplay, formatOptionsForDisplay,
removeCartItem,
updateCartItemQuantity,
} from "./orderModel.js"; } from "./orderModel.js";
import { handleRecommend } from "./recommendations.js"; import { handleRecommend } from "./recommendations.js";
import { getProductQtyRules } from "../0-ui/db/repo.js"; import { getProductQtyRules } from "../0-ui/db/repo.js";
@@ -66,7 +68,72 @@ function isShowMoreRequest(text) {
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) || /\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
/\bmas\s+opciones\b/.test(t) || /\bmas\s+opciones\b/.test(t) ||
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) || (/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
/\bsiguiente(s)?\b/.test(t) /\bsiguiente(s)?\b/.test(t) ||
// Patrones adicionales
/\b(no\s+)?hay\s+(otras?|m[aá]s)\b/.test(t) ||
/\botras?\s+opciones\b/.test(t) ||
/\bqu[eé]\s+m[aá]s\s+hay\b/.test(t) ||
/\bver\s+m[aá]s\b/.test(t) ||
/\btodas?\s+las?\s+opciones\b/.test(t)
);
}
// Detectar si el usuario pide ver las opciones disponibles
function isShowOptionsRequest(text) {
const t = String(text || "").toLowerCase();
return (
/\bqu[eé]\s+opciones\b/.test(t) ||
/\bcu[aá]les\s+(son|hay|ten[eé]s)\b/.test(t) ||
/\bmostr(a|ame)\s+(las\s+)?opciones\b/.test(t) ||
/\bver\s+(las\s+)?opciones\b/.test(t) ||
/\bqu[eé]\s+hay\b/.test(t) ||
/\bqu[eé]\s+ten[eé]s\b/.test(t)
);
}
// Buscar un candidato que coincida con el texto del usuario (fuzzy match)
function findMatchingCandidate(candidates, text) {
if (!candidates?.length || !text) return null;
const t = String(text).toLowerCase().trim();
const words = t.split(/\s+/).filter(w => w.length > 2);
let bestMatch = null;
let bestScore = 0;
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
const name = String(candidate.name || "").toLowerCase();
let score = 0;
for (const word of words) {
if (name.includes(word)) score += 1;
}
// Bonus si el texto completo está en el nombre
if (name.includes(t)) score += 2;
if (score > bestScore) {
bestScore = score;
bestMatch = { index: i, candidate, score };
}
}
// Requiere al menos una palabra que coincida
return bestScore > 0 ? bestMatch : null;
}
// Detectar si el texto indica un intent de escape (ver carrito, confirmar, etc.)
function isEscapeRequest(text) {
const t = String(text || "").toLowerCase();
return (
/\b(que|qué)\s+tengo\b/.test(t) ||
/\bmi\s+(carrito|pedido)\b/.test(t) ||
/\bver\s+(carrito|pedido)\b/.test(t) ||
/\bmostrar\s+(carrito|pedido)\b/.test(t) ||
/\blisto\b/.test(t) ||
/\bconfirmar?\b/.test(t) ||
/\bcancelar?\b/.test(t) ||
/\beso\s+(es\s+)?todo\b/.test(t)
); );
} }
@@ -144,8 +211,9 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i; const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i;
const wantsToSkipPending = pendingItem && cancelPhrases.test(text || ""); const wantsToSkipPending = pendingItem && cancelPhrases.test(text || "");
// Si quiere saltar el pending, eliminarlo // Si quiere saltar el pending, eliminarlo - PERO solo si NO es un intent prioritario
if (wantsToSkipPending && pendingItem) { // (ej: "nada más, que tengo en el carrito?" tiene "nada" pero el intent es view_cart)
if (wantsToSkipPending && pendingItem && !isPriorityIntent) {
currentOrder = { currentOrder = {
...currentOrder, ...currentOrder,
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id), pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
@@ -210,6 +278,96 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
}; };
} }
// 2.5) remove_from_cart: quitar productos del carrito o modificar cantidad
if (intent === "remove_from_cart") {
const items = nlu?.entities?.items || [];
const removedItems = [];
const addedItems = [];
const notFoundItems = [];
let updatedOrder = currentOrder;
for (const item of items) {
if (!item.product_query) continue;
// Intentar remover el producto
const { order: orderAfterRemove, removed } = removeCartItem(updatedOrder, item.product_query);
if (removed) {
removedItems.push(removed.name || item.product_query);
updatedOrder = orderAfterRemove;
} else {
notFoundItems.push(item.product_query);
}
// Si también quiere agregar una cantidad nueva (ej: "cambiame X por Y")
if (item.quantity && item.quantity > 0) {
// Esto parece un "agregar" más que un "quitar"
addedItems.push({ query: item.product_query, qty: item.quantity, unit: item.unit });
}
}
// Si hay items para agregar (como "3 provoletas wapi" en "no, 3 provoletas wapi")
if (addedItems.length > 0) {
for (const addItem of addedItems) {
const searchResult = await retrieveCandidates({ tenantId, query: addItem.query, limit: 20 });
const candidates = searchResult?.candidates || [];
const pendingItem = createPendingItemFromSearch({
query: addItem.query,
quantity: addItem.qty,
unit: addItem.unit,
candidates,
});
updatedOrder = addPendingItem(updatedOrder, pendingItem);
}
updatedOrder = moveReadyToCart(updatedOrder);
}
// Generar respuesta
let reply = "";
if (removedItems.length > 0) {
reply += `Listo, saqué: ${removedItems.join(", ")}. `;
}
if (notFoundItems.length > 0 && removedItems.length === 0) {
reply += `No encontré "${notFoundItems.join(", ")}" en tu carrito. `;
}
// Si hay pending items nuevos, pedir clarificación
const nextPending = getNextPendingItem(updatedOrder);
if (nextPending) {
if (nextPending.status === PendingStatus.NEEDS_TYPE) {
const { question } = formatOptionsForDisplay(nextPending);
reply += question;
return {
plan: {
reply: reply.trim(),
next_state: ConversationState.CART,
intent: "remove_from_cart",
missing_fields: ["product_selection"],
order_action: removedItems.length > 0 ? "remove_from_cart" : "none",
},
decision: { actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], order: updatedOrder, audit },
};
}
}
// Mostrar carrito actualizado
const cartDisplay = formatCartForDisplay(updatedOrder);
reply += `\n\n${cartDisplay}\n\n¿Algo más?`;
return {
plan: {
reply: reply.trim(),
next_state: ConversationState.CART,
intent: "remove_from_cart",
missing_fields: [],
order_action: removedItems.length > 0 ? "remove_from_cart" : "none",
},
decision: { actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [], order: updatedOrder, audit },
};
}
// 3) confirm_order: ir a SHIPPING si hay items // 3) confirm_order: ir a SHIPPING si hay items
if (intent === "confirm_order") { if (intent === "confirm_order") {
// Primero mover pending READY a cart // Primero mover pending READY a cart
@@ -445,10 +603,11 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
const finalOrder = moveReadyToCart(updatedOrder); const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
return { return {
plan: { plan: {
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}. Ya lo anoté. ¿Algo más?`, reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
next_state: ConversationState.CART, next_state: ConversationState.CART,
intent: "add_to_cart", intent: "add_to_cart",
missing_fields: [], missing_fields: [],
@@ -478,9 +637,10 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
const lastAdded = currentOrder.cart[currentOrder.cart.length - 1]; const lastAdded = currentOrder.cart[currentOrder.cart.length - 1];
if (lastAdded) { if (lastAdded) {
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`; const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
const cartSummary = formatCartForDisplay(currentOrder);
return { return {
plan: { plan: {
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}. ¿Algo más?`, reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}.\n\n${cartSummary}\n\n¿Algo más?`,
next_state: ConversationState.CART, next_state: ConversationState.CART,
intent: "add_to_cart", intent: "add_to_cart",
missing_fields: [], missing_fields: [],
@@ -834,13 +994,25 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
} }
async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) { 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 // Si necesita seleccionar tipo
if (pendingItem.status === PendingStatus.NEEDS_TYPE) { if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
const idx = parseIndexSelection(text); const idx = parseIndexSelection(text);
// Show more // Show more o mostrar opciones
if (isShowMoreRequest(text)) { if (isShowMoreRequest(text) || isShowOptionsRequest(text)) {
// TODO: implement pagination
const { question } = formatOptionsForDisplay(pendingItem); const { question } = formatOptionsForDisplay(pendingItem);
return { return {
plan: { plan: {
@@ -854,9 +1026,17 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
}; };
} }
// Selection by index // Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar
if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) { const textMatch = !idx && pendingItem.candidates?.length > 0
const selected = pendingItem.candidates[idx - 1]; ? 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: [] }); 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 // Usar la cantidad que pidió originalmente el usuario, o la unidad pedida para determinar si necesita cantidad
@@ -904,9 +1084,10 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
const qtyDisplay = displayUnit === "unit" const qtyDisplay = displayUnit === "unit"
? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}` ? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
: `${finalQty}${displayUnit}`; : `${finalQty}${displayUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
return { return {
plan: { plan: {
reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}. ¿Algo más?`, reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`,
next_state: ConversationState.CART, next_state: ConversationState.CART,
intent: "add_to_cart", intent: "add_to_cart",
missing_fields: [], missing_fields: [],
@@ -916,13 +1097,17 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
}; };
} }
// Si no hay candidatos (producto no encontrado) y el usuario da texto libre, // Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar
// intentar re-buscar con el texto como aclaración // Esto funciona tanto si no hay candidatos como si hay pero el usuario quiere otra cosa
if ((!pendingItem.candidates || pendingItem.candidates.length === 0) && text && text.length > 2) { 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 newQuery = text.trim();
const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 }); const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 });
const newCandidates = searchResult?.candidates || []; const newCandidates = searchResult?.candidates || [];
audit.retry_search = { query: newQuery, count: newCandidates.length }; audit.retry_search = { query: newQuery, count: newCandidates.length, had_previous: pendingItem.candidates?.length || 0 };
if (newCandidates.length > 0) { if (newCandidates.length > 0) {
// Encontramos opciones con la nueva búsqueda // Encontramos opciones con la nueva búsqueda
@@ -943,6 +1128,57 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p), 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); const { question } = formatOptionsForDisplay(updatedPending);
return { return {
plan: { plan: {
@@ -956,30 +1192,44 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
}; };
} }
// Sigue sin encontrar después de la aclaración -> escalar a humano // No encontró nada con la nueva búsqueda
// Primero eliminar el pending item que no se puede resolver // Si no había candidatos antes -> escalar a humano
const orderWithoutPending = { if (!pendingItem.candidates || pendingItem.candidates.length === 0) {
...order, const orderWithoutPending = {
pending: (order.pending || []).filter(p => p.id !== pendingItem.id), ...order,
}; pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
};
audit.escalated_to_human = true;
audit.original_query = pendingItem.query;
audit.retry_query = newQuery;
return createHumanTakeoverResponse({
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
order: orderWithoutPending,
context: {
original_query: pendingItem.query,
user_clarification: newQuery,
search_attempts: 2,
},
});
}
audit.escalated_to_human = true; // Había candidatos pero la búsqueda nueva no encontró, mantener los anteriores
audit.original_query = pendingItem.query; const { question } = formatOptionsForDisplay(pendingItem);
audit.retry_query = newQuery; return {
plan: {
// Crear takeover con contexto de la búsqueda fallida reply: `No encontré "${newQuery}". ${question}`,
return createHumanTakeoverResponse({ next_state: ConversationState.CART,
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`, intent: "other",
order: orderWithoutPending, missing_fields: ["product_selection"],
context: { order_action: "none",
original_query: pendingItem.query,
user_clarification: newQuery,
search_attempts: 2,
}, },
}); decision: { actions: [], order, audit },
};
} }
// No entendió, volver a preguntar // No entendió (no es número, no es texto largo), volver a preguntar
const { question } = formatOptionsForDisplay(pendingItem); const { question } = formatOptionsForDisplay(pendingItem);
return { return {
plan: { plan: {
@@ -1017,10 +1267,11 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
const finalOrder = moveReadyToCart(updatedOrder); const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`; const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
return { return {
plan: { plan: {
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}. ¿Algo más?`, reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
next_state: ConversationState.CART, next_state: ConversationState.CART,
intent: "add_to_cart", intent: "add_to_cart",
missing_fields: [], missing_fields: [],
@@ -1083,10 +1334,11 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
const finalOrder = moveReadyToCart(updatedOrder); const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
return { return {
plan: { plan: {
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}. Ya lo anoté. ¿Algo más?`, reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
next_state: ConversationState.CART, next_state: ConversationState.CART,
intent: "add_to_cart", intent: "add_to_cart",
missing_fields: [], missing_fields: [],