Tier 1: chat quality — fuzzy aliases, reply templates, dedup, rewriter
Foco: matar repetición y adaptar respuestas. Los handlers tenían ~30 strings hardcodeadas (3-7 lugares cada una). Aliases hacían substring exacto. - pg_trgm + GIN indexes en product_aliases / alias_product_mappings. Captura plurales, diminutivos, typos sin reglas. catalogRetrieval re-busca el snapshot con normalized_alias cuando el query original no rinde (vasio→vacio→Vacío). - reply_templates table + replyTemplates.js. 20 keys, 2-3 variantes c/u con DEFAULTS hardcodeados como fallback. pickVariant excluye las usadas en context.recent_replies (FIFO cap 8). Wired en idle/cart/cartHelpers/ shipping/payment/waiting. - failed_searches counter en context. count>=3 escala via humanFallback. Reset en cada add_to_cart exitoso. - storeContext.js: vars derivadas de getStoreConfig (delivery_zones, hours, zonas) listas para inyectar en templates cuando los datos se carguen. - replyRewriter.js: LLM call opcional (REPLY_REWRITER=1) que adapta el template al hilo conversacional. 1.5s timeout, fallback al template puro. Sólo activo en 8 slots semánticamente importantes. - 12 unit tests para replyTemplates (rotation, recency, FIFO, vars). 208 tests totales pasando. Plan completo: ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -535,32 +535,57 @@ export async function getDecryptedTenantEcommerceConfig({
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
|
||||
export async function searchProductAliases({ tenant_id, q = "", limit = 20, threshold = 0.3 }) {
|
||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return [];
|
||||
const normalized = query.toLowerCase();
|
||||
const like = `%${query}%`;
|
||||
const nlike = `%${normalized}%`;
|
||||
const sql = `
|
||||
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
|
||||
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at,
|
||||
greatest(similarity(alias, $2), similarity(normalized_alias, $3)) as sim
|
||||
from product_aliases
|
||||
where tenant_id=$1
|
||||
and (alias ilike $2 or normalized_alias ilike $3)
|
||||
order by boost desc, updated_at desc
|
||||
where tenant_id = $1
|
||||
and (alias % $2 or normalized_alias % $3)
|
||||
order by sim desc, boost desc, updated_at desc
|
||||
limit $4
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
|
||||
return rows.map((r) => ({
|
||||
tenant_id: r.tenant_id,
|
||||
alias: r.alias,
|
||||
normalized_alias: r.normalized_alias,
|
||||
woo_product_id: r.woo_product_id,
|
||||
category_hint: r.category_hint,
|
||||
boost: r.boost,
|
||||
metadata: r.metadata,
|
||||
updated_at: r.updated_at,
|
||||
}));
|
||||
const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]);
|
||||
return rows
|
||||
.filter((r) => Number(r.sim) >= threshold)
|
||||
.map((r) => ({
|
||||
tenant_id: r.tenant_id,
|
||||
alias: r.alias,
|
||||
normalized_alias: r.normalized_alias,
|
||||
woo_product_id: r.woo_product_id,
|
||||
category_hint: r.category_hint,
|
||||
boost: r.boost,
|
||||
metadata: r.metadata,
|
||||
updated_at: r.updated_at,
|
||||
similarity: Number(r.sim),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function searchAliasProductMappings({ tenant_id, q = "", limit = 50, threshold = 0.3 }) {
|
||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return [];
|
||||
const normalized = query.toLowerCase();
|
||||
const sql = `
|
||||
select alias, woo_product_id, score, similarity(alias, $2) as sim
|
||||
from alias_product_mappings
|
||||
where tenant_id = $1 and alias % $2
|
||||
order by sim desc, score desc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, normalized, lim]);
|
||||
return rows
|
||||
.filter((r) => Number(r.sim) >= threshold)
|
||||
.map((r) => ({
|
||||
alias: r.alias,
|
||||
woo_product_id: Number(r.woo_product_id),
|
||||
score: Number(r.score || 1),
|
||||
similarity: Number(r.sim),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRecoRules({ tenant_id }) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
searchProductAliases,
|
||||
getProductEmbedding,
|
||||
upsertProductEmbedding,
|
||||
getAllAliasProductMappings,
|
||||
searchAliasProductMappings,
|
||||
} from "../2-identity/db/repo.js";
|
||||
|
||||
function getOpenAiKey() {
|
||||
@@ -138,59 +138,61 @@ export async function retrieveCandidates({
|
||||
|
||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||
|
||||
// 1) Buscar aliases que matcheen la query
|
||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
||||
// 1) Buscar aliases con fuzzy matching (pg_trgm).
|
||||
// Captura plurales, diminutivos y typos sin reglas escritas.
|
||||
const [aliases, mappings] = await Promise.all([
|
||||
searchProductAliases({ tenant_id: tenantId, q, limit: 20 }),
|
||||
searchAliasProductMappings({ tenant_id: tenantId, q, limit: 50 }),
|
||||
]);
|
||||
|
||||
const aliasBoostByProduct = new Map();
|
||||
const aliasProductIds = new Set();
|
||||
|
||||
// También buscar en alias_product_mappings (multi-producto)
|
||||
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
|
||||
const normalizedQuery = normalizeText(q);
|
||||
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
|
||||
|
||||
// Buscar mappings cuyos aliases matcheen la query
|
||||
for (const mapping of allMappings) {
|
||||
const aliasNorm = normalizeText(mapping.alias);
|
||||
// Match exacto o parcial del alias
|
||||
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
|
||||
const id = Number(mapping.woo_product_id);
|
||||
const score = Number(mapping.score || 1);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
|
||||
aliasProductIds.add(id);
|
||||
} else {
|
||||
// Check word overlap
|
||||
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
|
||||
for (const word of queryWords) {
|
||||
if (aliasWords.has(word)) {
|
||||
const id = Number(mapping.woo_product_id);
|
||||
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
|
||||
aliasProductIds.add(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness)
|
||||
for (const m of mappings) {
|
||||
const id = m.woo_product_id;
|
||||
const boost = m.score * m.similarity;
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||
aliasProductIds.add(id);
|
||||
}
|
||||
|
||||
// También incluir aliases legacy (product_aliases.woo_product_id)
|
||||
|
||||
// product_aliases legacy (1 alias → 1 producto)
|
||||
for (const a of aliases) {
|
||||
if (a?.woo_product_id) {
|
||||
const id = Number(a.woo_product_id);
|
||||
const boost = Number(a.boost || 0);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
||||
const boost = Number(a.boost || 0) * (a.similarity || 1);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||
aliasProductIds.add(id);
|
||||
}
|
||||
}
|
||||
audit.sources.aliases = aliases.length;
|
||||
audit.sources.alias_mappings = aliasProductIds.size;
|
||||
audit.sources.alias_mappings = mappings.length;
|
||||
|
||||
// 2) Buscar productos por nombre/slug (búsqueda literal)
|
||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
// 2) Buscar productos por nombre/slug (búsqueda literal con query original)
|
||||
let { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
limit: lim,
|
||||
});
|
||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||
|
||||
// 2b) Si el query literal no rinde pero un alias matcheó (typo/plural/diminutivo),
|
||||
// re-buscar el snapshot con el normalized_alias del mejor match.
|
||||
// Esto cierra el loop: "vasio" → alias "vacio" → buscar "vacio" en productos.
|
||||
if ((!wooItems || wooItems.length === 0) && aliases.length > 0) {
|
||||
const refined = aliases[0]?.normalized_alias;
|
||||
if (refined && refined.toLowerCase() !== q.toLowerCase()) {
|
||||
const { items: aliasRefined, source: refSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q: refined,
|
||||
limit: lim,
|
||||
});
|
||||
if (aliasRefined?.length) {
|
||||
wooItems = aliasRefined;
|
||||
audit.sources.snapshot_refined = { query: refined, source: refSource, count: aliasRefined.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
|
||||
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
|
||||
|
||||
204
src/modules/3-turn-engine/replyRewriter.js
Normal file
204
src/modules/3-turn-engine/replyRewriter.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Reply Rewriter — adapta un template base usando contexto conversacional.
|
||||
*
|
||||
* Default ON en pre-producción. Si falla o tarda >1.5s, fallback al template puro.
|
||||
*
|
||||
* Slots que se reescriben (según plan):
|
||||
* cart.didnt_understand, cart.not_found, idle.greeting (1er turno),
|
||||
* cart.added_confirm, cart.ask_more, shipping.ask_method,
|
||||
* shipping.ask_address, payment.ask_method
|
||||
*
|
||||
* El rewriter recibe historial y vars de tienda para que pueda mencionar
|
||||
* datos contextuales (zonas, horarios) cuando estén disponibles.
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
|
||||
let _client = null;
|
||||
let _clientKey = null;
|
||||
|
||||
function getClient() {
|
||||
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
|
||||
if (!apiKey) {
|
||||
const err = new Error("OPENAI_API_KEY is not set");
|
||||
err.code = "OPENAI_NO_KEY";
|
||||
throw err;
|
||||
}
|
||||
if (_client && _clientKey === apiKey) return _client;
|
||||
_clientKey = apiKey;
|
||||
const baseURL = process.env.OPENAI_BASE_URL || undefined;
|
||||
_client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
return _client;
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
const v = String(process.env.REPLY_REWRITER || "").toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes" || v === "on";
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
return process.env.REPLY_REWRITER_MODEL || process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
}
|
||||
|
||||
function getTimeoutMs() {
|
||||
const n = parseInt(process.env.REPLY_REWRITER_TIMEOUT_MS || "1500", 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 1500;
|
||||
}
|
||||
|
||||
function lastN(history, n) {
|
||||
if (!Array.isArray(history) || history.length === 0) return [];
|
||||
return history.slice(-n).map((m) => ({
|
||||
role: m.role === "user" ? "user" : "assistant",
|
||||
content: String(m.content || "").slice(0, 200),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSystemPrompt() {
|
||||
return [
|
||||
"Sos un asistente de carnicería argentina (es-AR), conversacional y cálido.",
|
||||
"Tu tarea: REESCRIBIR un mensaje base de respuesta para que suene natural,",
|
||||
"adaptado al hilo de la conversación, sin sonar repetitivo ni robótico.",
|
||||
"",
|
||||
"REGLAS ESTRICTAS (no negociables):",
|
||||
"1. Mantené la INTENCIÓN exacta del mensaje base. No agregues ofertas,",
|
||||
" precios, productos ni datos que no estén en el contexto.",
|
||||
"2. Si el mensaje base contiene listas, números, opciones (1) X 2) Y),",
|
||||
" tenés que conservarlas EXACTAMENTE.",
|
||||
"3. Largo máximo: ≈ longitud del base + 30 caracteres.",
|
||||
"4. Tono: porteño/argentino, informal pero respetuoso. Sin emojis a menos",
|
||||
" que el base los tenga.",
|
||||
"5. NO repitas la frase exacta de tu mensaje anterior (te la paso en history).",
|
||||
"6. Devolvé SOLO el texto reescrito, sin comillas, sin explicaciones, sin prefijos.",
|
||||
"",
|
||||
"Si no podés mejorar el base manteniendo las reglas, devolvelo tal cual.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const _inflightCache = new Map();
|
||||
const _resultCache = new Map();
|
||||
const RESULT_TTL_MS = 30_000;
|
||||
|
||||
function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) {
|
||||
return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`;
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reescribe una respuesta base usando contexto.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.baseText - el texto del template renderizado
|
||||
* @param {string} params.templateKey - 'cart.didnt_understand', etc.
|
||||
* @param {Array} params.history - últimos mensajes [{role, content}]
|
||||
* @param {string} params.state - estado FSM actual
|
||||
* @param {string} params.userText - último mensaje del usuario
|
||||
* @param {Object} params.vars - vars de tienda (opcional)
|
||||
*
|
||||
* @returns {Promise<{ text: string, rewritten: boolean, model?: string, error?: string, ms: number }>}
|
||||
*/
|
||||
export async function rewriteReply({
|
||||
baseText,
|
||||
templateKey,
|
||||
history = [],
|
||||
state = null,
|
||||
userText = "",
|
||||
vars = {},
|
||||
}) {
|
||||
const t0 = Date.now();
|
||||
if (!isEnabled()) {
|
||||
return { text: baseText, rewritten: false, ms: 0 };
|
||||
}
|
||||
if (!baseText) {
|
||||
return { text: "", rewritten: false, ms: 0 };
|
||||
}
|
||||
|
||||
const recentMsgs = lastN(history, 4);
|
||||
const lastUser = recentMsgs.filter((m) => m.role === "user").pop()?.content || userText || "";
|
||||
const lastAssistant = recentMsgs.filter((m) => m.role === "assistant").pop()?.content || "";
|
||||
|
||||
const key = cacheKey({ templateKey, baseText, lastUserMsg: lastUser, lastAssistantMsg: lastAssistant });
|
||||
|
||||
const cached = _resultCache.get(key);
|
||||
if (cached && Date.now() - cached.t < RESULT_TTL_MS) {
|
||||
return { ...cached.value, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
if (_inflightCache.has(key)) {
|
||||
try {
|
||||
const value = await _inflightCache.get(key);
|
||||
return { ...value, ms: Date.now() - t0 };
|
||||
} catch (_) {
|
||||
// fall through to re-attempt
|
||||
}
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const client = getClient();
|
||||
const model = getModel();
|
||||
const userPayload = {
|
||||
template_key: templateKey,
|
||||
base_message: baseText,
|
||||
conversation_state: state,
|
||||
last_user_message: userText,
|
||||
recent_history: recentMsgs,
|
||||
store_context: {
|
||||
store_name: vars?.store_name || "",
|
||||
delivery_hours: vars?.delivery_hours || "",
|
||||
pickup_hours: vars?.pickup_hours || "",
|
||||
delivery_zones_summary: vars?.delivery_zones_summary || "",
|
||||
},
|
||||
};
|
||||
|
||||
if (dbg.llm) console.log("[rewriter] request", { templateKey, model });
|
||||
|
||||
const resp = await withTimeout(
|
||||
client.chat.completions.create({
|
||||
model,
|
||||
temperature: 0.6,
|
||||
max_tokens: 200,
|
||||
messages: [
|
||||
{ role: "system", content: buildSystemPrompt() },
|
||||
{ role: "user", content: JSON.stringify(userPayload) },
|
||||
],
|
||||
}),
|
||||
getTimeoutMs(),
|
||||
"rewriter"
|
||||
);
|
||||
|
||||
const text = (resp?.choices?.[0]?.message?.content || "").trim();
|
||||
if (!text) {
|
||||
return { text: baseText, rewritten: false, error: "empty" };
|
||||
}
|
||||
|
||||
// Sanity: no debe ser drásticamente más largo que el base
|
||||
const maxLen = baseText.length + 60;
|
||||
const safeText = text.length > maxLen ? text.slice(0, maxLen) : text;
|
||||
|
||||
const result = { text: safeText, rewritten: true, model };
|
||||
_resultCache.set(key, { value: result, t: Date.now() });
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (dbg.llm) console.log("[rewriter] error fallback to base", msg);
|
||||
return { text: baseText, rewritten: false, error: msg };
|
||||
}
|
||||
})();
|
||||
|
||||
_inflightCache.set(key, promise);
|
||||
try {
|
||||
const value = await promise;
|
||||
return { ...value, ms: Date.now() - t0 };
|
||||
} finally {
|
||||
_inflightCache.delete(key);
|
||||
}
|
||||
}
|
||||
317
src/modules/3-turn-engine/replyTemplates.js
Normal file
317
src/modules/3-turn-engine/replyTemplates.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Reply Templates - rotación de variantes con dedup por recencia.
|
||||
*
|
||||
* Cada slot (template_key) tiene N variantes. pickVariant:
|
||||
* 1. Filtra variantes ya usadas en recentReplies (FIFO cap 8 turnos).
|
||||
* 2. Si quedan, weighted-random sobre el resto.
|
||||
* 3. Si todas están en recent, usa la menos reciente.
|
||||
*
|
||||
* Soporta variables {{name}} con applyVariables.
|
||||
*
|
||||
* Si la tabla reply_templates está vacía, fallback a DEFAULTS.
|
||||
*/
|
||||
|
||||
import { pool } from "../shared/db/pool.js";
|
||||
import { rewriteReply } from "./replyRewriter.js";
|
||||
|
||||
const cache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Variantes por defecto. Cuando reply_templates esté vacía o no responda,
|
||||
// el bot igual rota. Diseñado para no requerir seed del DB para shippear.
|
||||
export const DEFAULTS = {
|
||||
// ---------------- IDLE ----------------
|
||||
"idle.greeting": [
|
||||
"¡Hola! ¿En qué te puedo ayudar?",
|
||||
"¡Hola! Estoy para ayudarte con tu pedido. ¿Qué andás buscando?",
|
||||
"Buenas. ¿Querés que te muestre algo en particular o hacemos un pedido?",
|
||||
],
|
||||
"idle.help_prompt": [
|
||||
"Decime qué necesitás. Podés pedirme productos, precios, o armar el pedido directo.",
|
||||
"¿Qué te tiro? Podés pedir algo, preguntar precios o consultar disponibilidad.",
|
||||
],
|
||||
|
||||
// ---------------- CART ----------------
|
||||
"cart.ask_more": [
|
||||
"¿Algo más?",
|
||||
"¿Querés agregar algo más al pedido?",
|
||||
"¿Sumamos algo más o cerramos así?",
|
||||
],
|
||||
"cart.empty_prompt": [
|
||||
"Tu carrito está vacío. ¿Qué querés agregar?",
|
||||
"Todavía no hay nada en el carrito. ¿Por dónde empezamos?",
|
||||
],
|
||||
"cart.not_found": [
|
||||
"No encontré \"{{query}}\". ¿Podés decirlo de otra forma?",
|
||||
"Mmm, no tengo \"{{query}}\" exacto. ¿Probamos con otra cosa?",
|
||||
"No me aparece \"{{query}}\". Si querés, dame otro nombre o detalle más.",
|
||||
],
|
||||
"cart.not_found_v2": [
|
||||
"No encontré \"{{query}}\". ¿Quisiste decir {{suggestions}}?",
|
||||
"No tengo \"{{query}}\" como tal. ¿Te referís a {{suggestions}}?",
|
||||
],
|
||||
"cart.didnt_understand": [
|
||||
"Perdón, no te entendí.",
|
||||
"No me quedó claro, ¿me lo decís de otra forma?",
|
||||
"No te seguí, ¿podés repetir?",
|
||||
],
|
||||
"cart.skip_acknowledged": [
|
||||
"Ok, lo dejamos.",
|
||||
"Listo, no lo agregamos.",
|
||||
],
|
||||
"cart.confirm_to_shipping": [
|
||||
"Buenísimo. ¿Es para delivery o lo pasás a buscar?",
|
||||
"Perfecto. ¿Te lo enviamos o lo retirás?",
|
||||
],
|
||||
"cart.pending_before_close": [
|
||||
"Antes de cerrar, ¿qué hacemos con lo que quedó pendiente?",
|
||||
"Tenemos algo pendiente para resolver antes de cerrar el pedido.",
|
||||
],
|
||||
"cart.added_confirm": [
|
||||
"Anoté {{summary}}. ¿Algo más?",
|
||||
"Listo, {{summary}} agregado. ¿Sumamos algo más?",
|
||||
"Sumé {{summary}}. ¿Querés agregar algo más?",
|
||||
"Va {{summary}}. ¿Algo más?",
|
||||
],
|
||||
"cart.ask_what_product": [
|
||||
"¿Qué producto querés?",
|
||||
"Decime el producto y lo busco.",
|
||||
],
|
||||
"cart.price_no_query": [
|
||||
"¿De qué producto querés saber el precio?",
|
||||
"Decime el producto y te paso el precio.",
|
||||
],
|
||||
"cart.price_results_header": [
|
||||
"Estos son los precios:",
|
||||
"Precios disponibles:",
|
||||
],
|
||||
|
||||
// ---------------- SHIPPING ----------------
|
||||
"shipping.ask_method": [
|
||||
"¿Lo enviamos a domicilio o lo pasás a buscar?",
|
||||
"¿Es para delivery o pickup?",
|
||||
],
|
||||
"shipping.ask_address": [
|
||||
"Pasame la dirección de entrega.",
|
||||
"Decime dónde lo entregamos (calle y altura).",
|
||||
],
|
||||
"shipping.address_recorded": [
|
||||
"Anotado: {{address}}.",
|
||||
"Listo, dirección guardada: {{address}}.",
|
||||
],
|
||||
"shipping.pickup_to_payment": [
|
||||
"Genial, lo pasás a buscar. ¿Cómo abonás?",
|
||||
"Pickup confirmado. ¿Pago en efectivo o link?",
|
||||
],
|
||||
|
||||
// ---------------- PAYMENT ----------------
|
||||
"payment.ask_method": [
|
||||
"¿Cómo querés abonar? Efectivo o link de pago.",
|
||||
"Para cerrar, ¿pagás en efectivo o con link?",
|
||||
],
|
||||
"payment.confirmed": [
|
||||
"Listo, te paso los datos del pedido.",
|
||||
"Perfecto, queda armado. Te paso los datos.",
|
||||
],
|
||||
|
||||
// ---------------- WAITING ----------------
|
||||
"waiting.in_progress": [
|
||||
"Tu pedido está en proceso. Cualquier cosa avisame.",
|
||||
"Esperando confirmación del pago. ¿Necesitás algo más?",
|
||||
],
|
||||
};
|
||||
|
||||
const RECENT_CAP = 8;
|
||||
|
||||
function pickWeightedRandom(variants) {
|
||||
const total = variants.reduce((s, v) => s + (v.weight || 1), 0);
|
||||
if (total <= 0) return variants[0];
|
||||
let r = Math.random() * total;
|
||||
for (const v of variants) {
|
||||
r -= v.weight || 1;
|
||||
if (r <= 0) return v;
|
||||
}
|
||||
return variants[variants.length - 1];
|
||||
}
|
||||
|
||||
async function loadFromDb({ tenantId, templateKey }) {
|
||||
const sql = `
|
||||
select variant, content, weight
|
||||
from reply_templates
|
||||
where tenant_id = $1 and template_key = $2 and is_active = true
|
||||
order by variant asc
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, templateKey]);
|
||||
return rows.map((r) => ({
|
||||
variant: Number(r.variant),
|
||||
content: r.content,
|
||||
weight: Number(r.weight || 1),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadReplyVariants({ tenantId, templateKey, skipCache = false }) {
|
||||
const cacheKey = `${tenantId}:${templateKey}`;
|
||||
if (!skipCache) {
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.variants;
|
||||
}
|
||||
}
|
||||
|
||||
let variants = [];
|
||||
try {
|
||||
variants = await loadFromDb({ tenantId, templateKey });
|
||||
} catch (err) {
|
||||
console.error(`[replyTemplates] DB error loading ${templateKey}: ${err.message}`);
|
||||
}
|
||||
|
||||
if (variants.length === 0) {
|
||||
const defaults = DEFAULTS[templateKey];
|
||||
if (defaults && defaults.length) {
|
||||
variants = defaults.map((content, i) => ({ variant: i + 1, content, weight: 1 }));
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(cacheKey, { variants, timestamp: Date.now() });
|
||||
return variants;
|
||||
}
|
||||
|
||||
export function pickVariant({ variants, recent = [], templateKey }) {
|
||||
if (!variants || variants.length === 0) {
|
||||
return { variant: 0, content: "" };
|
||||
}
|
||||
if (variants.length === 1) {
|
||||
return variants[0];
|
||||
}
|
||||
const recentSet = new Set(recent || []);
|
||||
const fresh = variants.filter((v) => !recentSet.has(`${templateKey}:${v.variant}`));
|
||||
|
||||
if (fresh.length > 0) {
|
||||
return pickWeightedRandom(fresh);
|
||||
}
|
||||
// Todas usadas: elegir la que aparece más temprano en recent (= la menos reciente)
|
||||
let oldestIdx = -1;
|
||||
let oldestVariant = variants[0];
|
||||
for (const v of variants) {
|
||||
const idx = recent.indexOf(`${templateKey}:${v.variant}`);
|
||||
if (idx >= 0 && (oldestIdx < 0 || idx < oldestIdx)) {
|
||||
oldestIdx = idx;
|
||||
oldestVariant = v;
|
||||
}
|
||||
}
|
||||
return oldestVariant;
|
||||
}
|
||||
|
||||
export function applyVariables(content, vars = {}) {
|
||||
let out = String(content || "");
|
||||
// Inject current_date if missing
|
||||
if (!vars.current_date) {
|
||||
const now = new Date();
|
||||
const months = ["enero","febrero","marzo","abril","mayo","junio","julio","agosto","septiembre","octubre","noviembre","diciembre"];
|
||||
vars = { ...vars, current_date: `${now.getDate()} de ${months[now.getMonth()]}` };
|
||||
}
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
const re = new RegExp(`{{\\s*${key}\\s*}}`, "g");
|
||||
out = out.replace(re, value == null ? "" : String(value));
|
||||
}
|
||||
// Limpiar variables no reemplazadas (deja vacío para tolerar datos faltantes)
|
||||
out = out.replace(/\{\{[^}]+\}\}/g, "");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza una respuesta del template, devolviendo texto + template_id
|
||||
* para tracking de recencia. Si conversation_history+state+userText vienen,
|
||||
* y la key está en REWRITE_KEYS, intenta adaptar via LLM rewriter.
|
||||
*
|
||||
* @returns {Promise<{ reply, template_id, variant, rewritten?, rewriter_ms? }>}
|
||||
*/
|
||||
export async function renderReply({
|
||||
tenantId,
|
||||
templateKey,
|
||||
vars = {},
|
||||
recentReplies = [],
|
||||
conversation_history = null,
|
||||
state = null,
|
||||
userText = null,
|
||||
}) {
|
||||
const variants = await loadReplyVariants({ tenantId, templateKey });
|
||||
if (variants.length === 0) {
|
||||
return { reply: "", template_id: `${templateKey}:0`, variant: 0 };
|
||||
}
|
||||
const picked = pickVariant({ variants, recent: recentReplies, templateKey });
|
||||
const baseReply = applyVariables(picked.content, vars);
|
||||
const base = {
|
||||
reply: baseReply,
|
||||
template_id: `${templateKey}:${picked.variant}`,
|
||||
variant: picked.variant,
|
||||
};
|
||||
|
||||
// Solo intentamos rewriter si el handler nos dio contexto conversacional.
|
||||
if (conversation_history === null && userText === null) {
|
||||
return base;
|
||||
}
|
||||
if (!shouldRewrite(templateKey, conversation_history || [])) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const rewritten = await rewriteReply({
|
||||
baseText: baseReply,
|
||||
templateKey,
|
||||
history: conversation_history || [],
|
||||
state,
|
||||
userText: userText || "",
|
||||
vars,
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
reply: rewritten.text || baseReply,
|
||||
rewritten: rewritten.rewritten,
|
||||
rewriter_ms: rewritten.ms,
|
||||
};
|
||||
}
|
||||
|
||||
// Slots donde el rewriter aporta valor (mensajes más visibles / repetitivos).
|
||||
// El resto se renderiza puro; la rotación de variantes ya da variedad.
|
||||
const REWRITE_KEYS = new Set([
|
||||
"cart.didnt_understand",
|
||||
"cart.not_found",
|
||||
"cart.added_confirm",
|
||||
"cart.ask_more",
|
||||
"idle.greeting", // se filtra adicionalmente: solo en 1er turno
|
||||
"shipping.ask_method",
|
||||
"shipping.ask_address",
|
||||
"payment.ask_method",
|
||||
]);
|
||||
|
||||
function shouldRewrite(templateKey, history) {
|
||||
if (!REWRITE_KEYS.has(templateKey)) return false;
|
||||
if (templateKey === "idle.greeting") {
|
||||
// Solo reescribir greeting en el primer turno (no hay history aún)
|
||||
return !Array.isArray(history) || history.length === 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega un template_id a la lista de recent_replies, manteniendo cap.
|
||||
*/
|
||||
export function pushRecent(recentReplies = [], template_id) {
|
||||
if (!template_id) return recentReplies;
|
||||
const next = [...(recentReplies || []), template_id];
|
||||
if (next.length > RECENT_CAP) {
|
||||
return next.slice(next.length - RECENT_CAP);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function invalidateCache(tenantId, templateKey) {
|
||||
if (templateKey) {
|
||||
cache.delete(`${tenantId}:${templateKey}`);
|
||||
} else {
|
||||
for (const k of cache.keys()) {
|
||||
if (k.startsWith(`${tenantId}:`)) cache.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/modules/3-turn-engine/replyTemplates.test.js
Normal file
136
src/modules/3-turn-engine/replyTemplates.test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
// Mock del pool de DB para que loadFromDb devuelva [] (siempre fallback a DEFAULTS)
|
||||
vi.mock("../shared/db/pool.js", () => ({
|
||||
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
|
||||
}));
|
||||
|
||||
// Mock del rewriter para que sea no-op por default en estos tests
|
||||
vi.mock("./replyRewriter.js", () => ({
|
||||
rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })),
|
||||
}));
|
||||
|
||||
import {
|
||||
pickVariant,
|
||||
applyVariables,
|
||||
pushRecent,
|
||||
renderReply,
|
||||
invalidateCache,
|
||||
DEFAULTS,
|
||||
} from "./replyTemplates.js";
|
||||
|
||||
const TENANT = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateCache(TENANT);
|
||||
});
|
||||
|
||||
describe("pickVariant", () => {
|
||||
const variants = [
|
||||
{ variant: 1, content: "A", weight: 1 },
|
||||
{ variant: 2, content: "B", weight: 1 },
|
||||
{ variant: 3, content: "C", weight: 1 },
|
||||
];
|
||||
|
||||
it("returns one variant when none are recent", () => {
|
||||
const r = pickVariant({ variants, recent: [], templateKey: "k" });
|
||||
expect([1, 2, 3]).toContain(r.variant);
|
||||
});
|
||||
|
||||
it("excludes recent variants", () => {
|
||||
const r = pickVariant({
|
||||
variants,
|
||||
recent: ["k:1", "k:2"],
|
||||
templateKey: "k",
|
||||
});
|
||||
expect(r.variant).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to least-recent when all are recent", () => {
|
||||
// recent order is FIFO: oldest first. With ['k:2','k:1','k:3'], k:2 is oldest.
|
||||
const r = pickVariant({
|
||||
variants,
|
||||
recent: ["k:2", "k:1", "k:3"],
|
||||
templateKey: "k",
|
||||
});
|
||||
expect(r.variant).toBe(2);
|
||||
});
|
||||
|
||||
it("returns single variant when only one exists", () => {
|
||||
const r = pickVariant({
|
||||
variants: [{ variant: 1, content: "only", weight: 1 }],
|
||||
recent: ["k:1"],
|
||||
templateKey: "k",
|
||||
});
|
||||
expect(r.variant).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyVariables", () => {
|
||||
it("replaces named variables", () => {
|
||||
expect(applyVariables("Hola {{name}}!", { name: "Pepe" })).toBe("Hola Pepe!");
|
||||
});
|
||||
|
||||
it("strips unmatched variables", () => {
|
||||
expect(applyVariables("a {{missing}} b", {})).toBe("a b");
|
||||
});
|
||||
|
||||
it("auto-injects current_date", () => {
|
||||
const out = applyVariables("Hoy es {{current_date}}.", {});
|
||||
expect(out).toMatch(/Hoy es \d+ de \w+\./);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushRecent", () => {
|
||||
it("appends template_id", () => {
|
||||
expect(pushRecent([], "x:1")).toEqual(["x:1"]);
|
||||
});
|
||||
|
||||
it("caps at 8 entries (FIFO)", () => {
|
||||
let r = [];
|
||||
for (let i = 1; i <= 10; i++) r = pushRecent(r, `k:${i}`);
|
||||
expect(r).toHaveLength(8);
|
||||
expect(r[0]).toBe("k:3");
|
||||
expect(r[7]).toBe("k:10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderReply (DEFAULTS fallback)", () => {
|
||||
it("renders from DEFAULTS when DB returns empty", async () => {
|
||||
const out = await renderReply({
|
||||
tenantId: TENANT,
|
||||
templateKey: "idle.greeting",
|
||||
vars: {},
|
||||
recentReplies: [],
|
||||
});
|
||||
expect(out.template_id).toMatch(/^idle\.greeting:\d+$/);
|
||||
expect(DEFAULTS["idle.greeting"]).toContain(out.reply);
|
||||
});
|
||||
|
||||
it("rotates variants across consecutive calls when feeding recent", async () => {
|
||||
let recent = [];
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await renderReply({
|
||||
tenantId: TENANT,
|
||||
templateKey: "cart.added_confirm",
|
||||
vars: { summary: "X" },
|
||||
recentReplies: recent,
|
||||
});
|
||||
seen.add(r.variant);
|
||||
recent = pushRecent(recent, r.template_id);
|
||||
}
|
||||
// 3 distintas variantes en 3 turnos
|
||||
expect(seen.size).toBe(3);
|
||||
});
|
||||
|
||||
it("returns empty string when key has no variants and no DEFAULT", async () => {
|
||||
const out = await renderReply({
|
||||
tenantId: TENANT,
|
||||
templateKey: "nonexistent.key",
|
||||
vars: {},
|
||||
recentReplies: [],
|
||||
});
|
||||
expect(out.reply).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -19,18 +19,20 @@ import {
|
||||
import { handleRecommend } from "../recommendations.js";
|
||||
import { getProductQtyRules } from "../../0-ui/db/repo.js";
|
||||
import { inferDefaultUnit, unitAskFor } from "./utils.js";
|
||||
import {
|
||||
extractProductQueries,
|
||||
createPendingItemFromSearch,
|
||||
processPendingClarification
|
||||
import {
|
||||
extractProductQueries,
|
||||
createPendingItemFromSearch,
|
||||
processPendingClarification
|
||||
} from "./cartHelpers.js";
|
||||
import { renderReply } from "../replyTemplates.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado CART (carrito activo)
|
||||
*/
|
||||
export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) {
|
||||
export async function handleCartState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history, failedSearches = { count: 0 }, fromIdle = false }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
const rewriteCtx = { conversation_history, state: "CART", userText: text };
|
||||
|
||||
// Intents que tienen prioridad sobre pending items
|
||||
const priorityIntents = ["view_cart", "confirm_order", "greeting"];
|
||||
@@ -43,109 +45,124 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
|
||||
|
||||
// Si quiere saltar el pending - PERO solo si NO es un intent prioritario
|
||||
if (wantsToSkipPending && pendingItem && !isPriorityIntent) {
|
||||
return handleSkipPending({ currentOrder, pendingItem, audit });
|
||||
return handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// 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 });
|
||||
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit, recentReplies, failedSearches, rewriteCtx });
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
|
||||
// 2) view_cart: mostrar carrito actual
|
||||
if (intent === "view_cart") {
|
||||
return handleViewCart({ currentOrder });
|
||||
return handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// 2.5) remove_from_cart: quitar productos del carrito
|
||||
if (intent === "remove_from_cart") {
|
||||
return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit });
|
||||
return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// 3) confirm_order: ir a SHIPPING si hay items
|
||||
if (intent === "confirm_order") {
|
||||
return handleConfirmOrder({ currentOrder, audit });
|
||||
return handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// 4) recommend
|
||||
if (intent === "recommend") {
|
||||
const result = await handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit });
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
|
||||
// 4.5) price_query - consulta de precios
|
||||
if (intent === "price_query") {
|
||||
return handlePriceQuery({ tenantId, nlu, currentOrder, audit });
|
||||
return handlePriceQuery({ tenantId, nlu, currentOrder, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// 5) add_to_cart / browse: buscar productos
|
||||
if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) {
|
||||
return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit });
|
||||
return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit, recentReplies, failedSearches, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// Default
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué más querés agregar?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja el skip de un pending item
|
||||
*/
|
||||
function handleSkipPending({ currentOrder, pendingItem, audit }) {
|
||||
async function handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx }) {
|
||||
const updatedOrder = {
|
||||
...currentOrder,
|
||||
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
|
||||
};
|
||||
audit.skipped_pending = pendingItem.query;
|
||||
|
||||
|
||||
const skipAck = await renderReply({
|
||||
tenantId,
|
||||
templateKey: "cart.skip_acknowledged",
|
||||
vars: { query: pendingItem.query },
|
||||
recentReplies,
|
||||
});
|
||||
|
||||
const nextPending = getNextPendingItem(updatedOrder);
|
||||
if (nextPending) {
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Ok, salteo "${pendingItem.query}". ${question}`,
|
||||
reply: `${skipAck.reply} ${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
decision: { actions: [], order: updatedOrder, audit, template_ids_used: [skipAck.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const cartDisplay = formatCartForDisplay(updatedOrder);
|
||||
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`,
|
||||
reply: `${skipAck.reply}\n\n${cartDisplay}\n\n${askMore.reply}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
decision: {
|
||||
actions: [],
|
||||
order: updatedOrder,
|
||||
audit,
|
||||
template_ids_used: [skipAck.template_id, askMore.template_id],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja view_cart
|
||||
*/
|
||||
function handleViewCart({ currentOrder }) {
|
||||
async function handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx }) {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
|
||||
let reply = cartDisplay;
|
||||
if (pendingCount > 0) {
|
||||
reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`;
|
||||
}
|
||||
reply += "\n\n¿Algo más?";
|
||||
|
||||
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
reply += `\n\n${askMore.reply}`;
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
@@ -154,14 +171,14 @@ function handleViewCart({ currentOrder }) {
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit: {} },
|
||||
decision: { actions: [], order: currentOrder, audit: {}, template_ids_used: [askMore.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja remove_from_cart
|
||||
*/
|
||||
async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }) {
|
||||
async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx }) {
|
||||
const items = nlu?.entities?.items || [];
|
||||
const removedItems = [];
|
||||
const addedItems = [];
|
||||
@@ -233,8 +250,9 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
|
||||
}
|
||||
|
||||
const cartDisplay = formatCartForDisplay(updatedOrder);
|
||||
reply += `\n\n${cartDisplay}\n\n¿Algo más?`;
|
||||
|
||||
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
reply += `\n\n${cartDisplay}\n\n${askMore.reply}`;
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: reply.trim(),
|
||||
@@ -243,10 +261,11 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
|
||||
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
|
||||
decision: {
|
||||
actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [],
|
||||
order: updatedOrder,
|
||||
audit,
|
||||
template_ids_used: [askMore.template_id],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -254,47 +273,50 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
|
||||
/**
|
||||
* Maneja confirm_order
|
||||
*/
|
||||
function handleConfirmOrder({ currentOrder, audit }) {
|
||||
async function handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx }) {
|
||||
let order = moveReadyToCart(currentOrder);
|
||||
|
||||
|
||||
if (!hasCartItems(order)) {
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Tu carrito está vacío. ¿Qué querés agregar?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["cart_items"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (hasPendingItems(order)) {
|
||||
const nextPending = getNextPendingItem(order);
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.pending_before_close", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`,
|
||||
reply: `${r.reply}\n\n${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["pending_items"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const { next_state } = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.confirm_to_shipping", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
|
||||
reply: `${r.reply}\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal`,
|
||||
next_state,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,20 +386,21 @@ function isCartTotalQuery(nlu) {
|
||||
/**
|
||||
* Maneja price_query
|
||||
*/
|
||||
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit, recentReplies, failedSearches = { count: 0 }, rewriteCtx }) {
|
||||
// Si pregunta por el total del carrito
|
||||
if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) {
|
||||
const cartItems = currentOrder?.cart || [];
|
||||
if (cartItems.length === 0) {
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Tu carrito está vacío. ¿Qué te gustaría agregar?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,7 +413,8 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
return `• ${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`;
|
||||
});
|
||||
|
||||
const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Algo más?`;
|
||||
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n${askMore.reply}`;
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
@@ -399,12 +423,12 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [askMore.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
const productQueries = extractProductQueries(nlu);
|
||||
|
||||
|
||||
if (productQueries.length === 0) {
|
||||
// Si no hay query pero hay carrito, mostrar el carrito
|
||||
const cartItems = currentOrder?.cart || [];
|
||||
@@ -430,18 +454,19 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
};
|
||||
}
|
||||
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.price_no_query", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿De qué producto querés saber el precio?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const priceResults = [];
|
||||
for (const pq of productQueries.slice(0, 5)) {
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 });
|
||||
@@ -458,19 +483,45 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
}
|
||||
|
||||
if (priceResults.length === 0) {
|
||||
const failedQuery = productQueries[0]?.query || "";
|
||||
const nextCount = (failedSearches?.count || 0) + 1;
|
||||
if (nextCount >= 3) {
|
||||
// Escalación: 3 fallos consecutivos → human takeover
|
||||
const { createHumanTakeoverResponse } = await import("../nlu/humanFallback.js");
|
||||
const escalated = createHumanTakeoverResponse({
|
||||
pendingQuery: failedQuery,
|
||||
order: currentOrder,
|
||||
context: { failed_count: nextCount, last_query: failedQuery, source: "price_query" },
|
||||
});
|
||||
return {
|
||||
...escalated,
|
||||
decision: {
|
||||
...escalated.decision,
|
||||
failed_searches_next: { count: 0, last_query: failedQuery, last_at: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
}
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.not_found", vars: { query: failedQuery }, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "No encontré ese producto. ¿Podés ser más específico?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: {
|
||||
actions: [],
|
||||
order: currentOrder,
|
||||
audit,
|
||||
template_ids_used: [r.template_id],
|
||||
failed_searches_next: { count: nextCount, last_query: failedQuery, last_at: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?";
|
||||
|
||||
const header = await renderReply({ tenantId, templateKey: "cart.price_results_header", recentReplies });
|
||||
const reply = `${header.reply}\n\n${priceResults.join("\n")}\n\n¿Querés agregar alguno al carrito?`;
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
@@ -479,26 +530,27 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [header.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja add_to_cart / browse
|
||||
*/
|
||||
async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }) {
|
||||
async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit, recentReplies }) {
|
||||
const productQueries = extractProductQueries(nlu);
|
||||
|
||||
|
||||
if (productQueries.length === 0) {
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.ask_what_product", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué producto querés agregar?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -540,7 +592,7 @@ async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audi
|
||||
};
|
||||
}
|
||||
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
|
||||
return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit });
|
||||
return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,35 +600,48 @@ async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audi
|
||||
const lastAdded = order.cart[order.cart.length - 1];
|
||||
if (lastAdded) {
|
||||
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
|
||||
const summary = `${qtyStr} de ${lastAdded.name}`;
|
||||
const cartSummary = formatCartForDisplay(order);
|
||||
const added = await renderReply({
|
||||
tenantId,
|
||||
templateKey: "cart.added_confirm",
|
||||
vars: { summary },
|
||||
recentReplies,
|
||||
});
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
reply: `${added.reply}\n\n${cartSummary}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order, audit },
|
||||
decision: {
|
||||
actions: [{ type: "add_to_cart", payload: lastAdded }],
|
||||
order,
|
||||
audit,
|
||||
template_ids_used: [added.template_id],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué más querés agregar?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cuando se necesita cantidad
|
||||
*/
|
||||
async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }) {
|
||||
async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx }) {
|
||||
// Detectar "para X personas" en el texto original
|
||||
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
||||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
|
||||
@@ -615,16 +680,17 @@ async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n${askMore.reply}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [askMore.template_id] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import { retrieveCandidates } from "../catalogRetrieval.js";
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import {
|
||||
createPendingItem,
|
||||
import {
|
||||
createPendingItem,
|
||||
PendingStatus,
|
||||
moveReadyToCart,
|
||||
updatePendingItem,
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
normalizeUnit,
|
||||
unitAskFor,
|
||||
} from "./utils.js";
|
||||
import { renderReply } from "../replyTemplates.js";
|
||||
|
||||
/**
|
||||
* Extrae queries de productos del resultado NLU
|
||||
*/
|
||||
export function extractProductQueries(nlu) {
|
||||
const queries = [];
|
||||
|
||||
|
||||
// Multi-items
|
||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) {
|
||||
for (const item of nlu.entities.items) {
|
||||
@@ -47,7 +48,7 @@ export function extractProductQueries(nlu) {
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
|
||||
// Single item
|
||||
if (nlu?.entities?.product_query) {
|
||||
queries.push({
|
||||
@@ -56,7 +57,7 @@ export function extractProductQueries(nlu) {
|
||||
unit: nlu.entities.unit,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ export function extractProductQueries(nlu) {
|
||||
*/
|
||||
export function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
||||
const cands = (candidates || []).filter(c => c && c.woo_product_id);
|
||||
|
||||
|
||||
if (cands.length === 0) {
|
||||
return createPendingItem({
|
||||
query,
|
||||
@@ -73,13 +74,13 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
||||
status: PendingStatus.NEEDS_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Check for strong match
|
||||
const best = cands[0];
|
||||
const second = cands[1];
|
||||
const isStrong = cands.length === 1 ||
|
||||
const isStrong = cands.length === 1 ||
|
||||
(best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
|
||||
|
||||
|
||||
if (isStrong) {
|
||||
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||
@@ -87,7 +88,7 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
||||
const hasExplicitUnit = unit != null && unit !== "";
|
||||
const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit;
|
||||
const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric);
|
||||
|
||||
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: [],
|
||||
@@ -100,7 +101,7 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Multiple candidates, needs selection
|
||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||
return createPendingItem({
|
||||
@@ -119,42 +120,62 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la clarificación de un pending item
|
||||
* Retorna un resultado si se pudo procesar, null si debe escapar al handler principal
|
||||
* Helper interno: arma la respuesta "se agregó X al carrito" con template rotativo.
|
||||
*/
|
||||
export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
async function buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName, finalOrder, rewriteCtx }) {
|
||||
const summary = `${qtyDisplay} de ${productName}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
const r = await renderReply({
|
||||
tenantId,
|
||||
templateKey: "cart.added_confirm",
|
||||
vars: { summary },
|
||||
recentReplies,
|
||||
...(rewriteCtx || {}),
|
||||
});
|
||||
return {
|
||||
reply: `${r.reply}\n\n${cartSummary}`,
|
||||
template_id: r.template_id,
|
||||
failed_searches_next: { count: 0, last_query: null, last_at: null },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la clarificación de un pending item.
|
||||
* Retorna un resultado si se pudo procesar, null si debe escapar al handler principal.
|
||||
*/
|
||||
export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
// 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) {
|
||||
return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit });
|
||||
return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// Si necesita cantidad
|
||||
if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) {
|
||||
return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit });
|
||||
return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la selección de tipo de producto
|
||||
*/
|
||||
async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }) {
|
||||
const idx = parseIndexSelection(text);
|
||||
|
||||
|
||||
// Show more o mostrar opciones
|
||||
if (isShowMoreRequest(text) || isShowOptionsRequest(text)) {
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
@@ -169,59 +190,60 @@ async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, a
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar
|
||||
const textMatch = !idx && pendingItem.candidates?.length > 0
|
||||
? findMatchingCandidate(pendingItem.candidates, text)
|
||||
const textMatch = !idx && pendingItem.candidates?.length > 0
|
||||
? findMatchingCandidate(pendingItem.candidates, text)
|
||||
: null;
|
||||
|
||||
|
||||
const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null);
|
||||
|
||||
|
||||
// Selection by index (o por match de texto)
|
||||
if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) {
|
||||
return processIndexSelection({ order, pendingItem, effectiveIdx, audit });
|
||||
return processIndexSelection({ tenantId, order, pendingItem, effectiveIdx, audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar
|
||||
const isNumberSelection = idx !== null;
|
||||
const hadTextMatch = effectiveIdx !== null && !idx;
|
||||
const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2;
|
||||
|
||||
|
||||
if (isTextClarification) {
|
||||
return processTextClarification({ tenantId, text, order, pendingItem, audit });
|
||||
return processTextClarification({ tenantId, text, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx });
|
||||
}
|
||||
|
||||
// No entendió (no es número, no es texto largo), volver a preguntar
|
||||
|
||||
// No entendió, volver a preguntar
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "No entendí. " + question,
|
||||
reply: `${r.reply} ${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa selección por índice
|
||||
*/
|
||||
function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
|
||||
async function processIndexSelection({ tenantId, order, pendingItem, effectiveIdx, audit, recentReplies, rewriteCtx }) {
|
||||
const selected = pendingItem.candidates[effectiveIdx - 1];
|
||||
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
|
||||
|
||||
|
||||
const requestedQty = pendingItem.requested_qty;
|
||||
const requestedUnit = pendingItem.requested_unit || displayUnit;
|
||||
const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0;
|
||||
|
||||
|
||||
const sellsByWeight = displayUnit !== "unit";
|
||||
const needsQuantity = sellsByWeight && !hasRequestedQty;
|
||||
|
||||
|
||||
const finalQty = hasRequestedQty ? requestedQty : 1;
|
||||
const finalUnit = requestedUnit || displayUnit;
|
||||
|
||||
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
selected_woo_id: selected.woo_id,
|
||||
selected_name: selected.name,
|
||||
@@ -232,7 +254,7 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
|
||||
qty: needsQuantity ? null : finalQty,
|
||||
unit: finalUnit,
|
||||
});
|
||||
|
||||
|
||||
if (needsQuantity) {
|
||||
const unitQuestion = unitAskFor(displayUnit);
|
||||
return {
|
||||
@@ -246,34 +268,34 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyDisplay = displayUnit === "unit"
|
||||
? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
|
||||
const qtyDisplay = displayUnit === "unit"
|
||||
? `${finalQty} ${finalQty === 1 ? "unidad" : "unidades"}`
|
||||
: `${finalQty}${displayUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: selected.name, finalOrder, rewriteCtx });
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
reply: built.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa clarificación por texto libre (re-búsqueda)
|
||||
*/
|
||||
async function processTextClarification({ tenantId, text, order, pendingItem, audit }) {
|
||||
async function processTextClarification({ tenantId, text, order, pendingItem, audit, recentReplies, failedSearches = { count: 0 }, rewriteCtx }) {
|
||||
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, had_previous: pendingItem.candidates?.length || 0 };
|
||||
|
||||
|
||||
if (newCandidates.length > 0) {
|
||||
const updatedPending = {
|
||||
...pendingItem,
|
||||
@@ -286,17 +308,17 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
// Si hay match fuerte, seleccionar automáticamente
|
||||
if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) {
|
||||
return processStrongMatch({ updatedOrder, pendingItem, best: newCandidates[0], audit });
|
||||
return processStrongMatch({ tenantId, updatedOrder, pendingItem, best: newCandidates[0], audit, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// Múltiples candidatos, mostrar opciones
|
||||
const { question } = formatOptionsForDisplay(updatedPending);
|
||||
return {
|
||||
@@ -310,19 +332,19 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// No encontró nada con la nueva búsqueda
|
||||
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({
|
||||
|
||||
const escalated = createHumanTakeoverResponse({
|
||||
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
|
||||
order: orderWithoutPending,
|
||||
context: {
|
||||
@@ -331,26 +353,68 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
|
||||
search_attempts: 2,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...escalated,
|
||||
decision: {
|
||||
...escalated.decision,
|
||||
failed_searches_next: { count: 0, last_query: newQuery, last_at: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Había candidatos pero la búsqueda nueva no encontró, mantener los anteriores
|
||||
const nextCount = (failedSearches?.count || 0) + 1;
|
||||
if (nextCount >= 3) {
|
||||
audit.escalated_to_human = true;
|
||||
audit.escalation_reason = "failed_searches_threshold";
|
||||
const orderWithoutPending = {
|
||||
...order,
|
||||
pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
|
||||
};
|
||||
const escalated = createHumanTakeoverResponse({
|
||||
pendingQuery: `${pendingItem.query} (intentos: ${nextCount})`,
|
||||
order: orderWithoutPending,
|
||||
context: { original_query: pendingItem.query, last_query: newQuery, failed_count: nextCount },
|
||||
});
|
||||
return {
|
||||
...escalated,
|
||||
decision: {
|
||||
...escalated.decision,
|
||||
failed_searches_next: { count: 0, last_query: newQuery, last_at: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
const r = await renderReply({
|
||||
tenantId,
|
||||
templateKey: "cart.not_found",
|
||||
vars: { query: newQuery },
|
||||
recentReplies,
|
||||
...(rewriteCtx || {}),
|
||||
});
|
||||
return {
|
||||
plan: {
|
||||
reply: `No encontré "${newQuery}". ${question}`,
|
||||
reply: `${r.reply} ${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: {
|
||||
actions: [],
|
||||
order,
|
||||
audit,
|
||||
template_ids_used: [r.template_id],
|
||||
failed_searches_next: { count: nextCount, last_query: newQuery, last_at: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un match fuerte (selección automática)
|
||||
*/
|
||||
function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
||||
async function processStrongMatch({ tenantId, updatedOrder, pendingItem, best, audit, recentReplies, rewriteCtx }) {
|
||||
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;
|
||||
@@ -365,7 +429,7 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
||||
qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1),
|
||||
unit: pendingItem.requested_unit || displayUnit,
|
||||
});
|
||||
|
||||
|
||||
if (needsQuantity) {
|
||||
const unitQuestion = unitAskFor(displayUnit);
|
||||
return {
|
||||
@@ -379,33 +443,33 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
||||
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'}`
|
||||
const qtyDisplay = displayUnit === "unit"
|
||||
? `${qty} ${qty === 1 ? "unidad" : "unidades"}`
|
||||
: `${qty}${displayUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: best.name, finalOrder, rewriteCtx });
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyDisplay} ${best.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
reply: built.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa input de cantidad
|
||||
*/
|
||||
async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, rewriteCtx }) {
|
||||
const qty = nlu?.entities?.quantity;
|
||||
const unit = nlu?.entities?.unit;
|
||||
|
||||
|
||||
// Try to parse quantity from text
|
||||
let parsedQty = qty;
|
||||
if (parsedQty == null) {
|
||||
@@ -414,7 +478,7 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a
|
||||
parsedQty = parseFloat(m[1].replace(",", "."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) {
|
||||
const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg";
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
@@ -422,66 +486,68 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a
|
||||
unit: finalUnit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
const qtyDisplay = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
|
||||
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: pendingItem.selected_name, finalOrder, rewriteCtx });
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
reply: built.reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Detectar "para X personas" y calcular cantidad automáticamente
|
||||
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
||||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
|
||||
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
|
||||
|
||||
|
||||
if (personasMatch && pendingItem.selected_woo_id) {
|
||||
return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch });
|
||||
return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch, recentReplies, rewriteCtx });
|
||||
}
|
||||
|
||||
|
||||
// No entendió cantidad
|
||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
||||
reply: `${r.reply} ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa cantidad para X personas
|
||||
*/
|
||||
async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch }) {
|
||||
async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch, recentReplies, rewriteCtx }) {
|
||||
const peopleCount = parseInt(personasMatch[1], 10);
|
||||
|
||||
|
||||
if (peopleCount <= 0 || peopleCount > 100) {
|
||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
||||
reply: `${r.reply} ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Buscar reglas de cantidad por persona para este producto
|
||||
let qtyRules = [];
|
||||
try {
|
||||
@@ -489,15 +555,15 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p
|
||||
} catch (e) {
|
||||
audit.qty_rules_error = e?.message;
|
||||
}
|
||||
|
||||
|
||||
const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") ||
|
||||
qtyRules.find(r => r.event_type === null && r.person_type === "adult") ||
|
||||
qtyRules.find(r => r.person_type === "adult") ||
|
||||
qtyRules[0];
|
||||
|
||||
|
||||
let calculatedQty;
|
||||
let calculatedUnit = pendingItem.selected_unit || "kg";
|
||||
|
||||
|
||||
if (rule && rule.qty_per_person > 0) {
|
||||
calculatedQty = rule.qty_per_person * peopleCount;
|
||||
calculatedUnit = rule.unit || calculatedUnit;
|
||||
@@ -507,32 +573,39 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p
|
||||
calculatedQty = fallbackPerPerson * peopleCount;
|
||||
audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit };
|
||||
}
|
||||
|
||||
// Redondear
|
||||
|
||||
if (calculatedUnit === "unit") {
|
||||
calculatedQty = Math.ceil(calculatedQty);
|
||||
} else {
|
||||
calculatedQty = Math.round(calculatedQty * 10) / 10;
|
||||
}
|
||||
|
||||
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
qty: calculatedQty,
|
||||
unit: calculatedUnit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
||||
const qtyDisplay = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
||||
const summary = `${qtyDisplay} de ${pendingItem.selected_name} (sugerencia para ${peopleCount} pers.)`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
const r = await renderReply({
|
||||
tenantId,
|
||||
templateKey: "cart.added_confirm",
|
||||
vars: { summary },
|
||||
recentReplies,
|
||||
...(rewriteCtx || {}),
|
||||
});
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
reply: `${r.reply}\n\n${cartSummary}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,41 +4,43 @@
|
||||
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { handleCartState } from "./cart.js";
|
||||
import { renderReply } from "../replyTemplates.js";
|
||||
import { buildStoreContextVars } from "../storeContext.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado IDLE (inicio de conversación)
|
||||
*/
|
||||
export async function handleIdleState({ tenantId, text, nlu, order, audit }) {
|
||||
export async function handleIdleState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
|
||||
const vars = buildStoreContextVars(storeConfig);
|
||||
|
||||
// Greeting
|
||||
if (intent === "greeting") {
|
||||
const r = await renderReply({ tenantId, templateKey: "idle.greeting", vars, recentReplies, conversation_history, state: "IDLE", userText: text });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¡Hola! ¿En qué te puedo ayudar hoy?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.IDLE,
|
||||
intent: "greeting",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Cualquier intent relacionado con productos → ir a CART
|
||||
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
||||
return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true });
|
||||
return handleCartState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history, fromIdle: true });
|
||||
}
|
||||
|
||||
|
||||
// Other
|
||||
const r = await renderReply({ tenantId, templateKey: "idle.help_prompt", vars, recentReplies, conversation_history, state: "IDLE", userText: text });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.IDLE,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
import { ConversationState, safeNextState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
import { parseIndexSelection } from "./utils.js";
|
||||
import { renderReply } from "../replyTemplates.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado PAYMENT (selección de método de pago)
|
||||
*/
|
||||
export async function handlePaymentState({ tenantId, text, nlu, order, audit }) {
|
||||
const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.";
|
||||
|
||||
export async function handlePaymentState({ tenantId, text, nlu, order, audit, recentReplies, conversation_history }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
const actions = [];
|
||||
|
||||
// Detectar selección de pago
|
||||
const rewriteCtx = { conversation_history, state: "PAYMENT", userText: text };
|
||||
|
||||
let paymentMethod = nlu?.entities?.payment_method;
|
||||
|
||||
|
||||
if (!paymentMethod) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const idx = parseIndexSelection(text);
|
||||
@@ -26,58 +26,58 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
|
||||
paymentMethod = "link";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (paymentMethod) {
|
||||
currentOrder = { ...currentOrder, payment_type: paymentMethod };
|
||||
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
|
||||
|
||||
|
||||
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
|
||||
|
||||
|
||||
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
|
||||
const deliveryInfo = currentOrder.is_delivery
|
||||
const deliveryInfo = currentOrder.is_delivery
|
||||
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
|
||||
: "Retiro en sucursal.";
|
||||
|
||||
const paymentInfo = paymentMethod === "link"
|
||||
? "Te contactamos para coordinar el pago."
|
||||
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
|
||||
|
||||
|
||||
const r = await renderReply({ tenantId, templateKey: "payment.confirmed", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`,
|
||||
reply: `${r.reply} Pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}`,
|
||||
next_state,
|
||||
intent: "select_payment",
|
||||
missing_fields: [],
|
||||
order_action: "create_order",
|
||||
},
|
||||
decision: { actions, order: currentOrder, audit },
|
||||
decision: { actions, order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
// view_cart
|
||||
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: cartDisplay + "\n\n¿Cómo preferís pagar?",
|
||||
reply: `${cartDisplay}\n\n${r.reply}`,
|
||||
next_state: ConversationState.PAYMENT,
|
||||
intent: "view_cart",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
|
||||
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
|
||||
reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`,
|
||||
next_state: ConversationState.PAYMENT,
|
||||
intent: "other",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,18 +5,24 @@
|
||||
import { ConversationState, safeNextState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
import { parseIndexSelection } from "./utils.js";
|
||||
import { renderReply } from "../replyTemplates.js";
|
||||
import { buildStoreContextVars } from "../storeContext.js";
|
||||
|
||||
const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal";
|
||||
const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.";
|
||||
|
||||
/**
|
||||
* Maneja el estado SHIPPING (selección de envío)
|
||||
*/
|
||||
export async function handleShippingState({ tenantId, text, nlu, order, audit }) {
|
||||
export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
|
||||
const storeVars = buildStoreContextVars(storeConfig);
|
||||
const rewriteCtx = { conversation_history, state: "SHIPPING", userText: text };
|
||||
|
||||
// Detectar selección de shipping (delivery/pickup)
|
||||
let shippingMethod = nlu?.entities?.shipping_method;
|
||||
|
||||
// Detectar por número o texto
|
||||
|
||||
if (!shippingMethod) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const idx = parseIndexSelection(text);
|
||||
@@ -26,95 +32,111 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit })
|
||||
shippingMethod = "pickup";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (shippingMethod) {
|
||||
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
||||
|
||||
|
||||
if (shippingMethod === "pickup") {
|
||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||
const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
|
||||
reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`,
|
||||
next_state,
|
||||
intent: "select_shipping",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Delivery: pedir dirección si no la tiene
|
||||
if (!currentOrder.shipping_address) {
|
||||
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "select_shipping",
|
||||
missing_fields: ["address"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Si ya eligió delivery y ahora da dirección
|
||||
if (currentOrder.is_delivery === true && !currentOrder.shipping_address) {
|
||||
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
|
||||
|
||||
|
||||
if (address) {
|
||||
currentOrder = { ...currentOrder, shipping_address: address };
|
||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||
|
||||
const recorded = await renderReply({
|
||||
tenantId,
|
||||
templateKey: "shipping.address_recorded",
|
||||
vars: { address },
|
||||
recentReplies,
|
||||
});
|
||||
const askPay = await renderReply({ tenantId, templateKey: "payment.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`,
|
||||
reply: `${recorded.reply}\n\n${askPay.reply}${PAYMENT_OPTIONS_TAIL}`,
|
||||
next_state,
|
||||
intent: "provide_address",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: {
|
||||
actions: [],
|
||||
order: currentOrder,
|
||||
audit,
|
||||
template_ids_used: [recorded.template_id, askPay.template_id],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Necesito la dirección de entrega. ¿Me la pasás?",
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "other",
|
||||
missing_fields: ["address"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
// view_cart
|
||||
|
||||
// view_cart en SHIPPING
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: cartDisplay + "\n\n¿Es para delivery o retiro?",
|
||||
reply: `${cartDisplay}\n\n${r.reply}${SHIPPING_OPTIONS_TAIL}`,
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "view_cart",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
// Default: preguntar de nuevo
|
||||
|
||||
// Default
|
||||
const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
|
||||
reply: `${r.reply}${SHIPPING_OPTIONS_TAIL}`,
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "other",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
import { renderReply } from "../replyTemplates.js";
|
||||
|
||||
/**
|
||||
* Detecta si el usuario pregunta por horarios/días de entrega
|
||||
@@ -41,7 +42,7 @@ function isPickupInfoQuestion(text) {
|
||||
/**
|
||||
* Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago)
|
||||
*/
|
||||
export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {} }) {
|
||||
export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {}, recentReplies }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
@@ -118,18 +119,15 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st
|
||||
}
|
||||
|
||||
// Default
|
||||
const reply = currentOrder.payment_type === "link"
|
||||
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
|
||||
: "Tu pedido está listo. Avisame si necesitás algo más.";
|
||||
|
||||
const r = await renderReply({ tenantId, templateKey: "waiting.in_progress", recentReplies });
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
reply: r.reply,
|
||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||
};
|
||||
}
|
||||
|
||||
104
src/modules/3-turn-engine/storeContext.js
Normal file
104
src/modules/3-turn-engine/storeContext.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Store Context - Helpers para inyectar info de la tienda en respuestas.
|
||||
*
|
||||
* Estos helpers leen del storeConfig ya cargado por getStoreConfig (en
|
||||
* pipeline / turnEngine) y producen variables consumibles por reply templates
|
||||
* (renderReply via {{var}}) y por el reply rewriter (como hint contextual).
|
||||
*
|
||||
* Filosofía: cuando los datos no están cargados, las vars resuelven a strings
|
||||
* vacíos / hints neutros. Los templates están diseñados para tolerar ausencia.
|
||||
*/
|
||||
|
||||
const DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
const DAY_KEYS_LONG = ["lunes", "martes", "miercoles", "jueves", "viernes", "sabado", "domingo"];
|
||||
|
||||
/**
|
||||
* Devuelve la clave de día (mon..sun) para hoy.
|
||||
*/
|
||||
function todayKey() {
|
||||
// Date.getDay(): 0 = domingo, 1 = lunes
|
||||
const d = new Date().getDay();
|
||||
// mapear a mon..sun
|
||||
return DAY_KEYS[(d + 6) % 7];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el horario (string) de un day-key del schedule jsonb.
|
||||
* Formato esperado: { mon: { start: '09:00', end: '14:00', enabled: true }, ... }
|
||||
* Tolera variantes (lunes, monday, etc.) y formatos planos.
|
||||
*/
|
||||
function pickDaySlot(scheduleObj, dayIdx) {
|
||||
if (!scheduleObj || typeof scheduleObj !== "object") return null;
|
||||
const keys = [DAY_KEYS[dayIdx], DAY_KEYS_LONG[dayIdx]];
|
||||
for (const k of keys) {
|
||||
if (scheduleObj[k]) return scheduleObj[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDaySlot(slot) {
|
||||
if (!slot) return null;
|
||||
if (slot.enabled === false) return null;
|
||||
const start = (slot.start || "").slice(0, 5);
|
||||
const end = (slot.end || "").slice(0, 5);
|
||||
if (!start || !end) return null;
|
||||
return `${start} a ${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen de zonas de delivery: lista de barrios o "Consultar zonas".
|
||||
*/
|
||||
function summarizeDeliveryZones(deliveryZones) {
|
||||
if (!deliveryZones || typeof deliveryZones !== "object") return "";
|
||||
const names = [];
|
||||
// Soporta varios formatos:
|
||||
// 1) { caba: { barrios: ["Palermo", "Belgrano"] } }
|
||||
// 2) { zones: [{ name }] }
|
||||
// 3) { palermo: true, belgrano: true } (flat)
|
||||
if (Array.isArray(deliveryZones.zones)) {
|
||||
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
|
||||
} else if (deliveryZones.caba?.barrios) {
|
||||
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(deliveryZones)) {
|
||||
if (v === true) names.push(k);
|
||||
else if (v?.name) names.push(v.name);
|
||||
}
|
||||
}
|
||||
if (!names.length) return "";
|
||||
if (names.length <= 5) return names.join(", ");
|
||||
return `${names.slice(0, 5).join(", ")} y otros`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye variables de contexto de tienda para usar en reply templates.
|
||||
* Cuando los datos no están, las vars vienen vacías — los templates las
|
||||
* absorben sin romper.
|
||||
*
|
||||
* @param {Object} storeConfig - resultado de getStoreConfig({ tenantId })
|
||||
* @returns {Object} vars para applyVariables / renderReply
|
||||
*/
|
||||
export function buildStoreContextVars(storeConfig = {}) {
|
||||
const dayIdx = (new Date().getDay() + 6) % 7; // 0=lunes
|
||||
const sched = storeConfig.schedule || {};
|
||||
|
||||
const deliverySlot = pickDaySlot(sched.delivery, dayIdx);
|
||||
const pickupSlot = pickDaySlot(sched.pickup, dayIdx);
|
||||
|
||||
const storeHoursToday = formatDaySlot(deliverySlot) || formatDaySlot(pickupSlot) || "";
|
||||
const deliveryAvailableNow = deliverySlot ? "sí" : (storeConfig.deliveryEnabled === false ? "no" : "");
|
||||
const deliveryZonesSummary = summarizeDeliveryZones(storeConfig.delivery_zones);
|
||||
|
||||
return {
|
||||
store_name: storeConfig.name || "",
|
||||
bot_name: storeConfig.botName || "",
|
||||
store_address: storeConfig.address || "",
|
||||
store_phone: storeConfig.phone || "",
|
||||
store_hours: storeConfig.hours || "",
|
||||
store_hours_today: storeHoursToday,
|
||||
delivery_hours: storeConfig.deliveryHours || "",
|
||||
pickup_hours: storeConfig.pickupHours || "",
|
||||
delivery_available_now: deliveryAvailableNow,
|
||||
delivery_zones_summary: deliveryZonesSummary,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
handleWaitingState,
|
||||
} from "./stateHandlers.js";
|
||||
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
|
||||
import { pushRecent } from "./replyTemplates.js";
|
||||
|
||||
// Feature flag para NLU modular
|
||||
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
|
||||
@@ -60,10 +61,20 @@ export async function runTurnV3({
|
||||
|
||||
// Migrar contexto viejo a nuevo formato de orden
|
||||
const order = migrateOldContext(prev_context);
|
||||
|
||||
|
||||
// Mapear estados viejos a nuevos
|
||||
const normalizedState = normalizeState(prev_state);
|
||||
|
||||
// Recent replies para dedup de templates (FIFO cap 8)
|
||||
const recentReplies = Array.isArray(prev_context?.recent_replies)
|
||||
? prev_context.recent_replies
|
||||
: [];
|
||||
|
||||
// Counter de búsquedas fallidas consecutivas para escalación
|
||||
const failedSearches = (prev_context?.failed_searches && typeof prev_context.failed_searches === "object")
|
||||
? prev_context.failed_searches
|
||||
: { count: 0, last_query: null, last_at: null };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// NLU (con feature flag para sistema modular)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -124,13 +135,16 @@ export async function runTurnV3({
|
||||
order,
|
||||
audit,
|
||||
storeConfig,
|
||||
recentReplies,
|
||||
conversation_history: conversation_history || [],
|
||||
failedSearches,
|
||||
};
|
||||
|
||||
// Regla universal: si quiere agregar productos, volver a CART
|
||||
const returnToCart = shouldReturnToCart(normalizedState, nlu, text);
|
||||
if (returnToCart) {
|
||||
const result = await handleCartState({ ...handlerParams, fromIdle: false });
|
||||
return formatResult(result, prev_context);
|
||||
return formatResult(result, prev_context, recentReplies, failedSearches);
|
||||
}
|
||||
|
||||
// Dispatch por estado actual
|
||||
@@ -162,7 +176,7 @@ export async function runTurnV3({
|
||||
result = await handleIdleState(handlerParams);
|
||||
}
|
||||
|
||||
return formatResult(result, prev_context);
|
||||
return formatResult(result, prev_context, recentReplies, failedSearches);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,9 +212,22 @@ function normalizeState(state) {
|
||||
/**
|
||||
* Formatea el resultado para compatibilidad con el sistema existente
|
||||
*/
|
||||
function formatResult(result, prevContext) {
|
||||
function formatResult(result, prevContext, recentReplies = [], failedSearches = { count: 0 }) {
|
||||
const { plan, decision } = result;
|
||||
const order = decision?.order || createEmptyOrder();
|
||||
|
||||
// Mergear template_ids usados por los handlers en recent_replies
|
||||
const idsUsed = Array.isArray(decision?.template_ids_used)
|
||||
? decision.template_ids_used.filter(Boolean)
|
||||
: [];
|
||||
let nextRecent = recentReplies;
|
||||
for (const id of idsUsed) {
|
||||
nextRecent = pushRecent(nextRecent, id);
|
||||
}
|
||||
|
||||
// failed_searches: handlers pueden devolver decision.failed_searches_next.
|
||||
// Si no, mantener el previo.
|
||||
const nextFailedSearches = decision?.failed_searches_next || failedSearches;
|
||||
|
||||
// Construir context_patch para compatibilidad con pipeline
|
||||
const context_patch = {
|
||||
@@ -238,6 +265,11 @@ function formatResult(result, prevContext) {
|
||||
order.is_delivery === false ? "pickup" : null,
|
||||
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
||||
woo_order_id: order.woo_order_id,
|
||||
|
||||
// Dedup de respuestas: ids de templates usados, FIFO cap 8
|
||||
recent_replies: nextRecent,
|
||||
// Counter de búsquedas fallidas para escalación
|
||||
failed_searches: nextFailedSearches,
|
||||
};
|
||||
|
||||
// Construir basket_resolved para UI
|
||||
|
||||
Reference in New Issue
Block a user