modificando el patron del sistema, orientado mas al usuario

This commit is contained in:
Lucas Tettamanti
2026-01-25 22:32:58 -03:00
parent 93e331535f
commit bd63d92c50
15 changed files with 707 additions and 83 deletions

View File

@@ -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) {

View File

@@ -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",
};
}

View File

@@ -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
};
}

View File

@@ -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 {