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

@@ -27,6 +27,17 @@ npm run seed # Seed a tenant via scripts/seed-tenant.mjs
No lint command is configured.
## Product goal
The bot must be **conversational and intelligent**, not a menu-driven flow. Customers reach out via WhatsApp **with intent to buy** — the bot's job is to:
1. **Engage in conversation** — answer questions about products, prices, availability/stock; recommend; clarify.
2. **Take orders** — build a cart through natural dialogue (multi-product turns, quantities, units).
3. **Collect delivery data** — address, delivery vs pickup, payment method.
4. **Operate within store rules** — delivery zones, days/hours, pickup windows. These config tables (`delivery_zones`, store schedule in `tenant_settings`) will be populated later; the bot has to read and respect them when present.
Repetitive, hardcoded responses are a known quality problem and the focus of the active improvement plan (see `~/.claude/plans/ok-creo-que-tiene-humming-sutton.md`). The system is **not yet in production** — refactors that change behavior are acceptable.
## Architecture
This is a **multi-tenant WhatsApp e-commerce chatbot** powered by Express.js. Tenants are WooCommerce store operators; their customers interact via WhatsApp to browse products, build carts, and place orders. All database operations are isolated by `tenant_id`.

View File

@@ -0,0 +1,17 @@
-- migrate:up
-- pg_trgm para fuzzy matching de aliases:
-- - Captura plurales (vacio↔vacios), diminutivos (costillita↔costilla),
-- typos (vasio↔vacio) sin escribir reglas.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS product_aliases_norm_trgm_idx
ON product_aliases USING gin (normalized_alias gin_trgm_ops);
CREATE INDEX IF NOT EXISTS alias_product_mappings_alias_trgm_idx
ON alias_product_mappings USING gin (alias gin_trgm_ops);
-- migrate:down
DROP INDEX IF EXISTS alias_product_mappings_alias_trgm_idx;
DROP INDEX IF EXISTS product_aliases_norm_trgm_idx;
-- Intencionalmente NO se hace DROP EXTENSION pg_trgm:
-- puede ser usada por otras consultas/migraciones futuras.

View File

@@ -0,0 +1,24 @@
-- migrate:up
-- Templates de respuestas con variantes para evitar repetición.
-- Filosofía: cada slot semántico (ej. cart.ask_more) tiene N variantes;
-- el código rota entre ellas excluyendo las recientemente usadas.
CREATE TABLE reply_templates (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
template_key VARCHAR(80) NOT NULL,
variant INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
weight INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_reply_variant UNIQUE(tenant_id, template_key, variant)
);
CREATE INDEX idx_reply_active
ON reply_templates(tenant_id, template_key)
WHERE is_active = true;
-- migrate:down
DROP INDEX IF EXISTS idx_reply_active;
DROP TABLE IF EXISTS reply_templates;

View File

@@ -48,6 +48,12 @@ EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producci
LIMIT_CONVERSATIONS=100
MAX_CHARS_PER_MESSAGE=4000
# ===================
# Reply Rewriter (LLM adapta templates al contexto)
# ===================
REPLY_REWRITER=0
REPLY_REWRITER_TIMEOUT_MS=1500
# ===================
# Debug Flags (1/true/yes/on para activar)
# ===================

View File

