/** * 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); } } }