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,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,
};
}