ux improved

This commit is contained in:
Lucas Tettamanti
2026-01-17 04:13:35 -03:00
parent 98e3d78e3d
commit 63b9ecef61
35 changed files with 4266 additions and 75 deletions

View File

@@ -1,6 +1,7 @@
import { llmNluV3 } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
import { safeNextState } from "./fsm.js";
import { handleRecommend } from "./recommendations.js";
function unitAskFor(displayUnit) {
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
@@ -19,6 +20,12 @@ function inferDefaultUnit({ name, categories }) {
const cats = Array.isArray(categories) ? categories : [];
const hay = (re) =>
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) {
return "unit";
}
if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) {
return "unit";
}
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
return "unit";
}
@@ -165,6 +172,25 @@ function resolveQuantity({ quantity, unit, displayUnit }) {
function buildPendingItemFromCandidate(candidate) {
const displayUnit = inferDefaultUnit({ name: candidate.name, categories: candidate.categories });
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H14",
location: "turnEngineV3.js:171",
message: "pending_item_display_unit",
data: {
name: candidate?.name || null,
categories: Array.isArray(candidate?.categories) ? candidate.categories.map((c) => c?.name || c) : [],
display_unit: displayUnit,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
return {
product_id: Number(candidate.woo_product_id),
variation_id: null,
@@ -192,6 +218,173 @@ function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
}
/**
* Procesa múltiples items mencionados en un solo mensaje.
* Para cada item: busca en catálogo, si hay match fuerte + cantidad -> agrega al carrito.
* Si hay ambigüedad, para y crea pending_clarification para el primer item ambiguo.
*/
async function processMultiItems({
tenantId,
items,
prev_state,
prev_context,
audit,
}) {
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
const actions = [];
const context_patch = {};
const addedItems = [];
const addedLabels = [];
let prevItems = Array.isArray(prev?.order_basket?.items) ? [...prev.order_basket.items] : [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const { candidates, audit: catAudit } = await retrieveCandidates({
tenantId,
query: item.product_query,
limit: 12,
});
audit.catalog_multi = audit.catalog_multi || [];
audit.catalog_multi.push({ query: item.product_query, count: candidates?.length || 0 });
if (!candidates.length) {
// No encontrado, seguimos con los demás
continue;
}
const best = candidates[0];
const second = candidates[1];
const strong = candidates.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
if (!strong) {
// Ambigüedad: crear pending_clarification para este item y guardar los restantes
const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
// Guardar cantidad pendiente para este item
if (item.quantity != null) {
context_patch.pending_quantity = item.quantity;
context_patch.pending_unit = item.unit;
}
// Guardar items restantes para procesar después
const remainingItems = items.slice(i + 1);
if (remainingItems.length > 0) {
context_patch.pending_multi_items = remainingItems;
}
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
// Si ya agregamos algunos items, incluirlos en el contexto
if (addedItems.length > 0) {
context_patch.order_basket = { items: prevItems };
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
let reply = question;
if (addedLabels.length > 0) {
reply = `Anoté ${addedLabels.join(", ")}. Ahora, para "${item.product_query}":\n\n${question}`;
}
return {
plan: {
reply,
next_state,
intent: "add_to_cart",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: addedItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Match fuerte, verificar cantidad
const pendingItem = buildPendingItemFromCandidate(best);
const qty = resolveQuantity({
quantity: item.quantity,
unit: item.unit,
displayUnit: pendingItem.display_unit,
});
if (!qty?.quantity) {
// Sin cantidad: crear pending_item para este y guardar restantes
context_patch.pending_item = pendingItem;
const remainingItems = items.slice(i + 1);
if (remainingItems.length > 0) {
context_patch.pending_multi_items = remainingItems;
}
if (addedItems.length > 0) {
context_patch.order_basket = { items: prevItems };
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
let reply = unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg");
if (addedLabels.length > 0) {
reply = `Anoté ${addedLabels.join(", ")}. Para ${pendingItem.name}, ${reply.toLowerCase()}`;
}
return {
plan: {
reply,
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: addedItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Todo completo: agregar al carrito
const cartItem = {
product_id: pendingItem.product_id,
variation_id: pendingItem.variation_id,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name,
};
prevItems.push(cartItem);
addedItems.push(cartItem);
actions.push({ type: "add_to_cart", payload: cartItem });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg de ${pendingItem.name}`
: qty.display_unit === "unit"
? `${qty.display_quantity} ${pendingItem.name}`
: `${qty.display_quantity}g de ${pendingItem.name}`;
addedLabels.push(display);
}
// Todos los items procesados exitosamente
if (addedItems.length > 0) {
context_patch.order_basket = { items: prevItems };
context_patch.pending_item = null;
context_patch.pending_clarification = null;
context_patch.pending_quantity = null;
context_patch.pending_unit = null;
context_patch.pending_multi_items = null;
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
return {
plan: {
reply: `Perfecto, anoto ${addedLabels.join(" y ")}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: addedItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Ningún item encontrado
return null;
}
export async function runTurnV3({
tenantId,
chat_id,
@@ -206,6 +399,12 @@ export async function runTurnV3({
const context_patch = {};
const audit = {};
// Observabilidad (NO se envía al LLM)
audit.trace = {
tenantId: tenantId || null,
chat_id: chat_id || null,
};
const last_shown_options = Array.isArray(prev?.pending_clarification?.options)
? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null }))
: [];
@@ -221,13 +420,100 @@ export async function runTurnV3({
last_shown_options,
locale: tenant_config?.locale || "es-AR",
};
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H6",
location: "turnEngineV3.js:231",
message: "nlu_input_built",
data: {
text_len: String(nluInput.last_user_message || "").length,
state: nluInput.conversation_state || null,
memory_len: String(nluInput.memory_summary || "").length,
pending_clarification: Boolean(nluInput.pending_context?.pending_clarification),
pending_item: Boolean(nluInput.pending_context?.pending_item),
last_shown_options: Array.isArray(nluInput.last_shown_options)
? nluInput.last_shown_options.length
: null,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H5",
location: "turnEngineV3.js:235",
message: "nlu_result",
data: {
intent: nlu?.intent || null,
needsCatalog: Boolean(nlu?.needs?.catalog_lookup),
has_pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
has_pending_item: Boolean(prev?.pending_item?.product_id),
nlu_valid: validation?.ok ?? null,
raw_len: typeof raw_text === "string" ? raw_text.length : null,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
// 0) Procesar multi-items si hay varios productos en un mensaje
// Solo si no hay pending_clarification ni pending_item (flujo limpio)
if (
Array.isArray(nlu?.entities?.items) &&
nlu.entities.items.length > 0 &&
!prev?.pending_clarification?.candidates?.length &&
!prev?.pending_item?.product_id
) {
const multiResult = await processMultiItems({
tenantId,
items: nlu.entities.items,
prev_state,
prev_context: prev,
audit,
});
if (multiResult) {
return multiResult;
}
// Si multiResult es null, ningún item fue encontrado, seguir con flujo normal
}
// 1) Resolver pending_clarification primero
if (prev?.pending_clarification?.candidates?.length) {
const resolved = resolvePendingSelection({ text, nlu, pending: prev.pending_clarification });
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H12",
location: "turnEngineV3.js:239",
message: "pending_clarification_resolved",
data: {
kind: resolved?.kind || null,
selection_type: nlu?.entities?.selection?.type || null,
selection_value: nlu?.entities?.selection?.value || null,
text_len: String(text || "").length,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (resolved.kind === "more") {
const nextPending = resolved.pending || prev.pending_clarification;
const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question;
@@ -249,9 +535,10 @@ export async function runTurnV3({
}
if (resolved.kind === "chosen" && resolved.chosen) {
const pendingItem = buildPendingItemFromCandidate(resolved.chosen);
// Usar cantidad guardada como fallback si el NLU actual no la tiene
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
quantity: nlu?.entities?.quantity ?? prev?.pending_quantity,
unit: nlu?.entities?.unit ?? prev?.pending_unit,
displayUnit: pendingItem.display_unit,
});
if (qty?.quantity) {
@@ -266,7 +553,34 @@ export async function runTurnV3({
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
context_patch.pending_clarification = null;
context_patch.pending_quantity = null;
context_patch.pending_unit = null;
actions.push({ type: "add_to_cart", payload: item });
// Procesar pending_multi_items si hay
const pendingMulti = Array.isArray(prev?.pending_multi_items) ? prev.pending_multi_items : [];
if (pendingMulti.length > 0) {
context_patch.pending_multi_items = null;
const multiResult = await processMultiItems({
tenantId,
items: pendingMulti,
prev_state,
prev_context: { ...prev, ...context_patch },
audit,
});
if (multiResult) {
// Combinar resultados
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg de ${pendingItem.name}`
: qty.display_unit === "unit"
? `${qty.display_quantity} ${pendingItem.name}`
: `${qty.display_quantity}g de ${pendingItem.name}`;
multiResult.plan.reply = `Anoté ${display}. ${multiResult.plan.reply}`;
multiResult.decision.actions = [...actions, ...multiResult.decision.actions];
return multiResult;
}
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
@@ -287,6 +601,7 @@ export async function runTurnV3({
}
context_patch.pending_item = pendingItem;
context_patch.pending_clarification = null;
// Preservar pending_quantity si había, se usará cuando el usuario dé cantidad
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
@@ -320,11 +635,32 @@ export async function runTurnV3({
// 2) Si hay pending_item, esperamos cantidad
if (prev?.pending_item?.product_id) {
const pendingItem = prev.pending_item;
// Usar cantidad guardada como fallback si el NLU actual no la tiene
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
quantity: nlu?.entities?.quantity ?? prev?.pending_quantity,
unit: nlu?.entities?.unit ?? prev?.pending_unit,
displayUnit: pendingItem.display_unit || "kg",
});
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H12",
location: "turnEngineV3.js:332",
message: "pending_item_quantity",
data: {
quantity_in: nlu?.entities?.quantity ?? null,
unit_in: nlu?.entities?.unit ?? null,
qty_resolved: qty?.quantity ?? null,
text: String(text || "").slice(0, 20),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (qty?.quantity) {
const item = {
product_id: Number(pendingItem.product_id),
@@ -336,7 +672,34 @@ export async function runTurnV3({
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
context_patch.pending_quantity = null;
context_patch.pending_unit = null;
actions.push({ type: "add_to_cart", payload: item });
// Procesar pending_multi_items si hay
const pendingMulti = Array.isArray(prev?.pending_multi_items) ? prev.pending_multi_items : [];
if (pendingMulti.length > 0) {
context_patch.pending_multi_items = null;
const multiResult = await processMultiItems({
tenantId,
items: pendingMulti,
prev_state,
prev_context: { ...prev, ...context_patch },
audit,
});
if (multiResult) {
// Combinar resultados
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg de ${item.label}`
: qty.display_unit === "unit"
? `${qty.display_quantity} ${item.label}`
: `${qty.display_quantity}g de ${item.label}`;
multiResult.plan.reply = `Anoté ${display}. ${multiResult.plan.reply}`;
multiResult.decision.actions = [...actions, ...multiResult.decision.actions];
return multiResult;
}
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
@@ -371,8 +734,71 @@ export async function runTurnV3({
// 3) Intento normal
const intent = nlu?.intent || "other";
const productQuery = String(nlu?.entities?.product_query || "").trim();
let productQuery = String(nlu?.entities?.product_query || "").trim();
const needsCatalog = Boolean(nlu?.needs?.catalog_lookup);
const lastBasketItem = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items.slice(-1)[0] : null;
const fallbackQuery =
!productQuery && intent === "browse"
? (prev?.pending_item?.name || lastBasketItem?.label || lastBasketItem?.name || null)
: null;
if (fallbackQuery) {
productQuery = String(fallbackQuery).trim();
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H13",
location: "turnEngineV3.js:390",
message: "browse_fallback_query",
data: {
fallback: productQuery,
has_basket: Boolean(lastBasketItem),
has_pending_item: Boolean(prev?.pending_item?.name),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
if (intent === "recommend") {
const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
const rec = await handleRecommend({
tenantId,
text,
prev_context: prev,
basket_items: basketItems,
});
if (rec?.actions?.length) actions.push(...rec.actions);
if (rec?.context_patch) Object.assign(context_patch, rec.context_patch);
if (rec?.audit) audit.recommend = rec.audit;
const didShowOptions = actions.some((a) => a?.type === "show_options");
const { next_state, validation: v } = safeNextState(
prev_state,
{ ...prev, ...context_patch },
{ did_show_options: didShowOptions, is_browsing: didShowOptions }
);
const missing_fields = [];
if (rec?.asked_slot) missing_fields.push(rec.asked_slot);
if (didShowOptions) missing_fields.push("product_selection");
if (!rec?.asked_slot && !didShowOptions && !basketItems.length && !rec?.candidates?.length) {
missing_fields.push("recommend_base");
}
return {
plan: {
reply: rec?.reply || "¿Qué te gustaría que te recomiende?",
next_state,
intent: "recommend",
missing_fields,
order_action: "none",
basket_resolved: { items: basketItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (intent === "greeting") {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
@@ -484,6 +910,11 @@ export async function runTurnV3({
const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
// Guardar cantidad pendiente para usarla después de la selección
if (nlu?.entities?.quantity != null) {
context_patch.pending_quantity = nlu.entities.quantity;
context_patch.pending_unit = nlu.entities.unit;
}
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {