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:
Lucas Tettamanti
2026-05-01 19:29:02 -03:00
parent 525679cf8b
commit f784ddd62d
17 changed files with 1347 additions and 308 deletions

View 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);
}
}
}