modificando el patron del sistema, orientado mas al usuario
This commit is contained in:
@@ -63,7 +63,7 @@ export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { response, responded_by, add_alias } = req.body || {};
|
||||
const { response, responded_by, add_alias, cart_items } = req.body || {};
|
||||
|
||||
if (!response) {
|
||||
return res.status(400).json({ ok: false, error: "response_required" });
|
||||
@@ -75,6 +75,7 @@ export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => {
|
||||
response,
|
||||
respondedBy: responded_by || null,
|
||||
addAlias: add_alias || null,
|
||||
cartItems: cart_items || null,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getTakeoverStats,
|
||||
} from "../db/takeoverRepo.js";
|
||||
import { insertAlias, upsertAliasMapping } from "../db/repo.js";
|
||||
import { getRecentMessagesForLLM } from "../../2-identity/db/repo.js";
|
||||
import { getRecentMessagesForLLM, getConversationState, upsertConversationState } from "../../2-identity/db/repo.js";
|
||||
|
||||
/**
|
||||
* Lista takeovers pendientes de respuesta
|
||||
@@ -99,19 +99,72 @@ export async function handleRespondToTakeover({
|
||||
response,
|
||||
respondedBy = null,
|
||||
addAlias = null, // { query: string, woo_product_id: number }
|
||||
cartItems = null, // [{ woo_id, name, qty, unit }] - items a agregar al carrito
|
||||
}) {
|
||||
// Responder al takeover
|
||||
// Responder al takeover con los items del carrito
|
||||
const result = await respondToTakeover({
|
||||
tenantId,
|
||||
id,
|
||||
humanResponse: response,
|
||||
respondedBy,
|
||||
cartItems, // Pasar los items para que se guarden
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Takeover not found or already responded");
|
||||
}
|
||||
|
||||
// Si hay items para agregar al carrito, actualizar el estado de la conversación
|
||||
if (cartItems && cartItems.length > 0 && result.chat_id) {
|
||||
try {
|
||||
// Obtener estado actual
|
||||
const currentState = await getConversationState(tenantId, result.chat_id);
|
||||
const context = currentState?.context || {};
|
||||
const order = context.order || { cart: [], pending: [] };
|
||||
|
||||
// Agregar los items al carrito
|
||||
const newCartItems = cartItems.map(item => ({
|
||||
woo_id: item.woo_id,
|
||||
qty: parseFloat(item.qty) || 1,
|
||||
unit: item.unit || "kg",
|
||||
name: item.name || null,
|
||||
price: item.price || null,
|
||||
}));
|
||||
|
||||
order.cart = [...(order.cart || []), ...newCartItems];
|
||||
|
||||
// Actualizar estado: cambiar a CART para que el bot retome
|
||||
await upsertConversationState({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: result.chat_id,
|
||||
state: "CART", // Retomar flujo normal
|
||||
last_intent: "human_response",
|
||||
context: { ...context, order },
|
||||
});
|
||||
|
||||
console.log(`[takeovers] Added ${newCartItems.length} items to cart for ${result.chat_id}`);
|
||||
} catch (e) {
|
||||
console.error("[takeovers] Error updating cart:", e);
|
||||
// No fallar si hay error al actualizar carrito
|
||||
}
|
||||
} else if (result.chat_id) {
|
||||
// Si no hay items pero respondió, igual cambiar estado a CART preservando el context
|
||||
try {
|
||||
const currentState = await getConversationState(tenantId, result.chat_id);
|
||||
const context = currentState?.context || {};
|
||||
|
||||
await upsertConversationState({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: result.chat_id,
|
||||
state: "CART",
|
||||
last_intent: "human_response",
|
||||
context,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[takeovers] Error updating state:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Si se pidió agregar alias, hacerlo
|
||||
if (addAlias && addAlias.query && addAlias.woo_product_id) {
|
||||
try {
|
||||
@@ -138,6 +191,7 @@ export async function handleRespondToTakeover({
|
||||
return {
|
||||
ok: true,
|
||||
takeover: result,
|
||||
cart_items_added: cartItems?.length || 0,
|
||||
message: "Response sent successfully",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ export function createPendingItem({
|
||||
selected_unit = null,
|
||||
qty = null,
|
||||
unit = null,
|
||||
status = PendingStatus.NEEDS_TYPE
|
||||
status = PendingStatus.NEEDS_TYPE,
|
||||
requested_qty = null,
|
||||
requested_unit = null,
|
||||
}) {
|
||||
return {
|
||||
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -66,6 +68,8 @@ export function createPendingItem({
|
||||
qty, // Cantidad (null si NEEDS_QUANTITY)
|
||||
unit, // Unidad elegida por usuario
|
||||
status,
|
||||
requested_qty, // Cantidad pedida originalmente por el usuario (para usar después de selección)
|
||||
requested_unit, // Unidad pedida originalmente por el usuario
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "./orderModel.js";
|
||||
import { handleRecommend } from "./recommendations.js";
|
||||
import { getProductQtyRules } from "../0-ui/db/repo.js";
|
||||
import { createHumanTakeoverResponse } from "./nlu/humanFallback.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Utilidades
|
||||
@@ -134,9 +135,55 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
const actions = [];
|
||||
|
||||
// 1) Si hay pending items sin resolver, procesar clarificación
|
||||
// Intents que tienen prioridad sobre pending items (permiten "escapar" del loop)
|
||||
const priorityIntents = ["view_cart", "confirm_order", "greeting"];
|
||||
const isPriorityIntent = priorityIntents.includes(intent);
|
||||
|
||||
// Detectar si el usuario quiere cancelar/saltar el pending item actual
|
||||
const pendingItem = getNextPendingItem(currentOrder);
|
||||
if (pendingItem) {
|
||||
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) {
|
||||
currentOrder = {
|
||||
...currentOrder,
|
||||
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
|
||||
};
|
||||
audit.skipped_pending = pendingItem.query;
|
||||
|
||||
// Si hay más pending items, continuar con el siguiente
|
||||
const nextPending = getNextPendingItem(currentOrder);
|
||||
if (nextPending) {
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Ok, salteo "${pendingItem.query}". ${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// No hay más pending, mostrar confirmación
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación
|
||||
if (pendingItem && !isPriorityIntent) {
|
||||
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit });
|
||||
if (result) return result;
|
||||
}
|
||||
@@ -768,6 +815,8 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
||||
}
|
||||
|
||||
// Multiple candidates, needs selection
|
||||
// Guardar la cantidad y unidad que pidió el usuario para usarla después de seleccionar
|
||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: cands.slice(0, 20).map(c => ({
|
||||
@@ -778,6 +827,9 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
||||
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
|
||||
})),
|
||||
status: PendingStatus.NEEDS_TYPE,
|
||||
// Guardar cantidad/unidad pedida para usarla al seleccionar
|
||||
requested_qty: hasQty ? Number(quantity) : null,
|
||||
requested_unit: normalizeUnit(unit) || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -806,7 +858,19 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
|
||||
if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) {
|
||||
const selected = pendingItem.candidates[idx - 1];
|
||||
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
|
||||
const needsQuantity = displayUnit !== "unit";
|
||||
|
||||
// Usar la cantidad que pidió originalmente el usuario, o la unidad pedida para determinar si necesita cantidad
|
||||
const requestedQty = pendingItem.requested_qty;
|
||||
const requestedUnit = pendingItem.requested_unit || displayUnit;
|
||||
const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0;
|
||||
|
||||
// Si vende por peso y no tenemos cantidad, preguntar. Si vende por unidad y tiene cantidad, usar esa.
|
||||
const sellsByWeight = displayUnit !== "unit";
|
||||
const needsQuantity = sellsByWeight && !hasRequestedQty;
|
||||
|
||||
// Determinar la cantidad final
|
||||
const finalQty = hasRequestedQty ? requestedQty : 1;
|
||||
const finalUnit = requestedUnit || displayUnit;
|
||||
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
selected_woo_id: selected.woo_id,
|
||||
@@ -815,8 +879,8 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
|
||||
selected_unit: displayUnit,
|
||||
candidates: [],
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
qty: needsQuantity ? null : 1,
|
||||
unit: displayUnit,
|
||||
qty: needsQuantity ? null : finalQty,
|
||||
unit: finalUnit,
|
||||
});
|
||||
|
||||
// Si necesita cantidad, preguntar
|
||||
@@ -836,9 +900,13 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
|
||||
|
||||
// Listo, mover al cart
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
// Formatear la cantidad según la unidad
|
||||
const qtyDisplay = displayUnit === "unit"
|
||||
? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
|
||||
: `${finalQty}${displayUnit}`;
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto 1 ${selected.name}. ¿Algo más?`,
|
||||
reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}. ¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
@@ -848,6 +916,69 @@ 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) {
|
||||
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 };
|
||||
|
||||
if (newCandidates.length > 0) {
|
||||
// Encontramos opciones con la nueva búsqueda
|
||||
const updatedPending = {
|
||||
...pendingItem,
|
||||
query: newQuery,
|
||||
candidates: newCandidates.slice(0, 20).map(c => ({
|
||||
woo_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
sell_unit: c.sell_unit || null,
|
||||
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
|
||||
})),
|
||||
};
|
||||
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p),
|
||||
};
|
||||
|
||||
const { question } = formatOptionsForDisplay(updatedPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// 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),
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No entendió, volver a preguntar
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user