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:
11
CLAUDE.md
11
CLAUDE.md
@@ -27,6 +27,17 @@ npm run seed # Seed a tenant via scripts/seed-tenant.mjs
|
|||||||
|
|
||||||
No lint command is configured.
|
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
|
## 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`.
|
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`.
|
||||||
|
|||||||
17
db/migrations/20260501100000_product_aliases_trgm.sql
Normal file
17
db/migrations/20260501100000_product_aliases_trgm.sql
Normal 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.
|
||||||
24
db/migrations/20260501110000_reply_templates.sql
Normal file
24
db/migrations/20260501110000_reply_templates.sql
Normal 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;
|
||||||
@@ -48,6 +48,12 @@ EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producci
|
|||||||
LIMIT_CONVERSATIONS=100
|
LIMIT_CONVERSATIONS=100
|
||||||
MAX_CHARS_PER_MESSAGE=4000
|
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)
|
# Debug Flags (1/true/yes/on para activar)
|
||||||
# ===================
|
# ===================
|
||||||
|
|||||||
@@ -535,32 +535,57 @@ export async function getDecryptedTenantEcommerceConfig({
|
|||||||
return rows[0] || null;
|
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 lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
||||||
const query = String(q || "").trim();
|
const query = String(q || "").trim();
|
||||||
if (!query) return [];
|
if (!query) return [];
|
||||||
const normalized = query.toLowerCase();
|
const normalized = query.toLowerCase();
|
||||||
const like = `%${query}%`;
|
|
||||||
const nlike = `%${normalized}%`;
|
|
||||||
const sql = `
|
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
|
from product_aliases
|
||||||
where tenant_id=$1
|
where tenant_id = $1
|
||||||
and (alias ilike $2 or normalized_alias ilike $3)
|
and (alias % $2 or normalized_alias % $3)
|
||||||
order by boost desc, updated_at desc
|
order by sim desc, boost desc, updated_at desc
|
||||||
limit $4
|
limit $4
|
||||||
`;
|
`;
|
||||||
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
|
const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]);
|
||||||
return rows.map((r) => ({
|
return rows
|
||||||
tenant_id: r.tenant_id,
|
.filter((r) => Number(r.sim) >= threshold)
|
||||||
alias: r.alias,
|
.map((r) => ({
|
||||||
normalized_alias: r.normalized_alias,
|
tenant_id: r.tenant_id,
|
||||||
woo_product_id: r.woo_product_id,
|
alias: r.alias,
|
||||||
category_hint: r.category_hint,
|
normalized_alias: r.normalized_alias,
|
||||||
boost: r.boost,
|
woo_product_id: r.woo_product_id,
|
||||||
metadata: r.metadata,
|
category_hint: r.category_hint,
|
||||||
updated_at: r.updated_at,
|
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),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecoRules({ tenant_id }) {
|
export async function getRecoRules({ tenant_id }) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
searchProductAliases,
|
searchProductAliases,
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
upsertProductEmbedding,
|
upsertProductEmbedding,
|
||||||
getAllAliasProductMappings,
|
searchAliasProductMappings,
|
||||||
} from "../2-identity/db/repo.js";
|
} from "../2-identity/db/repo.js";
|
||||||
|
|
||||||
function getOpenAiKey() {
|
function getOpenAiKey() {
|
||||||
@@ -138,59 +138,61 @@ export async function retrieveCandidates({
|
|||||||
|
|
||||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||||
|
|
||||||
// 1) Buscar aliases que matcheen la query
|
// 1) Buscar aliases con fuzzy matching (pg_trgm).
|
||||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
// 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 aliasBoostByProduct = new Map();
|
||||||
const aliasProductIds = new Set();
|
const aliasProductIds = new Set();
|
||||||
|
|
||||||
// También buscar en alias_product_mappings (multi-producto)
|
// alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness)
|
||||||
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
|
for (const m of mappings) {
|
||||||
const normalizedQuery = normalizeText(q);
|
const id = m.woo_product_id;
|
||||||
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
|
const boost = m.score * m.similarity;
|
||||||
|
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||||
// Buscar mappings cuyos aliases matcheen la query
|
aliasProductIds.add(id);
|
||||||
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));
|
|
||||||
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) {
|
for (const a of aliases) {
|
||||||
if (a?.woo_product_id) {
|
if (a?.woo_product_id) {
|
||||||
const id = Number(a.woo_product_id);
|
const id = Number(a.woo_product_id);
|
||||||
const boost = Number(a.boost || 0);
|
const boost = Number(a.boost || 0) * (a.similarity || 1);
|
||||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||||
aliasProductIds.add(id);
|
aliasProductIds.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audit.sources.aliases = aliases.length;
|
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)
|
// 2) Buscar productos por nombre/slug (búsqueda literal con query original)
|
||||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
let { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||||
tenantId,
|
tenantId,
|
||||||
q,
|
q,
|
||||||
limit: lim,
|
limit: lim,
|
||||||
});
|
});
|
||||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
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
|
// 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 foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
|
||||||
|
|||||||
204
src/modules/3-turn-engine/replyRewriter.js
Normal file
204
src/modules/3-turn-engine/replyRewriter.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/modules/3-turn-engine/replyTemplates.test.js
Normal file
136
src/modules/3-turn-engine/replyTemplates.test.js
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,18 +19,20 @@ import {
|
|||||||
import { handleRecommend } from "../recommendations.js";
|
import { handleRecommend } from "../recommendations.js";
|
||||||
import { getProductQtyRules } from "../../0-ui/db/repo.js";
|
import { getProductQtyRules } from "../../0-ui/db/repo.js";
|
||||||
import { inferDefaultUnit, unitAskFor } from "./utils.js";
|
import { inferDefaultUnit, unitAskFor } from "./utils.js";
|
||||||
import {
|
import {
|
||||||
extractProductQueries,
|
extractProductQueries,
|
||||||
createPendingItemFromSearch,
|
createPendingItemFromSearch,
|
||||||
processPendingClarification
|
processPendingClarification
|
||||||
} from "./cartHelpers.js";
|
} from "./cartHelpers.js";
|
||||||
|
import { renderReply } from "../replyTemplates.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maneja el estado CART (carrito activo)
|
* 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";
|
const intent = nlu?.intent || "other";
|
||||||
let currentOrder = order || createEmptyOrder();
|
let currentOrder = order || createEmptyOrder();
|
||||||
|
const rewriteCtx = { conversation_history, state: "CART", userText: text };
|
||||||
|
|
||||||
// Intents que tienen prioridad sobre pending items
|
// Intents que tienen prioridad sobre pending items
|
||||||
const priorityIntents = ["view_cart", "confirm_order", "greeting"];
|
const priorityIntents = ["view_cart", "confirm_order", "greeting"];
|
||||||
@@ -43,109 +45,124 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
|
|||||||
|
|
||||||
// Si quiere saltar el pending - PERO solo si NO es un intent prioritario
|
// Si quiere saltar el pending - PERO solo si NO es un intent prioritario
|
||||||
if (wantsToSkipPending && pendingItem && !isPriorityIntent) {
|
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
|
// 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación
|
||||||
if (pendingItem && !isPriorityIntent) {
|
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;
|
if (result) return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) view_cart: mostrar carrito actual
|
// 2) view_cart: mostrar carrito actual
|
||||||
if (intent === "view_cart") {
|
if (intent === "view_cart") {
|
||||||
return handleViewCart({ currentOrder });
|
return handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.5) remove_from_cart: quitar productos del carrito
|
// 2.5) remove_from_cart: quitar productos del carrito
|
||||||
if (intent === "remove_from_cart") {
|
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
|
// 3) confirm_order: ir a SHIPPING si hay items
|
||||||
if (intent === "confirm_order") {
|
if (intent === "confirm_order") {
|
||||||
return handleConfirmOrder({ currentOrder, audit });
|
return handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) recommend
|
// 4) recommend
|
||||||
if (intent === "recommend") {
|
if (intent === "recommend") {
|
||||||
const result = await handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit });
|
const result = await handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit });
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4.5) price_query - consulta de precios
|
// 4.5) price_query - consulta de precios
|
||||||
if (intent === "price_query") {
|
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
|
// 5) add_to_cart / browse: buscar productos
|
||||||
if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) {
|
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
|
// Default
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¿Qué más querés agregar?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
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
|
* Maneja el skip de un pending item
|
||||||
*/
|
*/
|
||||||
function handleSkipPending({ currentOrder, pendingItem, audit }) {
|
async function handleSkipPending({ tenantId, currentOrder, pendingItem, audit, recentReplies, rewriteCtx }) {
|
||||||
const updatedOrder = {
|
const updatedOrder = {
|
||||||
...currentOrder,
|
...currentOrder,
|
||||||
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
|
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
|
||||||
};
|
};
|
||||||
audit.skipped_pending = pendingItem.query;
|
audit.skipped_pending = pendingItem.query;
|
||||||
|
|
||||||
|
const skipAck = await renderReply({
|
||||||
|
tenantId,
|
||||||
|
templateKey: "cart.skip_acknowledged",
|
||||||
|
vars: { query: pendingItem.query },
|
||||||
|
recentReplies,
|
||||||
|
});
|
||||||
|
|
||||||
const nextPending = getNextPendingItem(updatedOrder);
|
const nextPending = getNextPendingItem(updatedOrder);
|
||||||
if (nextPending) {
|
if (nextPending) {
|
||||||
const { question } = formatOptionsForDisplay(nextPending);
|
const { question } = formatOptionsForDisplay(nextPending);
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `Ok, salteo "${pendingItem.query}". ${question}`,
|
reply: `${skipAck.reply} ${question}`,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: ["product_selection"],
|
missing_fields: ["product_selection"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: updatedOrder, audit },
|
decision: { actions: [], order: updatedOrder, audit, template_ids_used: [skipAck.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cartDisplay = formatCartForDisplay(updatedOrder);
|
const cartDisplay = formatCartForDisplay(updatedOrder);
|
||||||
|
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
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
|
* Maneja view_cart
|
||||||
*/
|
*/
|
||||||
function handleViewCart({ currentOrder }) {
|
async function handleViewCart({ tenantId, currentOrder, recentReplies, rewriteCtx }) {
|
||||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||||
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
|
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
|
||||||
let reply = cartDisplay;
|
let reply = cartDisplay;
|
||||||
if (pendingCount > 0) {
|
if (pendingCount > 0) {
|
||||||
reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`;
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply,
|
reply,
|
||||||
@@ -154,14 +171,14 @@ function handleViewCart({ currentOrder }) {
|
|||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit: {} },
|
decision: { actions: [], order: currentOrder, audit: {}, template_ids_used: [askMore.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maneja remove_from_cart
|
* 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 items = nlu?.entities?.items || [];
|
||||||
const removedItems = [];
|
const removedItems = [];
|
||||||
const addedItems = [];
|
const addedItems = [];
|
||||||
@@ -233,8 +250,9 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cartDisplay = formatCartForDisplay(updatedOrder);
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: reply.trim(),
|
reply: reply.trim(),
|
||||||
@@ -243,10 +261,11 @@ async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }
|
|||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: removedItems.length > 0 ? "remove_from_cart" : "none",
|
order_action: removedItems.length > 0 ? "remove_from_cart" : "none",
|
||||||
},
|
},
|
||||||
decision: {
|
decision: {
|
||||||
actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [],
|
actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [],
|
||||||
order: updatedOrder,
|
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
|
* Maneja confirm_order
|
||||||
*/
|
*/
|
||||||
function handleConfirmOrder({ currentOrder, audit }) {
|
async function handleConfirmOrder({ tenantId, currentOrder, audit, recentReplies, rewriteCtx }) {
|
||||||
let order = moveReadyToCart(currentOrder);
|
let order = moveReadyToCart(currentOrder);
|
||||||
|
|
||||||
if (!hasCartItems(order)) {
|
if (!hasCartItems(order)) {
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "Tu carrito está vacío. ¿Qué querés agregar?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "confirm_order",
|
intent: "confirm_order",
|
||||||
missing_fields: ["cart_items"],
|
missing_fields: ["cart_items"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingItems(order)) {
|
if (hasPendingItems(order)) {
|
||||||
const nextPending = getNextPendingItem(order);
|
const nextPending = getNextPendingItem(order);
|
||||||
const { question } = formatOptionsForDisplay(nextPending);
|
const { question } = formatOptionsForDisplay(nextPending);
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.pending_before_close", recentReplies });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`,
|
reply: `${r.reply}\n\n${question}`,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "confirm_order",
|
intent: "confirm_order",
|
||||||
missing_fields: ["pending_items"],
|
missing_fields: ["pending_items"],
|
||||||
order_action: "none",
|
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 { next_state } = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.confirm_to_shipping", recentReplies });
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state,
|
||||||
intent: "confirm_order",
|
intent: "confirm_order",
|
||||||
missing_fields: ["shipping_method"],
|
missing_fields: ["shipping_method"],
|
||||||
order_action: "none",
|
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
|
* 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
|
// Si pregunta por el total del carrito
|
||||||
if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) {
|
if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) {
|
||||||
const cartItems = currentOrder?.cart || [];
|
const cartItems = currentOrder?.cart || [];
|
||||||
if (cartItems.length === 0) {
|
if (cartItems.length === 0) {
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.empty_prompt", recentReplies });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "Tu carrito está vacío. ¿Qué te gustaría agregar?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "price_query",
|
intent: "price_query",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
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")}`;
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply,
|
reply,
|
||||||
@@ -399,12 +423,12 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
|||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [askMore.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const productQueries = extractProductQueries(nlu);
|
const productQueries = extractProductQueries(nlu);
|
||||||
|
|
||||||
if (productQueries.length === 0) {
|
if (productQueries.length === 0) {
|
||||||
// Si no hay query pero hay carrito, mostrar el carrito
|
// Si no hay query pero hay carrito, mostrar el carrito
|
||||||
const cartItems = currentOrder?.cart || [];
|
const cartItems = currentOrder?.cart || [];
|
||||||
@@ -430,18 +454,19 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.price_no_query", recentReplies });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¿De qué producto querés saber el precio?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "price_query",
|
intent: "price_query",
|
||||||
missing_fields: ["product_query"],
|
missing_fields: ["product_query"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceResults = [];
|
const priceResults = [];
|
||||||
for (const pq of productQueries.slice(0, 5)) {
|
for (const pq of productQueries.slice(0, 5)) {
|
||||||
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 });
|
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 });
|
||||||
@@ -458,19 +483,45 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (priceResults.length === 0) {
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "No encontré ese producto. ¿Podés ser más específico?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "price_query",
|
intent: "price_query",
|
||||||
missing_fields: ["product_query"],
|
missing_fields: ["product_query"],
|
||||||
order_action: "none",
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply,
|
reply,
|
||||||
@@ -479,26 +530,27 @@ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
|||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [header.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maneja add_to_cart / browse
|
* 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);
|
const productQueries = extractProductQueries(nlu);
|
||||||
|
|
||||||
if (productQueries.length === 0) {
|
if (productQueries.length === 0) {
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.ask_what_product", recentReplies });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¿Qué producto querés agregar?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent,
|
intent,
|
||||||
missing_fields: ["product_query"],
|
missing_fields: ["product_query"],
|
||||||
order_action: "none",
|
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) {
|
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];
|
const lastAdded = order.cart[order.cart.length - 1];
|
||||||
if (lastAdded) {
|
if (lastAdded) {
|
||||||
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
|
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
|
||||||
|
const summary = `${qtyStr} de ${lastAdded.name}`;
|
||||||
const cartSummary = formatCartForDisplay(order);
|
const cartSummary = formatCartForDisplay(order);
|
||||||
|
const added = await renderReply({
|
||||||
|
tenantId,
|
||||||
|
templateKey: "cart.added_confirm",
|
||||||
|
vars: { summary },
|
||||||
|
recentReplies,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "add_to_cart",
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¿Qué más querés agregar?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maneja cuando se necesita cantidad
|
* 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
|
// Detectar "para X personas" en el texto original
|
||||||
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
||||||
/\bpara\s+(\d+)\b/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 finalOrder = moveReadyToCart(updatedOrder);
|
||||||
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
||||||
const cartSummary = formatCartForDisplay(finalOrder);
|
const cartSummary = formatCartForDisplay(finalOrder);
|
||||||
|
const askMore = await renderReply({ tenantId, templateKey: "cart.ask_more", recentReplies, ...rewriteCtx });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "add_to_cart",
|
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] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
import { retrieveCandidates } from "../catalogRetrieval.js";
|
import { retrieveCandidates } from "../catalogRetrieval.js";
|
||||||
import { ConversationState } from "../fsm.js";
|
import { ConversationState } from "../fsm.js";
|
||||||
import {
|
import {
|
||||||
createPendingItem,
|
createPendingItem,
|
||||||
PendingStatus,
|
PendingStatus,
|
||||||
moveReadyToCart,
|
moveReadyToCart,
|
||||||
updatePendingItem,
|
updatePendingItem,
|
||||||
@@ -27,13 +27,14 @@ import {
|
|||||||
normalizeUnit,
|
normalizeUnit,
|
||||||
unitAskFor,
|
unitAskFor,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
|
import { renderReply } from "../replyTemplates.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrae queries de productos del resultado NLU
|
* Extrae queries de productos del resultado NLU
|
||||||
*/
|
*/
|
||||||
export function extractProductQueries(nlu) {
|
export function extractProductQueries(nlu) {
|
||||||
const queries = [];
|
const queries = [];
|
||||||
|
|
||||||
// Multi-items
|
// Multi-items
|
||||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) {
|
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) {
|
||||||
for (const item of nlu.entities.items) {
|
for (const item of nlu.entities.items) {
|
||||||
@@ -47,7 +48,7 @@ export function extractProductQueries(nlu) {
|
|||||||
}
|
}
|
||||||
return queries;
|
return queries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single item
|
// Single item
|
||||||
if (nlu?.entities?.product_query) {
|
if (nlu?.entities?.product_query) {
|
||||||
queries.push({
|
queries.push({
|
||||||
@@ -56,7 +57,7 @@ export function extractProductQueries(nlu) {
|
|||||||
unit: nlu.entities.unit,
|
unit: nlu.entities.unit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return queries;
|
return queries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export function extractProductQueries(nlu) {
|
|||||||
*/
|
*/
|
||||||
export function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
export function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
||||||
const cands = (candidates || []).filter(c => c && c.woo_product_id);
|
const cands = (candidates || []).filter(c => c && c.woo_product_id);
|
||||||
|
|
||||||
if (cands.length === 0) {
|
if (cands.length === 0) {
|
||||||
return createPendingItem({
|
return createPendingItem({
|
||||||
query,
|
query,
|
||||||
@@ -73,13 +74,13 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
|||||||
status: PendingStatus.NEEDS_TYPE,
|
status: PendingStatus.NEEDS_TYPE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for strong match
|
// Check for strong match
|
||||||
const best = cands[0];
|
const best = cands[0];
|
||||||
const second = cands[1];
|
const second = cands[1];
|
||||||
const isStrong = cands.length === 1 ||
|
const isStrong = cands.length === 1 ||
|
||||||
(best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
|
(best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
|
||||||
|
|
||||||
if (isStrong) {
|
if (isStrong) {
|
||||||
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||||
@@ -87,7 +88,7 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
|||||||
const hasExplicitUnit = unit != null && unit !== "";
|
const hasExplicitUnit = unit != null && unit !== "";
|
||||||
const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit;
|
const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit;
|
||||||
const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric);
|
const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric);
|
||||||
|
|
||||||
return createPendingItem({
|
return createPendingItem({
|
||||||
query,
|
query,
|
||||||
candidates: [],
|
candidates: [],
|
||||||
@@ -100,7 +101,7 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
|||||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple candidates, needs selection
|
// Multiple candidates, needs selection
|
||||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||||
return createPendingItem({
|
return createPendingItem({
|
||||||
@@ -119,42 +120,62 @@ export function createPendingItemFromSearch({ query, quantity, unit, candidates
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Procesa la clarificación de un pending item
|
* Helper interno: arma la respuesta "se agregó X al carrito" con template rotativo.
|
||||||
* Retorna un resultado si se pudo procesar, null si debe escapar al handler principal
|
|
||||||
*/
|
*/
|
||||||
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
|
// Detectar intents que deberían escapar de la clarificación
|
||||||
const escapeIntents = ["view_cart", "confirm_order", "greeting", "cancel_order"];
|
const escapeIntents = ["view_cart", "confirm_order", "greeting", "cancel_order"];
|
||||||
if (escapeIntents.includes(nlu?.intent)) {
|
if (escapeIntents.includes(nlu?.intent)) {
|
||||||
audit.escape_from_pending = { reason: "intent", intent: nlu?.intent };
|
audit.escape_from_pending = { reason: "intent", intent: nlu?.intent };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detectar frases de escape explícitas
|
// Detectar frases de escape explícitas
|
||||||
if (isEscapeRequest(text)) {
|
if (isEscapeRequest(text)) {
|
||||||
audit.escape_from_pending = { reason: "text_pattern", text };
|
audit.escape_from_pending = { reason: "text_pattern", text };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si necesita seleccionar tipo
|
// Si necesita seleccionar tipo
|
||||||
if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
|
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
|
// Si necesita cantidad
|
||||||
if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Procesa la selección de tipo de producto
|
* 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);
|
const idx = parseIndexSelection(text);
|
||||||
|
|
||||||
// Show more o mostrar opciones
|
// Show more o mostrar opciones
|
||||||
if (isShowMoreRequest(text) || isShowOptionsRequest(text)) {
|
if (isShowMoreRequest(text) || isShowOptionsRequest(text)) {
|
||||||
const { question } = formatOptionsForDisplay(pendingItem);
|
const { question } = formatOptionsForDisplay(pendingItem);
|
||||||
@@ -169,59 +190,60 @@ async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, a
|
|||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar
|
// Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar
|
||||||
const textMatch = !idx && pendingItem.candidates?.length > 0
|
const textMatch = !idx && pendingItem.candidates?.length > 0
|
||||||
? findMatchingCandidate(pendingItem.candidates, text)
|
? findMatchingCandidate(pendingItem.candidates, text)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null);
|
const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null);
|
||||||
|
|
||||||
// Selection by index (o por match de texto)
|
// Selection by index (o por match de texto)
|
||||||
if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) {
|
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
|
// Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar
|
||||||
const isNumberSelection = idx !== null;
|
const isNumberSelection = idx !== null;
|
||||||
const hadTextMatch = effectiveIdx !== null && !idx;
|
const hadTextMatch = effectiveIdx !== null && !idx;
|
||||||
const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2;
|
const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2;
|
||||||
|
|
||||||
if (isTextClarification) {
|
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 { question } = formatOptionsForDisplay(pendingItem);
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "No entendí. " + question,
|
reply: `${r.reply} ${question}`,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["product_selection"],
|
missing_fields: ["product_selection"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Procesa selección por índice
|
* 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 selected = pendingItem.candidates[effectiveIdx - 1];
|
||||||
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
|
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
|
||||||
|
|
||||||
const requestedQty = pendingItem.requested_qty;
|
const requestedQty = pendingItem.requested_qty;
|
||||||
const requestedUnit = pendingItem.requested_unit || displayUnit;
|
const requestedUnit = pendingItem.requested_unit || displayUnit;
|
||||||
const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0;
|
const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0;
|
||||||
|
|
||||||
const sellsByWeight = displayUnit !== "unit";
|
const sellsByWeight = displayUnit !== "unit";
|
||||||
const needsQuantity = sellsByWeight && !hasRequestedQty;
|
const needsQuantity = sellsByWeight && !hasRequestedQty;
|
||||||
|
|
||||||
const finalQty = hasRequestedQty ? requestedQty : 1;
|
const finalQty = hasRequestedQty ? requestedQty : 1;
|
||||||
const finalUnit = requestedUnit || displayUnit;
|
const finalUnit = requestedUnit || displayUnit;
|
||||||
|
|
||||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||||
selected_woo_id: selected.woo_id,
|
selected_woo_id: selected.woo_id,
|
||||||
selected_name: selected.name,
|
selected_name: selected.name,
|
||||||
@@ -232,7 +254,7 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
|
|||||||
qty: needsQuantity ? null : finalQty,
|
qty: needsQuantity ? null : finalQty,
|
||||||
unit: finalUnit,
|
unit: finalUnit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (needsQuantity) {
|
if (needsQuantity) {
|
||||||
const unitQuestion = unitAskFor(displayUnit);
|
const unitQuestion = unitAskFor(displayUnit);
|
||||||
return {
|
return {
|
||||||
@@ -246,34 +268,34 @@ function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
|
|||||||
decision: { actions: [], order: updatedOrder, audit },
|
decision: { actions: [], order: updatedOrder, audit },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalOrder = moveReadyToCart(updatedOrder);
|
const finalOrder = moveReadyToCart(updatedOrder);
|
||||||
const qtyDisplay = displayUnit === "unit"
|
const qtyDisplay = displayUnit === "unit"
|
||||||
? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
|
? `${finalQty} ${finalQty === 1 ? "unidad" : "unidades"}`
|
||||||
: `${finalQty}${displayUnit}`;
|
: `${finalQty}${displayUnit}`;
|
||||||
const cartSummary = formatCartForDisplay(finalOrder);
|
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: selected.name, finalOrder, rewriteCtx });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
reply: built.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "add_to_cart",
|
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)
|
* 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 newQuery = text.trim();
|
||||||
const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 });
|
const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 });
|
||||||
const newCandidates = searchResult?.candidates || [];
|
const newCandidates = searchResult?.candidates || [];
|
||||||
audit.retry_search = { query: newQuery, count: newCandidates.length, had_previous: pendingItem.candidates?.length || 0 };
|
audit.retry_search = { query: newQuery, count: newCandidates.length, had_previous: pendingItem.candidates?.length || 0 };
|
||||||
|
|
||||||
if (newCandidates.length > 0) {
|
if (newCandidates.length > 0) {
|
||||||
const updatedPending = {
|
const updatedPending = {
|
||||||
...pendingItem,
|
...pendingItem,
|
||||||
@@ -286,17 +308,17 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
|
|||||||
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
|
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedOrder = {
|
const updatedOrder = {
|
||||||
...order,
|
...order,
|
||||||
pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p),
|
pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Si hay match fuerte, seleccionar automáticamente
|
// Si hay match fuerte, seleccionar automáticamente
|
||||||
if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) {
|
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
|
// Múltiples candidatos, mostrar opciones
|
||||||
const { question } = formatOptionsForDisplay(updatedPending);
|
const { question } = formatOptionsForDisplay(updatedPending);
|
||||||
return {
|
return {
|
||||||
@@ -310,19 +332,19 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
|
|||||||
decision: { actions: [], order: updatedOrder, audit },
|
decision: { actions: [], order: updatedOrder, audit },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No encontró nada con la nueva búsqueda
|
// No encontró nada con la nueva búsqueda
|
||||||
if (!pendingItem.candidates || pendingItem.candidates.length === 0) {
|
if (!pendingItem.candidates || pendingItem.candidates.length === 0) {
|
||||||
const orderWithoutPending = {
|
const orderWithoutPending = {
|
||||||
...order,
|
...order,
|
||||||
pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
|
pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
audit.escalated_to_human = true;
|
audit.escalated_to_human = true;
|
||||||
audit.original_query = pendingItem.query;
|
audit.original_query = pendingItem.query;
|
||||||
audit.retry_query = newQuery;
|
audit.retry_query = newQuery;
|
||||||
|
|
||||||
return createHumanTakeoverResponse({
|
const escalated = createHumanTakeoverResponse({
|
||||||
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
|
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
|
||||||
order: orderWithoutPending,
|
order: orderWithoutPending,
|
||||||
context: {
|
context: {
|
||||||
@@ -331,26 +353,68 @@ async function processTextClarification({ tenantId, text, order, pendingItem, au
|
|||||||
search_attempts: 2,
|
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
|
// 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 { question } = formatOptionsForDisplay(pendingItem);
|
||||||
|
const r = await renderReply({
|
||||||
|
tenantId,
|
||||||
|
templateKey: "cart.not_found",
|
||||||
|
vars: { query: newQuery },
|
||||||
|
recentReplies,
|
||||||
|
...(rewriteCtx || {}),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `No encontré "${newQuery}". ${question}`,
|
reply: `${r.reply} ${question}`,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["product_selection"],
|
missing_fields: ["product_selection"],
|
||||||
order_action: "none",
|
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)
|
* 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 displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||||
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
|
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
|
||||||
const needsQuantity = displayUnit !== "unit" && !hasQty;
|
const needsQuantity = displayUnit !== "unit" && !hasQty;
|
||||||
@@ -365,7 +429,7 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
|||||||
qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1),
|
qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1),
|
||||||
unit: pendingItem.requested_unit || displayUnit,
|
unit: pendingItem.requested_unit || displayUnit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (needsQuantity) {
|
if (needsQuantity) {
|
||||||
const unitQuestion = unitAskFor(displayUnit);
|
const unitQuestion = unitAskFor(displayUnit);
|
||||||
return {
|
return {
|
||||||
@@ -379,33 +443,33 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
|||||||
decision: { actions: [], order: autoSelectedOrder, audit },
|
decision: { actions: [], order: autoSelectedOrder, audit },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalOrder = moveReadyToCart(autoSelectedOrder);
|
const finalOrder = moveReadyToCart(autoSelectedOrder);
|
||||||
const qty = hasQty ? pendingItem.requested_qty : 1;
|
const qty = hasQty ? pendingItem.requested_qty : 1;
|
||||||
const qtyDisplay = displayUnit === "unit"
|
const qtyDisplay = displayUnit === "unit"
|
||||||
? `${qty} ${qty === 1 ? 'unidad de' : 'unidades de'}`
|
? `${qty} ${qty === 1 ? "unidad" : "unidades"}`
|
||||||
: `${qty}${displayUnit}`;
|
: `${qty}${displayUnit}`;
|
||||||
const cartSummary = formatCartForDisplay(finalOrder);
|
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: best.name, finalOrder, rewriteCtx });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `Perfecto, anoto ${qtyDisplay} ${best.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
reply: built.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "add_to_cart",
|
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
|
* 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 qty = nlu?.entities?.quantity;
|
||||||
const unit = nlu?.entities?.unit;
|
const unit = nlu?.entities?.unit;
|
||||||
|
|
||||||
// Try to parse quantity from text
|
// Try to parse quantity from text
|
||||||
let parsedQty = qty;
|
let parsedQty = qty;
|
||||||
if (parsedQty == null) {
|
if (parsedQty == null) {
|
||||||
@@ -414,7 +478,7 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a
|
|||||||
parsedQty = parseFloat(m[1].replace(",", "."));
|
parsedQty = parseFloat(m[1].replace(",", "."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) {
|
if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) {
|
||||||
const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg";
|
const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg";
|
||||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||||
@@ -422,66 +486,68 @@ async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, a
|
|||||||
unit: finalUnit,
|
unit: finalUnit,
|
||||||
status: PendingStatus.READY,
|
status: PendingStatus.READY,
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalOrder = moveReadyToCart(updatedOrder);
|
const finalOrder = moveReadyToCart(updatedOrder);
|
||||||
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
|
const qtyDisplay = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
|
||||||
const cartSummary = formatCartForDisplay(finalOrder);
|
const built = await buildAddedReply({ tenantId, recentReplies, qtyDisplay, productName: pendingItem.selected_name, finalOrder, rewriteCtx });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
reply: built.reply,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "add_to_cart",
|
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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detectar "para X personas" y calcular cantidad automáticamente
|
// Detectar "para X personas" y calcular cantidad automáticamente
|
||||||
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
||||||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
|
/\bpara\s+(\d+)\b/i.exec(text || "") ||
|
||||||
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
|
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
|
||||||
|
|
||||||
if (personasMatch && pendingItem.selected_woo_id) {
|
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
|
// No entendió cantidad
|
||||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
reply: `${r.reply} ${unitQuestion}`,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["quantity"],
|
missing_fields: ["quantity"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Procesa cantidad para X personas
|
* 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);
|
const peopleCount = parseInt(personasMatch[1], 10);
|
||||||
|
|
||||||
if (peopleCount <= 0 || peopleCount > 100) {
|
if (peopleCount <= 0 || peopleCount > 100) {
|
||||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "cart.didnt_understand", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
reply: `${r.reply} ${unitQuestion}`,
|
||||||
next_state: ConversationState.CART,
|
next_state: ConversationState.CART,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["quantity"],
|
missing_fields: ["quantity"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar reglas de cantidad por persona para este producto
|
// Buscar reglas de cantidad por persona para este producto
|
||||||
let qtyRules = [];
|
let qtyRules = [];
|
||||||
try {
|
try {
|
||||||
@@ -489,15 +555,15 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
audit.qty_rules_error = e?.message;
|
audit.qty_rules_error = e?.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") ||
|
const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") ||
|
||||||
qtyRules.find(r => r.event_type === null && r.person_type === "adult") ||
|
qtyRules.find(r => r.event_type === null && r.person_type === "adult") ||
|
||||||
qtyRules.find(r => r.person_type === "adult") ||
|
qtyRules.find(r => r.person_type === "adult") ||
|
||||||
qtyRules[0];
|
qtyRules[0];
|
||||||
|
|
||||||
let calculatedQty;
|
let calculatedQty;
|
||||||
let calculatedUnit = pendingItem.selected_unit || "kg";
|
let calculatedUnit = pendingItem.selected_unit || "kg";
|
||||||
|
|
||||||
if (rule && rule.qty_per_person > 0) {
|
if (rule && rule.qty_per_person > 0) {
|
||||||
calculatedQty = rule.qty_per_person * peopleCount;
|
calculatedQty = rule.qty_per_person * peopleCount;
|
||||||
calculatedUnit = rule.unit || calculatedUnit;
|
calculatedUnit = rule.unit || calculatedUnit;
|
||||||
@@ -507,32 +573,39 @@ async function processQuantityForPeople({ tenantId, order, pendingItem, audit, p
|
|||||||
calculatedQty = fallbackPerPerson * peopleCount;
|
calculatedQty = fallbackPerPerson * peopleCount;
|
||||||
audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit };
|
audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redondear
|
|
||||||
if (calculatedUnit === "unit") {
|
if (calculatedUnit === "unit") {
|
||||||
calculatedQty = Math.ceil(calculatedQty);
|
calculatedQty = Math.ceil(calculatedQty);
|
||||||
} else {
|
} else {
|
||||||
calculatedQty = Math.round(calculatedQty * 10) / 10;
|
calculatedQty = Math.round(calculatedQty * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||||
qty: calculatedQty,
|
qty: calculatedQty,
|
||||||
unit: calculatedUnit,
|
unit: calculatedUnit,
|
||||||
status: PendingStatus.READY,
|
status: PendingStatus.READY,
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalOrder = moveReadyToCart(updatedOrder);
|
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 cartSummary = formatCartForDisplay(finalOrder);
|
||||||
|
const r = await renderReply({
|
||||||
|
tenantId,
|
||||||
|
templateKey: "cart.added_confirm",
|
||||||
|
vars: { summary },
|
||||||
|
recentReplies,
|
||||||
|
...(rewriteCtx || {}),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state: ConversationState.CART,
|
||||||
intent: "add_to_cart",
|
intent: "add_to_cart",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "add_to_cart",
|
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] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,41 +4,43 @@
|
|||||||
|
|
||||||
import { ConversationState } from "../fsm.js";
|
import { ConversationState } from "../fsm.js";
|
||||||
import { handleCartState } from "./cart.js";
|
import { handleCartState } from "./cart.js";
|
||||||
|
import { renderReply } from "../replyTemplates.js";
|
||||||
|
import { buildStoreContextVars } from "../storeContext.js";
|
||||||
|
|
||||||
/**
|
export async function handleIdleState({ tenantId, text, nlu, order, audit, storeConfig, recentReplies, conversation_history }) {
|
||||||
* Maneja el estado IDLE (inicio de conversación)
|
|
||||||
*/
|
|
||||||
export async function handleIdleState({ tenantId, text, nlu, order, audit }) {
|
|
||||||
const intent = nlu?.intent || "other";
|
const intent = nlu?.intent || "other";
|
||||||
|
const vars = buildStoreContextVars(storeConfig);
|
||||||
|
|
||||||
// Greeting
|
// Greeting
|
||||||
if (intent === "greeting") {
|
if (intent === "greeting") {
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "idle.greeting", vars, recentReplies, conversation_history, state: "IDLE", userText: text });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¡Hola! ¿En qué te puedo ayudar hoy?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.IDLE,
|
next_state: ConversationState.IDLE,
|
||||||
intent: "greeting",
|
intent: "greeting",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
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
|
// Cualquier intent relacionado con productos → ir a CART
|
||||||
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
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
|
// Other
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "idle.help_prompt", vars, recentReplies, conversation_history, state: "IDLE", userText: text });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.",
|
reply: r.reply,
|
||||||
next_state: ConversationState.IDLE,
|
next_state: ConversationState.IDLE,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order, audit },
|
decision: { actions: [], order, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,18 @@
|
|||||||
import { ConversationState, safeNextState } from "../fsm.js";
|
import { ConversationState, safeNextState } from "../fsm.js";
|
||||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||||
import { parseIndexSelection } from "./utils.js";
|
import { parseIndexSelection } from "./utils.js";
|
||||||
|
import { renderReply } from "../replyTemplates.js";
|
||||||
|
|
||||||
/**
|
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 PAYMENT (selección de método de pago)
|
|
||||||
*/
|
export async function handlePaymentState({ tenantId, text, nlu, order, audit, recentReplies, conversation_history }) {
|
||||||
export async function handlePaymentState({ tenantId, text, nlu, order, audit }) {
|
|
||||||
const intent = nlu?.intent || "other";
|
const intent = nlu?.intent || "other";
|
||||||
let currentOrder = order || createEmptyOrder();
|
let currentOrder = order || createEmptyOrder();
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
const rewriteCtx = { conversation_history, state: "PAYMENT", userText: text };
|
||||||
// Detectar selección de pago
|
|
||||||
let paymentMethod = nlu?.entities?.payment_method;
|
let paymentMethod = nlu?.entities?.payment_method;
|
||||||
|
|
||||||
if (!paymentMethod) {
|
if (!paymentMethod) {
|
||||||
const t = String(text || "").toLowerCase();
|
const t = String(text || "").toLowerCase();
|
||||||
const idx = parseIndexSelection(text);
|
const idx = parseIndexSelection(text);
|
||||||
@@ -26,58 +26,58 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
|
|||||||
paymentMethod = "link";
|
paymentMethod = "link";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paymentMethod) {
|
if (paymentMethod) {
|
||||||
currentOrder = { ...currentOrder, payment_type: paymentMethod };
|
currentOrder = { ...currentOrder, payment_type: paymentMethod };
|
||||||
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
|
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
|
||||||
|
|
||||||
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
|
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
|
||||||
|
|
||||||
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
|
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
|
||||||
const deliveryInfo = currentOrder.is_delivery
|
const deliveryInfo = currentOrder.is_delivery
|
||||||
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
|
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
|
||||||
: "Retiro en sucursal.";
|
: "Retiro en sucursal.";
|
||||||
|
|
||||||
const paymentInfo = paymentMethod === "link"
|
const paymentInfo = paymentMethod === "link"
|
||||||
? "Te contactamos para coordinar el pago."
|
? "Te contactamos para coordinar el pago."
|
||||||
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
|
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
|
||||||
|
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "payment.confirmed", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state,
|
||||||
intent: "select_payment",
|
intent: "select_payment",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "create_order",
|
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") {
|
if (intent === "view_cart") {
|
||||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: cartDisplay + "\n\n¿Cómo preferís pagar?",
|
reply: `${cartDisplay}\n\n${r.reply}`,
|
||||||
next_state: ConversationState.PAYMENT,
|
next_state: ConversationState.PAYMENT,
|
||||||
intent: "view_cart",
|
intent: "view_cart",
|
||||||
missing_fields: ["payment_method"],
|
missing_fields: ["payment_method"],
|
||||||
order_action: "none",
|
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 {
|
return {
|
||||||
plan: {
|
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,
|
next_state: ConversationState.PAYMENT,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["payment_method"],
|
missing_fields: ["payment_method"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,24 @@
|
|||||||
import { ConversationState, safeNextState } from "../fsm.js";
|
import { ConversationState, safeNextState } from "../fsm.js";
|
||||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||||
import { parseIndexSelection } from "./utils.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)
|
* 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";
|
const intent = nlu?.intent || "other";
|
||||||
let currentOrder = order || createEmptyOrder();
|
let currentOrder = order || createEmptyOrder();
|
||||||
|
const storeVars = buildStoreContextVars(storeConfig);
|
||||||
|
const rewriteCtx = { conversation_history, state: "SHIPPING", userText: text };
|
||||||
|
|
||||||
// Detectar selección de shipping (delivery/pickup)
|
// Detectar selección de shipping (delivery/pickup)
|
||||||
let shippingMethod = nlu?.entities?.shipping_method;
|
let shippingMethod = nlu?.entities?.shipping_method;
|
||||||
|
|
||||||
// Detectar por número o texto
|
|
||||||
if (!shippingMethod) {
|
if (!shippingMethod) {
|
||||||
const t = String(text || "").toLowerCase();
|
const t = String(text || "").toLowerCase();
|
||||||
const idx = parseIndexSelection(text);
|
const idx = parseIndexSelection(text);
|
||||||
@@ -26,95 +32,111 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit })
|
|||||||
shippingMethod = "pickup";
|
shippingMethod = "pickup";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shippingMethod) {
|
if (shippingMethod) {
|
||||||
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
||||||
|
|
||||||
if (shippingMethod === "pickup") {
|
if (shippingMethod === "pickup") {
|
||||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
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,
|
next_state,
|
||||||
intent: "select_shipping",
|
intent: "select_shipping",
|
||||||
missing_fields: ["payment_method"],
|
missing_fields: ["payment_method"],
|
||||||
order_action: "none",
|
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
|
// Delivery: pedir dirección si no la tiene
|
||||||
if (!currentOrder.shipping_address) {
|
if (!currentOrder.shipping_address) {
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.SHIPPING,
|
next_state: ConversationState.SHIPPING,
|
||||||
intent: "select_shipping",
|
intent: "select_shipping",
|
||||||
missing_fields: ["address"],
|
missing_fields: ["address"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si ya eligió delivery y ahora da dirección
|
// Si ya eligió delivery y ahora da dirección
|
||||||
if (currentOrder.is_delivery === true && !currentOrder.shipping_address) {
|
if (currentOrder.is_delivery === true && !currentOrder.shipping_address) {
|
||||||
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
|
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
|
||||||
|
|
||||||
if (address) {
|
if (address) {
|
||||||
currentOrder = { ...currentOrder, shipping_address: address };
|
currentOrder = { ...currentOrder, shipping_address: address };
|
||||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
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 {
|
return {
|
||||||
plan: {
|
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,
|
next_state,
|
||||||
intent: "provide_address",
|
intent: "provide_address",
|
||||||
missing_fields: ["payment_method"],
|
missing_fields: ["payment_method"],
|
||||||
order_action: "none",
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "Necesito la dirección de entrega. ¿Me la pasás?",
|
reply: r.reply,
|
||||||
next_state: ConversationState.SHIPPING,
|
next_state: ConversationState.SHIPPING,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["address"],
|
missing_fields: ["address"],
|
||||||
order_action: "none",
|
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") {
|
if (intent === "view_cart") {
|
||||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||||
|
const r = await renderReply({ tenantId, templateKey: "shipping.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: cartDisplay + "\n\n¿Es para delivery o retiro?",
|
reply: `${cartDisplay}\n\n${r.reply}${SHIPPING_OPTIONS_TAIL}`,
|
||||||
next_state: ConversationState.SHIPPING,
|
next_state: ConversationState.SHIPPING,
|
||||||
intent: "view_cart",
|
intent: "view_cart",
|
||||||
missing_fields: ["shipping_method"],
|
missing_fields: ["shipping_method"],
|
||||||
order_action: "none",
|
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 {
|
return {
|
||||||
plan: {
|
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,
|
next_state: ConversationState.SHIPPING,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: ["shipping_method"],
|
missing_fields: ["shipping_method"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { ConversationState } from "../fsm.js";
|
import { ConversationState } from "../fsm.js";
|
||||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||||
|
import { renderReply } from "../replyTemplates.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detecta si el usuario pregunta por horarios/días de entrega
|
* 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)
|
* 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 intent = nlu?.intent || "other";
|
||||||
const currentOrder = order || createEmptyOrder();
|
const currentOrder = order || createEmptyOrder();
|
||||||
|
|
||||||
@@ -118,18 +119,15 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
const reply = currentOrder.payment_type === "link"
|
const r = await renderReply({ tenantId, templateKey: "waiting.in_progress", recentReplies });
|
||||||
? "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.";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply,
|
reply: r.reply,
|
||||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||||
intent: "other",
|
intent: "other",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
},
|
},
|
||||||
decision: { actions: [], order: currentOrder, audit },
|
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/modules/3-turn-engine/storeContext.js
Normal file
104
src/modules/3-turn-engine/storeContext.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
handleWaitingState,
|
handleWaitingState,
|
||||||
} from "./stateHandlers.js";
|
} from "./stateHandlers.js";
|
||||||
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
|
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
|
||||||
|
import { pushRecent } from "./replyTemplates.js";
|
||||||
|
|
||||||
// Feature flag para NLU modular
|
// Feature flag para NLU modular
|
||||||
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
|
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
|
||||||
@@ -60,10 +61,20 @@ export async function runTurnV3({
|
|||||||
|
|
||||||
// Migrar contexto viejo a nuevo formato de orden
|
// Migrar contexto viejo a nuevo formato de orden
|
||||||
const order = migrateOldContext(prev_context);
|
const order = migrateOldContext(prev_context);
|
||||||
|
|
||||||
// Mapear estados viejos a nuevos
|
// Mapear estados viejos a nuevos
|
||||||
const normalizedState = normalizeState(prev_state);
|
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)
|
// NLU (con feature flag para sistema modular)
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -124,13 +135,16 @@ export async function runTurnV3({
|
|||||||
order,
|
order,
|
||||||
audit,
|
audit,
|
||||||
storeConfig,
|
storeConfig,
|
||||||
|
recentReplies,
|
||||||
|
conversation_history: conversation_history || [],
|
||||||
|
failedSearches,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Regla universal: si quiere agregar productos, volver a CART
|
// Regla universal: si quiere agregar productos, volver a CART
|
||||||
const returnToCart = shouldReturnToCart(normalizedState, nlu, text);
|
const returnToCart = shouldReturnToCart(normalizedState, nlu, text);
|
||||||
if (returnToCart) {
|
if (returnToCart) {
|
||||||
const result = await handleCartState({ ...handlerParams, fromIdle: false });
|
const result = await handleCartState({ ...handlerParams, fromIdle: false });
|
||||||
return formatResult(result, prev_context);
|
return formatResult(result, prev_context, recentReplies, failedSearches);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch por estado actual
|
// Dispatch por estado actual
|
||||||
@@ -162,7 +176,7 @@ export async function runTurnV3({
|
|||||||
result = await handleIdleState(handlerParams);
|
result = await handleIdleState(handlerParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatResult(result, prev_context);
|
return formatResult(result, prev_context, recentReplies, failedSearches);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,9 +212,22 @@ function normalizeState(state) {
|
|||||||
/**
|
/**
|
||||||
* Formatea el resultado para compatibilidad con el sistema existente
|
* 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 { plan, decision } = result;
|
||||||
const order = decision?.order || createEmptyOrder();
|
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
|
// Construir context_patch para compatibilidad con pipeline
|
||||||
const context_patch = {
|
const context_patch = {
|
||||||
@@ -238,6 +265,11 @@ function formatResult(result, prevContext) {
|
|||||||
order.is_delivery === false ? "pickup" : null,
|
order.is_delivery === false ? "pickup" : null,
|
||||||
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
||||||
woo_order_id: order.woo_order_id,
|
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
|
// Construir basket_resolved para UI
|
||||||
|
|||||||
Reference in New Issue
Block a user