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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user