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>
318 lines
9.8 KiB
JavaScript
318 lines
9.8 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|