@@ -535,23 +535,24 @@ export async function getDecryptedTenantEcommerceConfig({
return rows[0] || null;
}
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
export async function searchProductAliases({ tenant_id, q = "", limit = 20, threshold = 0.3 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
const query = String(q || "").trim();
if (!query) return [];
const normalized = query.toLowerCase();
const like = `%${query}%`;
const nlike = `%${normalized}%`;
const sql = `
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at,
greatest(similarity(alias, $2), similarity(normalized_alias, $3)) as sim
from product_aliases
where tenant_id = $1
and (alias ilike $2 or normalized_alias ilike $3)
order by boost desc, updated_at desc
and (alias % $2 or normalized_alias % $3)
order by sim desc, boost desc, updated_at desc
limit $4
`;
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
return rows.map((r) => ({
const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]);
return rows
.filter((r) => Number(r.sim) >= threshold)
.map((r) => ({
tenant_id: r.tenant_id,
alias: r.alias,
normalized_alias: r.normalized_alias,
@@ -560,6 +561,30 @@ export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
boost: r.boost,
metadata: r.metadata,
updated_at: r.updated_at,
similarity: Number(r.sim),
}));
}
export async function searchAliasProductMappings({ tenant_id, q = "", limit = 50, threshold = 0.3 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
const query = String(q || "").trim();
if (!query) return [];
const normalized = query.toLowerCase();
const sql = `
select alias, woo_product_id, score, similarity(alias, $2) as sim
from alias_product_mappings
where tenant_id = $1 and alias % $2
order by sim desc, score desc
limit $3
`;
const { rows } = await pool.query(sql, [tenant_id, normalized, lim]);
return rows
.filter((r) => Number(r.sim) >= threshold)
.map((r) => ({
alias: r.alias,
woo_product_id: Number(r.woo_product_id),
score: Number(r.score || 1),
similarity: Number(r.sim),
}));
}

View File

@@ -6,7 +6,7 @@ import {
searchProductAliases,
getProductEmbedding,
upsertProductEmbedding,
getAllAliasProductMappings,
searchAliasProductMappings,
} from "../2-identity/db/repo.js";
function getOpenAiKey() {
@@ -138,60 +138,62 @@ export async function retrieveCandidates({
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
// 1) Buscar aliases que matcheen la query
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
// 1) Buscar aliases con fuzzy matching (pg_trgm).
// Captura plurales, diminutivos y typos sin reglas escritas.
const [aliases, mappings] = await Promise.all([
searchProductAliases({ tenant_id: tenantId, q, limit: 20 }),
searchAliasProductMappings({ tenant_id: tenantId, q, limit: 50 }),
]);
const aliasBoostByProduct = new Map();
const aliasProductIds = new Set();
// También buscar en alias_product_mappings (multi-producto)
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
const normalizedQuery = normalizeText(q);
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
// Buscar mappings cuyos aliases matcheen la query
for (const mapping of allMappings) {
const aliasNorm = normalizeText(mapping.alias);
// Match exacto o parcial del alias
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
// alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness)
for (const m of mappings) {
const id = m.woo_product_id;
const boost = m.score * m.similarity;
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
aliasProductIds.add(id);
} else {
// Check word overlap
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
for (const word of queryWords) {
if (aliasWords.has(word)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
aliasProductIds.add(id);
break;
}
}
}
}
// También incluir aliases legacy (product_aliases.woo_product_id)
// product_aliases legacy (1 alias → 1 producto)
for (const a of aliases) {
if (a?.woo_product_id) {
const id = Number(a.woo_product_id);
const boost = Number(a.boost || 0);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
const boost = Number(a.boost || 0) * (a.similarity || 1);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
aliasProductIds.add(id);
}
}
audit.sources.aliases = aliases.length;
audit.sources.alias_mappings = aliasProductIds.size;
audit.sources.alias_mappings = mappings.length;
// 2) Buscar productos por nombre/slug (búsqueda literal)
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
// 2) Buscar productos por nombre/slug (búsqueda literal con query original)
let { items: wooItems, source: wooSource } = await searchSnapshotItems({
tenantId,
q,
limit: lim,
});
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
// 2b) Si el query literal no rinde pero un alias matcheó (typo/plural/diminutivo),
// re-buscar el snapshot con el normalized_alias del mejor match.
// Esto cierra el loop: "vasio" → alias "vacio" → buscar "vacio" en productos.
if ((!wooItems || wooItems.length === 0) && aliases.length > 0) {
const refined = aliases[0]?.normalized_alias;
if (refined && refined.toLowerCase() !== q.toLowerCase()) {
const { items: aliasRefined, source: refSource } = await searchSnapshotItems({
tenantId,
q: refined,
limit: lim,
});
if (aliasRefined?.length) {
wooItems = aliasRefined;
audit.sources.snapshot_refined = { query: refined, source: refSource, count: aliasRefined.length };
}
}
}
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
const missingAliasIds = [...aliasProductIds].filter(id => !foundIds.has(id));

View File

@@ -0,0 +1,204 @@
/**
* Reply Rewriter — adapta un template base usando contexto conversacional.
*
* Default ON en pre-producción. Si falla o tarda >1.5s, fallback al template puro.
*
* Slots que se reescriben (según plan):
* cart.didnt_understand, cart.not_found, idle.greeting (1er turno),
* cart.added_confirm, cart.ask_more, shipping.ask_method,
* shipping.ask_address, payment.ask_method
*
* El rewriter recibe historial y vars de tienda para que pueda mencionar
* datos contextuales (zonas, horarios) cuando estén disponibles.
*/
import OpenAI from "openai";
import { debug as dbg } from "../shared/debug.js";
let _client = null;
let _clientKey = null;
function getClient() {
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
if (!apiKey) {
const err = new Error("OPENAI_API_KEY is not set");
err.code = "OPENAI_NO_KEY";
throw err;
}
if (_client && _clientKey === apiKey) return _client;
_clientKey = apiKey;
const baseURL = process.env.OPENAI_BASE_URL || undefined;
_client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
return _client;
}
function isEnabled() {
const v = String(process.env.REPLY_REWRITER || "").toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
}
function getModel() {
return process.env.REPLY_REWRITER_MODEL || process.env.OPENAI_MODEL || "gpt-4o-mini";
}
function getTimeoutMs() {
const n = parseInt(process.env.REPLY_REWRITER_TIMEOUT_MS || "1500", 10);
return Number.isFinite(n) && n > 0 ? n : 1500;
}
function lastN(history, n) {
if (!Array.isArray(history) || history.length === 0) return [];
return history.slice(-n).map((m) => ({
role: m.role === "user" ? "user" : "assistant",
content: String(m.content || "").slice(0, 200),
}));
}
function buildSystemPrompt() {
return [
"Sos un asistente de carnicería argentina (es-AR), conversacional y cálido.",
"Tu tarea: REESCRIBIR un mensaje base de respuesta para que suene natural,",
"adaptado al hilo de la conversación, sin sonar repetitivo ni robótico.",
"",
"REGLAS ESTRICTAS (no negociables):",
"1. Mantené la INTENCIÓN exacta del mensaje base. No agregues ofertas,",
" precios, productos ni datos que no estén en el contexto.",
"2. Si el mensaje base contiene listas, números, opciones (1) X 2) Y),",
" tenés que conservarlas EXACTAMENTE.",
"3. Largo máximo: ≈ longitud del base + 30 caracteres.",
"4. Tono: porteño/argentino, informal pero respetuoso. Sin emojis a menos",
" que el base los tenga.",
"5. NO repitas la frase exacta de tu mensaje anterior (te la paso en history).",
"6. Devolvé SOLO el texto reescrito, sin comillas, sin explicaciones, sin prefijos.",
"",
"Si no podés mejorar el base manteniendo las reglas, devolvelo tal cual.",
].join("\n");
}
const _inflightCache = new Map();
const _resultCache = new Map();
const RESULT_TTL_MS = 30_000;
function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) {
return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`;
}
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms)
),
]);
}
/**
* Reescribe una respuesta base usando contexto.
*
* @param {Object} params
* @param {string} params.baseText - el texto del template renderizado
* @param {string} params.templateKey - 'cart.didnt_understand', etc.
* @param {Array} params.history - últimos mensajes [{role, content}]
* @param {string} params.state - estado FSM actual
* @param {string} params.userText - último mensaje del usuario
* @param {Object} params.vars - vars de tienda (opcional)
*
* @returns {Promise<{ text: string, rewritten: boolean, model?: string, error?: string, ms: number }>}
*/
export async function rewriteReply({
baseText,
templateKey,
history = [],
state = null,
userText = "",
vars = {},
}) {
const t0 = Date.now();
if (!isEnabled()) {
return { text: baseText, rewritten: false, ms: 0 };
}
if (!baseText) {
return { text: "", rewritten: false, ms: 0 };
}
const recentMsgs = lastN(history, 4);
const lastUser = recentMsgs.filter((m) => m.role === "user").pop()?.content || userText || "";
const lastAssistant = recentMsgs.filter((m) => m.role === "assistant").pop()?.content || "";
const key = cacheKey({ templateKey, baseText, lastUserMsg: lastUser, lastAssistantMsg: lastAssistant });
const cached = _resultCache.get(key);
if (cached && Date.now() - cached.t < RESULT_TTL_MS) {
return { ...cached.value, ms: Date.now() - t0 };
}
if (_inflightCache.has(key)) {
try {
const value = await _inflightCache.get(key);
return { ...value, ms: Date.now() - t0 };
} catch (_) {
// fall through to re-attempt
}
}
const promise = (async () => {
try {
const client = getClient();
const model = getModel();
const userPayload = {
template_key: templateKey,
base_message: baseText,
conversation_state: state,
last_user_message: userText,
recent_history: recentMsgs,
store_context: {
store_name: vars?.store_name || "",
delivery_hours: vars?.delivery_hours || "",
pickup_hours: vars?.pickup_hours || "",
delivery_zones_summary: vars?.delivery_zones_summary || "",
},
};
if (dbg.llm) console.log("[rewriter] request", { templateKey, model });
const resp = await withTimeout(
client.chat.completions.create({
model,
temperature: 0.6,
max_tokens: 200,
messages: [
{ role: "system", content: buildSystemPrompt() },
{ role: "user", content: JSON.stringify(userPayload) },
],
}),
getTimeoutMs(),
"rewriter"
);
const text = (resp?.choices?.[0]?.message?.content || "").trim();
if (!text) {
return { text: baseText, rewritten: false, error: "empty" };
}
// Sanity: no debe ser drásticamente más largo que el base
const maxLen = baseText.length + 60;
const safeText = text.length > maxLen ? text.slice(0, maxLen) : text;
const result = { text: safeText, rewritten: true, model };
_resultCache.set(key, { value: result, t: Date.now() });
return result;
} catch (err) {
const msg = String(err?.message || err);
if (dbg.llm) console.log("[rewriter] error fallback to base", msg);
return { text: baseText, rewritten: false, error: msg };
}
})();
_inflightCache.set(key, promise);
try {
const value = await promise;
return { ...value, ms: Date.now() - t0 };
} finally {
_inflightCache.delete(key);
}
}

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

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock del pool de DB para que loadFromDb devuelva [] (siempre fallback a DEFAULTS)
vi.mock("../shared/db/pool.js", () => ({
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
}));
// Mock del rewriter para que sea no-op por default en estos tests
vi.mock("./replyRewriter.js", () => ({
rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })),
}));
import {
pickVariant,
applyVariables,
pushRecent,
renderReply,
invalidateCache,
DEFAULTS,
} from "./replyTemplates.js";
const TENANT = "00000000-0000-0000-0000-000000000000";
beforeEach(() => {
invalidateCache(TENANT);
});
describe("pickVariant", () => {
const variants = [
{ variant: 1, content: "A", weight: 1 },
{ variant: 2, content: "B", weight: 1 },
{ variant: 3, content: "C", weight: 1 },
];
it("returns one variant when none are recent", () => {
const r = pickVariant({ variants, recent: [], templateKey: "k" });
expect([1, 2, 3]).toContain(r.variant);
});
it("excludes recent variants", () => {
const r = pickVariant({
variants,
recent: ["k:1", "k:2"],
templateKey: "k",
});
expect(r.variant).toBe(3);
});
it("falls back to least-recent when all are recent", () => {
// recent order is FIFO: oldest first. With ['k:2','k:1','k:3'], k:2 is oldest.
const r = pickVariant({
variants,
recent: ["k:2", "k:1", "k:3"],
templateKey: "k",
});
expect(r.variant).toBe(2);
});
it("returns single variant when only one exists", () => {
const r = pickVariant({
variants: [{ variant: 1, content: "only", weight: 1 }],
recent: ["k:1"],
templateKey: "k",
});
expect(r.variant).toBe(1);
});
});
describe("applyVariables", () => {
it("replaces named variables", () => {
expect(applyVariables("Hola {{name}}!", { name: "Pepe" })).toBe("Hola Pepe!");
});
it("strips unmatched variables", () => {
expect(applyVariables("a {{missing}} b", {})).toBe("a b");
});
it("auto-injects current_date", () => {
const out = applyVariables("Hoy es {{current_date}}.", {});
expect(out).toMatch(/Hoy es \d+ de \w+\./);
});
});
describe("pushRecent", () => {
it("appends template_id", () => {
expect(pushRecent([], "x:1")).toEqual(["x:1"]);
});
it("caps at 8 entries (FIFO)", () => {
let r = [];
for (let i = 1; i <= 10; i++) r = pushRecent(r, `k:${i}`);
expect(r).toHaveLength(8);
expect(r[0]).toBe("k:3");
expect(r[7]).toBe("k:10");
});
});
describe("renderReply (DEFAULTS fallback)", () => {
it("renders from DEFAULTS when DB returns empty", async () => {
const out = await renderReply({
tenantId: TENANT,
templateKey: "idle.greeting",
vars: {},
recentReplies: [],
});
expect(out.template_id).toMatch(/^idle\.greeting:\d+$/);
expect(DEFAULTS["idle.greeting"]).toContain(out.reply);
});
it("rotates variants across consecutive calls when feeding recent", async () => {
let recent = [];
const seen = new Set();
for (let i = 0; i < 3; i++) {
const r = await renderReply({
tenantId: TENANT,
templateKey: "cart.added_confirm",
vars: { summary: "X" },
recentReplies: recent,
});
seen.add(r.variant);
recent = pushRecent(recent, r.template_id);
}
// 3 distintas variantes en 3 turnos
expect(seen.size).toBe(3);
});
it("returns empty string when key has no variants and no DEFAULT", async () => {
const out = await renderReply({
tenantId: TENANT,
templateKey: "nonexistent.key",
vars: {},
recentReplies: [],
});
expect(out.reply).toBe("");
});
});

View File

@@ -24,13 +24,15 @@ import {
createPendingItemFromSearch,
processPendingClarification
} from "./cartHelpers.js";
import { renderReply } from "../replyTemplates.js";
/**
* Maneja el estado CART (carrito activo)
*/
export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) {
export async function handleCartState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history, failedSearches = { count: 0 }, fromIdle = false }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
const rewriteCtx = { conversation_history, state: "CART", userText: text };
// Intents que tienen prioridad sobre pending items
const priorityIntents = ["view_cart", "confirm_order", "greeting"];
@@ -43,28 +45,28 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
// Si quiere saltar el pending - PERO solo si NO es un intent prioritario
if (wantsToSkipPending && pendingItem && !isPriorityIntent) {
return handleSkipPending({ currentOrder, pendingItem, audit });
return handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx });
}
// 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación
if (pendingItem && !isPriorityIntent) {
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit });
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit, recentReplies, failedSearches, rewriteCtx });
if (result) return result;
}
// 2) view_cart: mostrar carrito actual
if (intent === "view_cart") {
return handleViewCart({ currentOrder });
return handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx });
}
// 2.5) remove_from_cart: quitar productos del carrito
if (intent === "remove_from_cart") {
return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit });
return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx });
}
// 3) confirm_order: ir a SHIPPING si hay items
if (intent === "confirm_order") {
return handleConfirmOrder({ currentOrder, audit });
return handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx });
}
// 4) recommend
@@ -75,76 +77,91 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
// 4.5) price_query - consulta de precios
if (intent === "price_query") {
return handlePriceQuery({ tenantId, nlu, currentOrder, audit });
return handlePriceQuery({ tenantId, nlu, currentOrder, audit, recentReplies, rewriteCtx });
}
// 5) add_to_cart / browse: buscar productos
if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) {
return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit });
return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit, recentReplies, failedSearches, rewriteCtx });
}
// Default
const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
return {
plan: {
reply: "¿Qué más querés agregar?",
reply: r.reply,
next_state: ConversationState.CART,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
/**
* Maneja el skip de un pending item
*/
function handleSkipPending({ currentOrder, pendingItem, audit }) {
async function handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx }) {
const updatedOrder = {
...currentOrder,
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
};
audit.skipped_pending = pendingItem.query;
const skipAck = await renderReply({
tenantId,
templateKey: "cart.skip_acknowledged",
vars: { query: pendingItem.query },
recentReplies,
});
const nextPending = getNextPendingItem(updatedOrder);
if (nextPending) {
const { question } = formatOptionsForDisplay(nextPending);
return {
plan: {
reply: `Ok, salteo "${pendingItem.query}". ${question}`,
reply: `${skipAck.reply} ${question}`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: ["product_selection"],
order_action: "none",
},
decision: { actions: [], order: updatedOrder, audit },
decision: { actions: [], order: updatedOrder, audit, template_ids_used: [skipAck.template_id] },
};
}
const cartDisplay = formatCartForDisplay(updatedOrder);
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`,
reply: `${skipAck.reply}\n\n${cartDisplay}\n\n${askMore.reply}`,
next_state: ConversationState.CART,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: updatedOrder, audit },
decision: {
actions: [],
order: updatedOrder,
audit,
template_ids_used: [skipAck.template_id, askMore.template_id],
},
};
}
/**
* Maneja view_cart
*/
function handleViewCart({ currentOrder }) {
async function handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx }) {
const cartDisplay = formatCartForDisplay(currentOrder);
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
let reply = cartDisplay;
if (pendingCount > 0) {
reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`;
}
reply += "\n\n¿Algo más?";
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
reply += `\n\n${askMore.reply}`;
return {
plan: {
@@ -154,14 +171,14 @@ function handleViewCart({ currentOrder }) {
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit: {} },
decision: { actions: [], order: currentOrder, audit: {}, template_ids_used: [askMore.template_id] },
};
}
/**
* Maneja remove_from_cart
*/
async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }) {
async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit, recentReplies, rewriteCtx }) {
const items = nlu?.entities?.items || [];
const removedItems = [];
const addedItems = [];
@@ -233,7 +250,8 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
}
const cartDisplay = formatCartForDisplay(updatedOrder);
reply += `\n\n${cartDisplay}\n\n¿Algo más?`;
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
reply += `\n\n${cartDisplay}\n\n${askMore.reply}`;
return {
plan: {
@@ -246,7 +264,8 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
decision: {
actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [],
order: updatedOrder,
audit
audit,
template_ids_used: [askMore.template_id],
},
};
}
@@ -254,47 +273,50 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
/**
* Maneja confirm_order
*/
function handleConfirmOrder({ currentOrder, audit }) {
async function handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx }) {
let order = moveReadyToCart(currentOrder);
if (!hasCartItems(order)) {
const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies });
return {
plan: {
reply: "Tu carrito está vacío. ¿Qué querés agregar?",
reply: r.reply,
next_state: ConversationState.CART,
intent: "confirm_order",
missing_fields: ["cart_items"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
if (hasPendingItems(order)) {
const nextPending = getNextPendingItem(order);
const { question } = formatOptionsForDisplay(nextPending);
const r = await renderReply({ tenantId, templateKey: "cart.pending_before_close", recentReplies });
return {
plan: {
reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`,
reply: `${r.reply}\n\n${question}`,
next_state: ConversationState.CART,
intent: "confirm_order",
missing_fields: ["pending_items"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
const { next_state } = safeNextState(ConversationState.CART, order, { confirm_order: true });
const r = await renderReply({ tenantId, templateKey: "cart.confirm_to_shipping", recentReplies });
return {
plan: {
reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
reply: `${r.reply}\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal`,
next_state,
intent: "confirm_order",
missing_fields: ["shipping_method"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
@@ -364,20 +386,21 @@ function isCartTotalQuery(nlu) {
/**
* Maneja price_query
*/
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit, recentReplies, failedSearches = { count: 0 }, rewriteCtx }) {
// Si pregunta por el total del carrito
if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) {
const cartItems = currentOrder?.cart || [];
if (cartItems.length === 0) {
const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies });
return {
plan: {
reply: "Tu carrito está vacío. ¿Qué te gustaría agregar?",
reply: r.reply,
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
@@ -390,7 +413,8 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
return `${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`;
});
const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Algo más?`;
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n${askMore.reply}`;
return {
plan: {
reply,
@@ -399,7 +423,7 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [askMore.template_id] },
};
}
@@ -430,15 +454,16 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
};
}
const r = await renderReply({ tenantId, templateKey: "cart.price_no_query", recentReplies });
return {
plan: {
reply: "¿De qué producto querés saber el precio?",
reply: r.reply,
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: ["product_query"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
@@ -458,19 +483,45 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
}
if (priceResults.length === 0) {
const failedQuery = productQueries[0]?.query || "";
const nextCount = (failedSearches?.count || 0) + 1;
if (nextCount >= 3) {
// Escalación: 3 fallos consecutivos → human takeover
const { createHumanTakeoverResponse } = await import("../nlu/humanFallback.js");
const escalated = createHumanTakeoverResponse({
pendingQuery: failedQuery,
order: currentOrder,
context: { failed_count: nextCount, last_query: failedQuery, source: "price_query" },
});
return {
...escalated,
decision: {
...escalated.decision,
failed_searches_next: { count: 0, last_query: failedQuery, last_at: new Date().toISOString() },
},
};
}
const r = await renderReply({ tenantId, templateKey: "cart.not_found", vars: { query: failedQuery }, recentReplies, ...rewriteCtx });
return {
plan: {
reply: "No encontré ese producto. ¿Podés ser más específico?",
reply: r.reply,
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: ["product_query"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: {
actions: [],
order: currentOrder,
audit,
template_ids_used: [r.template_id],
failed_searches_next: { count: nextCount, last_query: failedQuery, last_at: new Date().toISOString() },
},
};
}
const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?";
const header = await renderReply({ tenantId, templateKey: "cart.price_results_header", recentReplies });
const reply = `${header.reply}\n\n${priceResults.join("\n")}\n\n¿Querés agregar alguno al carrito?`;
return {
plan: {
reply,
@@ -479,26 +530,27 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [header.template_id] },
};
}
/**
* Maneja add_to_cart / browse
*/
async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }) {
async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit, recentReplies }) {
const productQueries = extractProductQueries(nlu);
if (productQueries.length === 0) {
const r = await renderReply({ tenantId, templateKey: "cart.ask_what_product", recentReplies });
return {
plan: {
reply: "¿Qué producto querés agregar?",
reply: r.reply,
next_state: ConversationState.CART,
intent,
missing_fields: ["product_query"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
@@ -540,7 +592,7 @@ async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audi
};
}
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit });
return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx });
}
}
@@ -548,35 +600,48 @@ async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audi
const lastAdded = order.cart[order.cart.length - 1];
if (lastAdded) {
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
const summary = `${qtyStr} de ${lastAdded.name}`;
const cartSummary = formatCartForDisplay(order);
const added = await renderReply({
tenantId,
templateKey: "cart.added_confirm",
vars: { summary },
recentReplies,
});
return {
plan: {
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}.\n\n${cartSummary}\n\n¿Algo más?`,
reply: `${added.reply}\n\n${cartSummary}`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order, audit },
decision: {
actions: [{ type: "add_to_cart", payload: lastAdded }],
order,
audit,
template_ids_used: [added.template_id],
},
};
}
const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
return {
plan: {
reply: "¿Qué más querés agregar?",
reply: r.reply,
next_state: ConversationState.CART,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
/**
* Maneja cuando se necesita cantidad
*/
async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }) {
async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit, recentReplies, rewriteCtx }) {
// Detectar "para X personas" en el texto original
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
@@ -615,16 +680,17 @@ async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent
const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n${askMore.reply}`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [askMore.template_id] },
};
}
}

