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");
|
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;
|
||||||
|
|||||||
@@ -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:
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.escalated_to_human = true;
|
||||||
audit.original_query = pendingItem.query;
|
audit.original_query = pendingItem.query;
|
||||||
audit.retry_query = newQuery;
|
audit.retry_query = newQuery;
|
||||||
|
|
||||||
// Crear takeover con contexto de la búsqueda fallida
|
return createHumanTakeoverResponse({
|
||||||
return createHumanTakeoverResponse({
|
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
|
||||||
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
|
order: orderWithoutPending,
|
||||||
order: orderWithoutPending,
|
context: {
|
||||||
context: {
|
original_query: pendingItem.query,
|
||||||
original_query: pendingItem.query,
|
user_clarification: newQuery,
|
||||||
user_clarification: newQuery,
|
search_attempts: 2,
|
||||||
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);
|
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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user