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