View File

@@ -27,6 +27,7 @@ import {
normalizeUnit,
unitAskFor,
} from "./utils.js";
import { renderReply } from "../replyTemplates.js";
/**
* Extrae queries de productos del resultado NLU
@@ -119,10 +120,30 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
}
/**
* Procesa la clarificación de un pending item
* Retorna un resultado si se pudo procesar, null si debe escapar al handler principal
* Helper interno: arma la respuesta "se agregó X al carrito" con template rotativo.
*/
export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) {
async function buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName, finalOrder, rewriteCtx }) {
const summary = `${qtyDisplay} de ${productName}`;
const cartSummary = formatCartForDisplay(finalOrder);
const r = await renderReply({
tenantId,
templateKey: "cart.added_confirm",
vars: { summary },
recentReplies,
...(rewriteCtx || {}),
});
return {
reply: `${r.reply}\n\n${cartSummary}`,
template_id: r.template_id,
failed_searches_next: { count: 0, last_query: null, last_at: null },
};
}
/**
* Procesa la clarificación de un pending item.
* Retorna un resultado si se pudo procesar, null si debe escapar al handler principal.
*/
export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }) {
// Detectar intents que deberían escapar de la clarificación
const escapeIntents = ["view_cart", "confirm_order", "greeting", "cancel_order"];
if (escapeIntents.includes(nlu?.intent)) {
@@ -138,12 +159,12 @@ export async function processPendingClarification({ tenantId, text, nlu, order,
// Si necesita seleccionar tipo
if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit });
return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx });
}
// Si necesita cantidad
if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) {
return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit });
return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, rewriteCtx });
}
return null;
@@ -152,7 +173,7 @@ export async function processPendingClarification({ tenantId, text, nlu, order,
/**
* Procesa la selección de tipo de producto
*/
async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }) {
async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx }) {
const idx = parseIndexSelection(text);
// Show more o mostrar opciones
@@ -179,7 +200,7 @@ async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, a
// Selection by index (o por match de texto)
if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) {
return processIndexSelection({ order, pendingItem, effectiveIdx, audit });
return processIndexSelection({ tenantId, order, pendingItem, effectiveIdx, audit, recentReplies, rewriteCtx });
}
// Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar
@@ -188,27 +209,28 @@ async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, a
const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2;
if (isTextClarification) {
return processTextClarification({ tenantId, text, order, pendingItem, audit });
return processTextClarification({ tenantId, text, order, pendingItem, audit, recentReplies, failedSearches, rewriteCtx });
}
// No entendió (no es número, no es texto largo), volver a preguntar
// No entendió, volver a preguntar
const { question } = formatOptionsForDisplay(pendingItem);
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
return {
plan: {
reply: "No entendí. " + question,
reply: `${r.reply} ${question}`,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["product_selection"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
/**
* Procesa selección por índice
*/
function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
async function processIndexSelection({ tenantId, order, pendingItem, effectiveIdx, audit, recentReplies, rewriteCtx }) {
const selected = pendingItem.candidates[effectiveIdx - 1];
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
@@ -249,26 +271,26 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
const finalOrder = moveReadyToCart(updatedOrder);
const qtyDisplay = displayUnit === "unit"
? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
? `${finalQty} ${finalQty === 1 ? "unidad" : "unidades"}`
: `${finalQty}${displayUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: selected.name, finalOrder, rewriteCtx });
return {
plan: {
reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`,
reply: built.reply,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next },
};
}
/**
* Procesa clarificación por texto libre (re-búsqueda)
*/
async function processTextClarification({ tenantId, text, order, pendingItem, audit }) {
async function processTextClarification({ tenantId, text, order, pendingItem, audit, recentReplies, failedSearches = { count: 0 }, rewriteCtx }) {
const newQuery = text.trim();
const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 });
const newCandidates = searchResult?.candidates || [];
@@ -294,7 +316,7 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
// Si hay match fuerte, seleccionar automáticamente
if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) {
return processStrongMatch({ updatedOrder, pendingItem, best: newCandidates[0], audit });
return processStrongMatch({ tenantId, updatedOrder, pendingItem, best: newCandidates[0], audit, recentReplies, rewriteCtx });
}
// Múltiples candidatos, mostrar opciones
@@ -322,7 +344,7 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
audit.original_query = pendingItem.query;
audit.retry_query = newQuery;
return createHumanTakeoverResponse({
const escalated = createHumanTakeoverResponse({
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
order: orderWithoutPending,
context: {
@@ -331,26 +353,68 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
search_attempts: 2,
},
});
return {
...escalated,
decision: {
...escalated.decision,
failed_searches_next: { count: 0, last_query: newQuery, last_at: new Date().toISOString() },
},
};
}
// Había candidatos pero la búsqueda nueva no encontró, mantener los anteriores
const nextCount = (failedSearches?.count || 0) + 1;
if (nextCount >= 3) {
audit.escalated_to_human = true;
audit.escalation_reason = "failed_searches_threshold";
const orderWithoutPending = {
...order,
pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
};
const escalated = createHumanTakeoverResponse({
pendingQuery: `${pendingItem.query} (intentos: ${nextCount})`,
order: orderWithoutPending,
context: { original_query: pendingItem.query, last_query: newQuery, failed_count: nextCount },
});
return {
...escalated,
decision: {
...escalated.decision,
failed_searches_next: { count: 0, last_query: newQuery, last_at: new Date().toISOString() },
},
};
}
const { question } = formatOptionsForDisplay(pendingItem);
const r = await renderReply({
tenantId,
templateKey: "cart.not_found",
vars: { query: newQuery },
recentReplies,
...(rewriteCtx || {}),
});
return {
plan: {
reply: `No encontré "${newQuery}". ${question}`,
reply: `${r.reply} ${question}`,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["product_selection"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: {
actions: [],
order,
audit,
template_ids_used: [r.template_id],
failed_searches_next: { count: nextCount, last_query: newQuery, last_at: new Date().toISOString() },
},
};
}
/**
* Procesa un match fuerte (selección automática)
*/
function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
async function processStrongMatch({ tenantId, updatedOrder, pendingItem, best, audit, recentReplies, rewriteCtx }) {
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
const needsQuantity = displayUnit !== "unit" && !hasQty;
@@ -383,26 +447,26 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
const finalOrder = moveReadyToCart(autoSelectedOrder);
const qty = hasQty ? pendingItem.requested_qty : 1;
const qtyDisplay = displayUnit === "unit"
? `${qty} ${qty === 1 ? 'unidad de' : 'unidades de'}`
? `${qty} ${qty === 1 ? "unidad" : "unidades"}`
: `${qty}${displayUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: best.name, finalOrder, rewriteCtx });
return {
plan: {
reply: `Perfecto, anoto ${qtyDisplay} ${best.name}.\n\n${cartSummary}\n\n¿Algo más?`,
reply: built.reply,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next },
};
}
/**
* Procesa input de cantidad
*/
async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }) {
async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit, recentReplies, rewriteCtx }) {
const qty = nlu?.entities?.quantity;
const unit = nlu?.entities?.unit;
@@ -424,18 +488,18 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a
});
const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
const cartSummary = formatCartForDisplay(finalOrder);
const qtyDisplay = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: pendingItem.selected_name, finalOrder, rewriteCtx });
return {
plan: {
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
reply: built.reply,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [built.template_id], failed_searches_next: built.failed_searches_next },
};
}
@@ -445,40 +509,42 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
if (personasMatch && pendingItem.selected_woo_id) {
return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch });
return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch, recentReplies, rewriteCtx });
}
// No entendió cantidad
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `No entendí la cantidad. ${unitQuestion}`,
reply: `${r.reply} ${unitQuestion}`,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["quantity"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
/**
* Procesa cantidad para X personas
*/
async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch }) {
async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch, recentReplies, rewriteCtx }) {
const peopleCount = parseInt(personasMatch[1], 10);
if (peopleCount <= 0 || peopleCount > 100) {
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `No entendí la cantidad. ${unitQuestion}`,
reply: `${r.reply} ${unitQuestion}`,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["quantity"],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
@@ -508,7 +574,6 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p
audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit };
}
// Redondear
if (calculatedUnit === "unit") {
calculatedQty = Math.ceil(calculatedQty);
} else {
@@ -522,17 +587,25 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p
});
const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
const qtyDisplay = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
const summary = `${qtyDisplay} de ${pendingItem.selected_name} (sugerencia para ${peopleCount} pers.)`;
const cartSummary = formatCartForDisplay(finalOrder);
const r = await renderReply({
tenantId,
templateKey: "cart.added_confirm",
vars: { summary },
recentReplies,
...(rewriteCtx || {}),
});
return {
plan: {
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
reply: `${r.reply}\n\n${cartSummary}`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit, template_ids_used: [r.template_id] },
};
}

View File

@@ -4,41 +4,43 @@
import { ConversationState } from "../fsm.js";
import { handleCartState } from "./cart.js";
import { renderReply } from "../replyTemplates.js";
import { buildStoreContextVars } from "../storeContext.js";
/**
* Maneja el estado IDLE (inicio de conversación)
*/
export async function handleIdleState({ tenantId, text, nlu, order, audit }) {
export async function handleIdleState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history }) {
const intent = nlu?.intent || "other";
const vars = buildStoreContextVars(storeConfig);
// Greeting
if (intent === "greeting") {
const r = await renderReply({ tenantId, templateKey: "idle.greeting", vars, recentReplies, conversation_history, state: "IDLE", userText: text });
return {
plan: {
reply: "¡Hola! ¿En qué te puedo ayudar hoy?",
reply: r.reply,
next_state: ConversationState.IDLE,
intent: "greeting",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}
// Cualquier intent relacionado con productos → ir a CART
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true });
return handleCartState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history, fromIdle: true });
}
// Other
const r = await renderReply({ tenantId, templateKey: "idle.help_prompt", vars, recentReplies, conversation_history, state: "IDLE", userText: text });
return {
plan: {
reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.",
reply: r.reply,
next_state: ConversationState.IDLE,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit },
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
};
}

View File

@@ -5,16 +5,16 @@
import { ConversationState, safeNextState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { parseIndexSelection } from "./utils.js";
import { renderReply } from "../replyTemplates.js";
/**
* Maneja el estado PAYMENT (selección de método de pago)
*/
export async function handlePaymentState({ tenantId, text, nlu, order, audit }) {
const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.";
export async function handlePaymentState({ tenantId, text, nlu, order, audit, recentReplies, conversation_history }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
const actions = [];
const rewriteCtx = { conversation_history, state: "PAYMENT", userText: text };
// Detectar selección de pago
let paymentMethod = nlu?.entities?.payment_method;
if (!paymentMethod) {
@@ -37,47 +37,47 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
const deliveryInfo = currentOrder.is_delivery
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
: "Retiro en sucursal.";
const paymentInfo = paymentMethod === "link"
? "Te contactamos para coordinar el pago."
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
const r = await renderReply({ tenantId, templateKey: "payment.confirmed", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`,
reply: `${r.reply} Pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}`,
next_state,
intent: "select_payment",
missing_fields: [],
order_action: "create_order",
},
decision: { actions, order: currentOrder, audit },
decision: { actions, order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
// view_cart
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
return {
plan: {
reply: cartDisplay + "\n\n¿Cómo preferís pagar?",
reply: `${cartDisplay}\n\n${r.reply}`,
next_state: ConversationState.PAYMENT,
intent: "view_cart",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
// Default
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
return {
plan: {
reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`,
next_state: ConversationState.PAYMENT,
intent: "other",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}

View File

@@ -5,18 +5,24 @@
import { ConversationState, safeNextState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { parseIndexSelection } from "./utils.js";
import { renderReply } from "../replyTemplates.js";
import { buildStoreContextVars } from "../storeContext.js";
const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal";
const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.";
/**
* Maneja el estado SHIPPING (selección de envío)
*/
export async function handleShippingState({ tenantId, text, nlu, order, audit }) {
export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
const storeVars = buildStoreContextVars(storeConfig);
const rewriteCtx = { conversation_history, state: "SHIPPING", userText: text };
// Detectar selección de shipping (delivery/pickup)
let shippingMethod = nlu?.entities?.shipping_method;
// Detectar por número o texto
if (!shippingMethod) {
const t = String(text || "").toLowerCase();
const idx = parseIndexSelection(text);
@@ -32,29 +38,31 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit })
if (shippingMethod === "pickup") {
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`,
next_state,
intent: "select_shipping",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
// Delivery: pedir dirección si no la tiene
if (!currentOrder.shipping_address) {
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?",
reply: r.reply,
next_state: ConversationState.SHIPPING,
intent: "select_shipping",
missing_fields: ["address"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
}
@@ -66,55 +74,69 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit })
if (address) {
currentOrder = { ...currentOrder, shipping_address: address };
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
const recorded = await renderReply({
tenantId,
templateKey: "shipping.address_recorded",
vars: { address },
recentReplies,
});
const askPay = await renderReply({ tenantId, templateKey: "payment.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`,
reply: `${recorded.reply}\n\n${askPay.reply}${PAYMENT_OPTIONS_TAIL}`,
next_state,
intent: "provide_address",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: {
actions: [],
order: currentOrder,
audit,
template_ids_used: [recorded.template_id, askPay.template_id],
},
};
}
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: "Necesito la dirección de entrega. ¿Me la pasás?",
reply: r.reply,
next_state: ConversationState.SHIPPING,
intent: "other",
missing_fields: ["address"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
// view_cart
// view_cart en SHIPPING
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: cartDisplay + "\n\n¿Es para delivery o retiro?",
reply: `${cartDisplay}\n\n${r.reply}${SHIPPING_OPTIONS_TAIL}`,
next_state: ConversationState.SHIPPING,
intent: "view_cart",
missing_fields: ["shipping_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
// Default: preguntar de nuevo
// Default
const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
reply: `${r.reply}${SHIPPING_OPTIONS_TAIL}`,
next_state: ConversationState.SHIPPING,
intent: "other",
missing_fields: ["shipping_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}

View File

@@ -4,6 +4,7 @@
import { ConversationState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { renderReply } from "../replyTemplates.js";
/**
* Detecta si el usuario pregunta por horarios/días de entrega
@@ -41,7 +42,7 @@ function isPickupInfoQuestion(text) {
/**
* Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago)
*/
export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {} }) {
export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {}, recentReplies }) {
const intent = nlu?.intent || "other";
const currentOrder = order || createEmptyOrder();
@@ -118,18 +119,15 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st
}
// Default
const reply = currentOrder.payment_type === "link"
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
: "Tu pedido está listo. Avisame si necesitás algo más.";
const r = await renderReply({ tenantId, templateKey: "waiting.in_progress", recentReplies });
return {
plan: {
reply,
reply: r.reply,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}

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

View File

@@ -19,6 +19,7 @@ import {
handleWaitingState,
} from "./stateHandlers.js";
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
import { pushRecent } from "./replyTemplates.js";
// Feature flag para NLU modular
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
@@ -64,6 +65,16 @@ export async function runTurnV3({
// Mapear estados viejos a nuevos
const normalizedState = normalizeState(prev_state);
// Recent replies para dedup de templates (FIFO cap 8)
const recentReplies = Array.isArray(prev_context?.recent_replies)
? prev_context.recent_replies
: [];
// Counter de búsquedas fallidas consecutivas para escalación
const failedSearches = (prev_context?.failed_searches && typeof prev_context.failed_searches === "object")
? prev_context.failed_searches
: { count: 0, last_query: null, last_at: null };
// ─────────────────────────────────────────────────────────────
// NLU (con feature flag para sistema modular)
// ─────────────────────────────────────────────────────────────
@@ -124,13 +135,16 @@ export async function runTurnV3({
order,
audit,
storeConfig,
recentReplies,
conversation_history: conversation_history || [],
failedSearches,
};
// Regla universal: si quiere agregar productos, volver a CART
const returnToCart = shouldReturnToCart(normalizedState, nlu, text);
if (returnToCart) {
const result = await handleCartState({ ...handlerParams, fromIdle: false });
return formatResult(result, prev_context);
return formatResult(result, prev_context, recentReplies, failedSearches);
}
// Dispatch por estado actual
@@ -162,7 +176,7 @@ export async function runTurnV3({
result = await handleIdleState(handlerParams);
}
return formatResult(result, prev_context);
return formatResult(result, prev_context, recentReplies, failedSearches);
}
/**
@@ -198,10 +212,23 @@ function normalizeState(state) {
/**
* Formatea el resultado para compatibilidad con el sistema existente
*/
function formatResult(result, prevContext) {
function formatResult(result, prevContext, recentReplies = [], failedSearches = { count: 0 }) {
const { plan, decision } = result;
const order = decision?.order || createEmptyOrder();
// Mergear template_ids usados por los handlers en recent_replies
const idsUsed = Array.isArray(decision?.template_ids_used)
? decision.template_ids_used.filter(Boolean)
: [];
let nextRecent = recentReplies;
for (const id of idsUsed) {
nextRecent = pushRecent(nextRecent, id);
}
// failed_searches: handlers pueden devolver decision.failed_searches_next.
// Si no, mantener el previo.
const nextFailedSearches = decision?.failed_searches_next || failedSearches;
// Construir context_patch para compatibilidad con pipeline
const context_patch = {
// Nueva estructura
@@ -238,6 +265,11 @@ function formatResult(result, prevContext) {
order.is_delivery === false ? "pickup" : null,
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
woo_order_id: order.woo_order_id,
// Dedup de respuestas: ids de templates usados, FIFO cap 8
recent_replies: nextRecent,
// Counter de búsquedas fallidas para escalación
failed_searches: nextFailedSearches,
};
// Construir basket_resolved para UI