travel to another computer

This commit is contained in:
Lucas Tettamanti
2026-01-10 12:39:32 -03:00
parent ce96df9e30
commit 2d01972619
7 changed files with 1200 additions and 96 deletions

View File

@@ -17,6 +17,12 @@
- **`OPENAI_API_KEY`** (o `OPENAI_APIKEY`): API key.
- **`OPENAI_MODEL`**: modelo (default `gpt-4o-mini`).
### Turn Engine
- **`TURN_ENGINE`**: motor de turnos. Valores:
- `v1` (default): pipeline actual (heurísticas + guardrails + LLM plan final).
- `v2`: “LLM-first NLU, deterministic core” (nuevo motor detrás de feature flag).
### WooCommerce (solo fallback si falta config por tenant)
- **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`).

17
docs/llm_regionalismos.md Normal file
View File

@@ -0,0 +1,17 @@
# Guía de lenguaje regional (AR/UY) — Catálogo Carnicería
## Objetivo
Evitar “no existe” por diferencias de vocabulario. Si hay ambigüedad, pedir una aclaración con opciones numeradas.
## Ejemplos comunes
- **asado**: puede referirse a *tira de asado*, *tapa de asado*, *asado ventana*, etc.
- **vacío**: puede venir como *vacío*, *vacío pulpón*, *vacío de cerdo*, etc.
- **bondiola**: puede ser cerdo, curada/no curada, feteada, etc.
- **matambre**: puede venir como *a la pizza*, *matambre de cerdo/vacuno*, etc.
## Reglas prácticas
- Si el usuario dice “**opciones** / **variedades** / **tenés X?**”: listar opciones (hasta 9) y ofrecer “Mostrame más…”.
- Si el usuario pregunta por **precio**: no inventar; usar `products_context` o pedir selección primero.
- Si el usuario responde solo “**por kilo**” o solo un número (ej. “2kg”), usar el último producto seleccionado (contexto).

100
scripts/scan-unused.mjs Normal file
View File

@@ -0,0 +1,100 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
/**
* Scan simple de archivos JS alcanzables desde un entrypoint,
* siguiendo imports estáticos: `import ... from "./x.js"` y `await import("./x.js")`.
*
* OJO: no entiende requires dinámicos ni construcciones complejas.
* Útil para detectar archivos claramente no usados.
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, "..");
const entry = path.join(root, "index.js");
const srcDir = path.join(root, "src");
function listJsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length) {
const cur = stack.pop();
const items = fs.readdirSync(cur, { withFileTypes: true });
for (const it of items) {
const p = path.join(cur, it.name);
if (it.isDirectory()) stack.push(p);
else if (it.isFile() && p.endsWith(".js")) out.push(p);
}
}
return out;
}
function readText(p) {
return fs.readFileSync(p, "utf8");
}
function resolveLocalImport(fromFile, spec) {
if (!spec.startsWith(".")) return null;
const base = path.resolve(path.dirname(fromFile), spec);
const candidates = [];
if (base.endsWith(".js")) candidates.push(base);
else {
candidates.push(`${base}.js`);
candidates.push(path.join(base, "index.js"));
}
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
}
return null;
}
function extractImports(code) {
const out = new Set();
// static import ... from "x"
for (const m of code.matchAll(/import\s+[^;]*?\s+from\s+["']([^"']+)["']/g)) {
out.add(m[1]);
}
// side-effect import "x"
for (const m of code.matchAll(/import\s+["']([^"']+)["']/g)) {
out.add(m[1]);
}
// dynamic import("x") or await import("x")
for (const m of code.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g)) {
out.add(m[1]);
}
return [...out];
}
const allSrc = listJsFiles(srcDir);
const all = [entry, ...allSrc];
const reachable = new Set();
const queue = [entry];
while (queue.length) {
const f = queue.shift();
if (reachable.has(f)) continue;
reachable.add(f);
let code = "";
try {
code = readText(f);
} catch {
continue;
}
const specs = extractImports(code);
for (const s of specs) {
const resolved = resolveLocalImport(f, s);
if (resolved && !reachable.has(resolved)) queue.push(resolved);
}
}
const unused = allSrc
.filter((f) => !reachable.has(f))
.map((f) => path.relative(root, f).replace(/\\/g, "/"))
.sort();
console.log(JSON.stringify({ entry: path.relative(root, entry), reachable_count: reachable.size, total_src: allSrc.length, unused }, null, 2));

View File

@@ -171,3 +171,93 @@ export async function llmExtract({ input, model } = {}) {
const extracted = ExtractSchema.parse(parsed);
return { extracted, raw_text, model: chosenModel, usage };
}
// --- NLU v2 (LLM-first) ---
const NluIntentV2Schema = z.enum([
"price_query",
"browse",
"add_to_cart",
"remove_from_cart",
"checkout",
"delivery_question",
"store_hours",
"greeting",
"other",
]);
const NluSelectionSchema = z
.object({
type: z.enum(["index", "text", "sku"]),
value: z.string().min(1),
})
.nullable()
.default(null);
const NluEntitiesSchema = z
.object({
product_query: z.string().nullable().default(null),
quantity: z.number().nullable().default(null),
unit: z.enum(["kg", "g", "unidad", "docena"]).nullable().default(null),
selection: NluSelectionSchema,
attributes: z.array(z.string()).default([]),
preparation: z.array(z.string()).default([]),
budget: z.number().nullable().default(null),
})
.strict();
const NluNeedsSchema = z
.object({
catalog_lookup: z.boolean().default(false),
knowledge_lookup: z.boolean().default(false),
})
.strict();
const NluClarificationSchema = z
.object({
reason: z.enum([
"ambiguous_product",
"missing_quantity",
"missing_variant",
"missing_delivery_zone",
"none",
]),
question: z.string().nullable().default(null),
})
.strict();
const NluV2Schema = z
.object({
intent: NluIntentV2Schema,
confidence: z.number().min(0).max(1).default(0.5),
language: z.string().default("es-AR"),
entities: NluEntitiesSchema,
dialogue_act: z.enum(["answer", "ask_clarification", "confirm", "propose_options"]).default("answer"),
needs: NluNeedsSchema,
clarification: NluClarificationSchema,
})
.strict();
export async function llmNlu({ input, model } = {}) {
const system =
"Sos un servicio NLU para un asistente de carnicería en Argentina (es-AR).\n" +
"Tu tarea es EXTRAER intención, entidades y acto conversacional del mensaje del usuario.\n" +
"Respondé SOLO JSON válido (sin markdown) y con keys EXACTAS según el contrato.\n" +
"\n" +
"Reglas críticas:\n" +
"- Si el contexto incluye last_shown_options y el usuario responde con un número o 'el segundo/la cuarta', eso es selection {type:'index'}.\n" +
"- Si el usuario pone '2kg' o '500g' o '3 unidades' eso es quantity+unit.\n" +
"- Si el usuario pone solo un número y hay opciones mostradas, interpretalo como selection (no como cantidad).\n" +
"- Si el contexto indica pending_item (ya hay producto elegido) y NO hay opciones mostradas, y el usuario pone solo un número, interpretalo como quantity (con unit null o la que indique el usuario).\n" +
"- No inventes productos ni SKUs. product_query es lo que el usuario pidió (ej 'asado', 'tapa de asado wagyu').\n" +
"- needs.catalog_lookup debe ser true para intents: price_query, browse, add_to_cart (salvo que sea pura selección numérica sobre opciones ya mostradas).\n";
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
system,
user: JSON.stringify(input ?? {}),
model,
});
const nlu = NluV2Schema.parse(parsed);
return { nlu, raw_text, model: chosenModel, usage };
}

View File

@@ -15,6 +15,7 @@ import { createWooCustomer, getWooCustomerById } from "./woo.js";
import { llmExtract, llmPlan } from "./openai.js";
import { searchProducts } from "./wooProducts.js";
import { debug as dbg } from "./debug.js";
import { runTurnV2 } from "./turnEngineV2.js";
function nowIso() {
@@ -62,6 +63,16 @@ Tool context: products_context
If products_context is provided (array of products with name/price), you MUST use it as the source of truth for prices and product options.
If the user asks for a price and products_context is empty or has no price, you MUST ask a single clarifying question or say you don't have that price yet. Never invent prices.
Never claim "we don't sell X" unless you actually checked products_context (or asked a clarifying question). If unsure, say you can check and ask for the product name as it appears in the catalog.
Units / weight (IMPORTANT)
Some products are sold by weight (kg/g) and others by unit. If products_context includes categories (e.g., bebidas/vinos) infer unit accordingly.
When asking "how much?", ask for kilos for meat cuts, and units for beverages.
Regional language
Users may use regional names (AR/UY): "vacío", "tira de asado", "bondiola", "matambre", "nalga", etc. If ambiguous, ask a short clarification with numbered options.
basket_resolved (MANDATORY)
@@ -86,6 +97,8 @@ Before returning, double-check: did I include all required keys? Did I move the
function isPriceQuestion(text) {
const t = String(text || "").toLowerCase();
// Si el usuario está pidiendo "opciones" / "variedades", priorizamos browse y NO price.
if (isBrowseQuestion(t)) return false;
return (
t.includes("precio") ||
t.includes("cuánto") ||
@@ -98,6 +111,38 @@ function isPriceQuestion(text) {
);
}
function extractBrowseQuery(text, prev_context = null) {
const raw = String(text || "").toLowerCase();
const prevLabel = prev_context?.slots?.product_label || null;
// patrones: "opciones de X", "que opciones de X", "tenes X", "tenes de X"
const m1 = /\bopciones\s+de\s+(.+)$/.exec(raw);
const m2 = /\bten[eé]s\s+de\s+(.+)$/.exec(raw);
const m3 = /\bten[eé]s\s+(.+)$/.exec(raw);
// producto antes del verbo: "asado vendes?", "asado premium hay?"
const m4 = /^(.+?)\s+\b(ten[eé]s|vendes|hay)\b/.exec(raw);
const pick = (m) =>
m?.[1]
? m[1]
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim()
: null;
let q = pick(m1) || pick(m2) || pick(m3) || pick(m4) || null;
if (q) {
q = q
.replace(/\b(ten[eé]s|vendes|hay|opciones|variedades|tipos)\b/g, " ")
.replace(/\s+/g, " ")
.trim();
}
if (q && q.length >= 3) return q;
// si el user dice "dame opciones" y veníamos de un producto, reutilizarlo
if (/\b(dame|pasame|mandame)\s+opciones\b/.test(raw) && prevLabel) return prevLabel;
return prevLabel || null;
}
function extractProductQuery(text) {
// Heurística simple: remover palabras comunes de pregunta de precio y unidades.
// Esto se puede reemplazar por un extractor LLM más adelante.
@@ -109,10 +154,28 @@ function extractProductQuery(text) {
const stop = new Set([
"precio",
"precios",
"cuanto",
"cuánto",
"sale",
"vale",
"quiero",
"saber",
"decime",
"decir",
"dime",
"dame",
"pasame",
"mandame",
"tenes",
"tenés",
"vendes",
"hay",
"opciones",
"variedades",
"variedad",
"tipos",
"tipo",
"el",
"la",
"los",
@@ -176,6 +239,59 @@ function formatARS(n) {
return x.toLocaleString("es-AR", { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
function inferDefaultUnit({ name, categories }) {
const n = String(name || "").toLowerCase();
const cats = Array.isArray(categories) ? categories : [];
const hay = (re) =>
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
// Heurística: bebidas (vino/cerveza/etc) casi siempre es por unidad.
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
return "unit";
}
return "kg";
}
function unitDisplay(unit) {
if (unit === "unit") return "unidad";
if (unit === "g") return "g";
return "kilo";
}
function askQtyText(unit) {
if (unit === "unit") return "¿Cuántas unidades querés?";
if (unit === "g") return "¿Cuántos gramos querés?";
return "¿Cuántos kilos querés?";
}
function pricePerText(unit) {
return unit === "unit" ? "por unidad" : "el kilo";
}
function parseQuantityUnit(text, defaultUnit = "kg") {
const t = String(text || "").toLowerCase();
// 2kg / 0.5 kg / 500g / 3 unidades
const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|u|unidad|unidades)\b/.exec(t);
if (m) {
const qty = Number(String(m[1]).replace(",", "."));
const u = m[2];
const unit =
u === "g" || u === "gramo" || u === "gramos"
? "g"
: u === "u" || u === "unidad" || u === "unidades"
? "unit"
: "kg";
return Number.isFinite(qty) ? { quantity: qty, unit } : null;
}
// solo número => asumir unidad default
const m2 = /\b(\d+(?:[.,]\d+)?)\b/.exec(t);
if (m2) {
const qty = Number(String(m2[1]).replace(",", "."));
return Number.isFinite(qty) ? { quantity: qty, unit: defaultUnit } : null;
}
return null;
}
function makePerf() {
const started_at = Date.now();
const perf = { t0: started_at, marks: {} };
@@ -214,6 +330,50 @@ function isAffirmation(text) {
);
}
function isBrowseQuestion(text) {
const t = String(text || "").toLowerCase();
// "qué opciones de vacío tenés", "qué tenés de vacío", "mostrame opciones de ..."
if (/\bopciones\b/.test(t)) return true;
if (/\b(que|qué)\s+ten[eé]s\b/.test(t)) return true;
if (/\b(variedades|variedad|tipos|tipo)\b/.test(t)) return true;
if (/\bmostr(a|ame|ame)\b/.test(t) && /\b(opciones|productos)\b/.test(t)) return true;
// consultas simples de catálogo: "asado tenés?", "asado vendés?", "hay asado premium?"
if (/\b(ten[eé]s|vendes|hay)\b/.test(t)) {
const cleaned = t
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim();
const stop = new Set([
"tenes",
"tenés",
"vendes",
"hay",
"de",
"del",
"la",
"el",
"los",
"las",
"un",
"una",
"por",
"favor",
"hola",
"buenas",
"buenos",
"dia",
"día",
"tardes",
"noches",
"precio",
"precios",
]);
const toks = cleaned.split(" ").filter(Boolean).filter((w) => !stop.has(w));
if (toks.length >= 1) return true;
}
return false;
}
function isGreeting(text) {
const t = String(text || "").trim().toLowerCase();
if (!t) return false;
@@ -229,7 +389,7 @@ function classifyIntent(text, prev_context = null) {
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
const hasPending = Boolean(prev.pending_clarification?.candidates?.length);
const hasBasket = Array.isArray(prev.order_basket?.items) && prev.order_basket.items.length > 0;
const awaitingPrice = Boolean(prev.awaiting_price?.labels?.length);
const awaitingPrice = Boolean(prev.awaiting_price?.labels?.length) || Boolean(prev.pending_item);
if (isAffirmation(text) && (hasPending || hasBasket || awaitingPrice)) {
return { kind: awaitingPrice ? "price" : "order", intent_hint: awaitingPrice ? "ask_price" : "create_order", needs_extract: false, needs_products: awaitingPrice, is_continuation: true };
@@ -237,6 +397,10 @@ function classifyIntent(text, prev_context = null) {
if (isOrderish(text)) {
return { kind: "order", intent_hint: "create_order", needs_extract: true, needs_products: true };
}
// Browse ANTES que price: "opciones de asado ... buscando precios" es browse (listado) + luego precio por item.
if (isBrowseQuestion(text)) {
return { kind: "browse", intent_hint: "browse_products", needs_extract: false, needs_products: true };
}
if (isPriceQuestion(text)) {
return { kind: "price", intent_hint: "ask_price", needs_extract: true, needs_products: true };
}
@@ -255,6 +419,12 @@ function tokenize(s) {
function candidateText(c) {
const parts = [c?.name || ""];
if (Array.isArray(c?.categories)) {
for (const cat of c.categories) {
if (cat?.name) parts.push(cat.name);
if (cat?.slug) parts.push(cat.slug);
}
}
if (Array.isArray(c?.attributes)) {
for (const a of c.attributes) {
if (a?.name) parts.push(a.name);
@@ -301,6 +471,53 @@ function computeDifferentiators(candidates) {
});
}
function isShowMoreRequest(text) {
const t = String(text || "").toLowerCase();
return (
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
/\bmas\s+opciones\b/.test(t) ||
/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t) ||
/\bsiguiente(s)?\b/.test(t)
);
}
function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) {
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
const off = Math.max(0, parseInt(candidateOffset, 10) || 0);
const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9));
const slice = cands.slice(off, off + size);
const diffs = computeDifferentiators(cands);
const options = slice.map((c, i) => {
const d = diffs.find((z) => z.id === c.woo_product_id);
const hint = d?.unique_tokens?.length ? ` (${d.unique_tokens.join(", ")})` : "";
return { idx: baseIdx + i, type: "product", woo_product_id: c.woo_product_id, name: c.name, hint };
});
const hasMore = off + size < cands.length;
if (hasMore) {
options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" });
}
const list = options
.map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}${o.hint}`))
.join("\n");
const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`;
const pending = {
candidates: cands,
options,
candidate_offset: off,
page_size: size,
base_idx: baseIdx,
has_more: hasMore,
next_candidate_offset: off + size,
next_base_idx: baseIdx + size + (hasMore ? 1 : 0),
};
return { question, pending, options, hasMore };
}
function resolveAmbiguity({ userText, candidates }) {
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
if (cands.length <= 1) {
@@ -320,31 +537,31 @@ function resolveAmbiguity({ userText, candidates }) {
return { kind: "resolved", chosen: best.c, pending: null, debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) } };
}
const diffs = computeDifferentiators(scored.map((x) => x.c));
const options = scored.slice(0, 3).map((x, i) => {
const d = diffs.find((z) => z.id === x.c.woo_product_id);
const hint = d?.unique_tokens?.length ? ` (${d.unique_tokens.join(", ")})` : "";
return { idx: i + 1, woo_product_id: x.c.woo_product_id, name: x.c.name, hint };
});
const list = options.map((o) => `${o.idx}) ${o.name}${o.hint}`).join(" / ");
const question = `¿Cuál de estos querés? ${list}`;
const full = scored.map((x) => x.c).slice(0, 60);
const { question, pending } = buildPagedOptions({ candidates: full, candidateOffset: 0, baseIdx: 1, pageSize: 9 });
return {
kind: "ask",
question,
pending: { candidates: scored.map((x) => x.c).slice(0, 5), options },
pending,
debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) },
};
}
function parseOptionSelection(userText) {
const t = String(userText || "").toLowerCase();
const m = /\b([1-3])\b/.exec(t);
const m = /\b(\d{1,2})\b/.exec(t);
if (m) return parseInt(m[1], 10);
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
if (/\btercera\b|\btercero\b/.test(t)) return 3;
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
return null;
}
@@ -427,7 +644,8 @@ async function extractProducts({
for (const it of extracted.items) {
try {
const { items } = await searchProducts({ tenantId, q: it.label, limit: 5 });
// Para ambigüedades queremos más candidatos (no 3), así el paginado 9+1 tiene con qué.
const { items } = await searchProducts({ tenantId, q: it.label, limit: 25 });
if (!items?.length) {
unresolved.push({ ...it, reason: "not_found" });
continue;
@@ -454,8 +672,13 @@ async function extractProducts({
...it,
reason: "ambiguous",
candidates: scored
.slice(0, 3)
.map((x) => ({ woo_product_id: x.p.woo_product_id, name: x.p.name, price: x.p.price })),
.slice(0, 25)
.map((x) => ({
woo_product_id: x.p.woo_product_id,
name: x.p.name,
price: x.p.price,
attributes: x.p.attributes || [],
})),
});
continue;
}
@@ -480,19 +703,27 @@ async function extractProducts({
// products_context para precios / browse (soporta múltiples labels)
let products_context = null;
if (classification.kind === "price" || isPriceQuestion(text)) {
if (classification.needs_products || classification.kind === "price" || isPriceQuestion(text) || classification.kind === "browse") {
const labels = (extracted?.items || []).map((x) => x.label).filter(Boolean);
const queries = labels.length ? labels.slice(0, 3) : [extractProductQuery(text)];
const prevLabel = llmInput?.context?.slots?.product_label || null;
const q0 = classification.kind === "browse"
? (extractBrowseQuery(text, llmInput?.context) || extractProductQuery(text))
: extractProductQuery(text);
const baseQueries = labels.length ? labels.slice(0, 3) : [prevLabel, q0].filter(Boolean);
const queries = baseQueries.length ? baseQueries.slice(0, 3) : [q0].filter(Boolean);
mark("before_product_lookup");
try {
const results = [];
for (const q of queries) {
const { items, source } = await searchProducts({ tenantId, q, limit: 5 });
const lim = classification.kind === "browse" ? 25 : 12;
const forceWoo = classification.kind === "browse" || classification.kind === "price";
const { items, source } = await searchProducts({ tenantId, q, limit: lim, forceWoo });
results.push({ label: q, source, items });
}
// flatten para UI/LLM (top 1 por label primero)
const flat = results.flatMap((r) => (r.items || []).slice(0, 2).map((p) => ({ ...p, _query: r.label })));
// flatten para UI/LLM
const perLabel = classification.kind === "browse" ? 25 : 12;
const flat = results.flatMap((r) => (r.items || []).slice(0, perLabel).map((p) => ({ ...p, _query: r.label })));
products_context = { queries, results, items: flat };
llmInput.products_context = products_context;
logStage(stageDebug, "extractProducts.products_context", { queries, results: results.map((r) => ({ q: r.label, count: r.items?.length || 0 })) });
@@ -526,33 +757,73 @@ function applyBusinessLogic({
classification.kind === "price" ||
extracted?.intent === "ask_price" ||
Boolean(prev.awaiting_price?.labels?.length) ||
Boolean(prev.pending_item) ||
isPriceQuestion(text);
// 0) Si hay una ambigüedad pendiente, intentamos colapsarla con la respuesta del usuario
// (evita loops de "¿a cuál te referís?" cuando el usuario ya respondió "la no curada")
// Nota: esta rama debe tener prioridad sobre pending_item para evitar casos tipo:
// el bot mostró opciones y el usuario responde "4" => selección (NO cantidad en kg).
if (prev.pending_clarification?.candidates?.length) {
// Selección explícita por número (1/2/3)
const sel = parseOptionSelection(text);
if (sel && Array.isArray(prev.pending_clarification.options)) {
const opt = prev.pending_clarification.options.find((o) => o.idx === sel);
if (opt) {
const chosen = prev.pending_clarification.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
if (opt.type === "more") {
const nextOffset = prev.pending_clarification.next_candidate_offset ?? ((prev.pending_clarification.candidate_offset || 0) + (prev.pending_clarification.page_size || 9));
const nextBaseIdx = prev.pending_clarification.next_base_idx ?? ((prev.pending_clarification.base_idx || 1) + (prev.pending_clarification.page_size || 9) + 1);
const { question, pending } = buildPagedOptions({
candidates: prev.pending_clarification.candidates,
candidateOffset: nextOffset,
baseIdx: nextBaseIdx,
pageSize: prev.pending_clarification.page_size || 9,
});
const decision = {
mode: "ask_clarification",
reply: question,
next_state: "BROWSING",
intent: extracted?.intent || classification.intent_hint || "browse_products",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: resolvedBasket || { items: [] },
meta: { paged: true, via: "more_option" },
context_patch: { pending_clarification: pending, pending_item: null },
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
}
const chosen =
prev.pending_clarification.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
if (chosen) {
const price = chosen.price ?? null;
const unit = inferDefaultUnit({ name: chosen.name, categories: chosen.categories });
const decision = {
mode: "resolved_from_pending",
resolved_product: chosen,
reply:
price != null
? `Perfecto. ${chosen.name} está $${formatARS(price)} el kilo. ¿Cuántos kilos querés?`
: `Perfecto. ¿Cuántos kilos querés llevar de ${chosen.name}?`,
? `Perfecto. ${chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
: `Perfecto. ${askQtyText(unit)} de ${chosen.name}?`,
next_state: "BROWSING",
intent: "ask_price",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
meta: { collapsed: true, via: "option_number" },
context_patch: { pending_clarification: null, awaiting_price: null },
context_patch: {
pending_clarification: null,
awaiting_price: null,
pending_item: {
product_id: Number(chosen.woo_product_id),
variation_id: null,
name: chosen.name,
price: chosen.price ?? null,
categories: chosen.categories || [],
attributes: chosen.attributes || [],
default_unit: unit,
},
},
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
@@ -560,23 +831,61 @@ function applyBusinessLogic({
}
}
// “mostrame más” sin número
if (isShowMoreRequest(text)) {
const nextOffset = prev.pending_clarification.next_candidate_offset ?? ((prev.pending_clarification.candidate_offset || 0) + (prev.pending_clarification.page_size || 9));
const nextBaseIdx = prev.pending_clarification.next_base_idx ?? ((prev.pending_clarification.base_idx || 1) + (prev.pending_clarification.page_size || 9) + 1);
const { question, pending } = buildPagedOptions({
candidates: prev.pending_clarification.candidates,
candidateOffset: nextOffset,
baseIdx: nextBaseIdx,
pageSize: prev.pending_clarification.page_size || 9,
});
const decision = {
mode: "ask_clarification",
reply: question,
next_state: "BROWSING",
intent: extracted?.intent || classification.intent_hint || "browse_products",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: resolvedBasket || { items: [] },
meta: { paged: true, via: "more_text" },
context_patch: { pending_clarification: pending, pending_item: null },
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
}
const r = resolveAmbiguity({ userText: text, candidates: prev.pending_clarification.candidates });
if (r.kind === "resolved" && r.chosen) {
// si era un price inquiry, resolvemos precio directo en base a ese candidato
const price = r.chosen.price ?? null;
const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories });
const decision = {
mode: "resolved_from_pending",
resolved_product: r.chosen,
reply: price != null
? `Perfecto. ${r.chosen.name} está $${formatARS(price)} el kilo. ¿Cuántos kilos querés?`
: `Perfecto. ¿Cuántos kilos querés llevar de ${r.chosen.name}?`,
? `Perfecto. ${r.chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
: `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`,
next_state: "BROWSING",
intent: "ask_price",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
meta: { collapsed: true, debug: r.debug || null },
context_patch: { pending_clarification: null, awaiting_price: null },
context_patch: {
pending_clarification: null,
awaiting_price: null,
pending_item: {
product_id: Number(r.chosen.woo_product_id),
variation_id: null,
name: r.chosen.name,
price: r.chosen.price ?? null,
categories: r.chosen.categories || [],
attributes: r.chosen.attributes || [],
default_unit: unit,
},
},
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
@@ -592,13 +901,85 @@ function applyBusinessLogic({
order_action: "none",
basket_resolved: resolvedBasket || { items: [] },
meta: { skipped: "pending_still_ambiguous", debug: r.debug || null },
context_patch: { pending_clarification: r.pending, awaiting_price: isPriceFlow ? { labels: [prev?.slots?.product_label].filter(Boolean) } : null },
context_patch: { pending_clarification: r.pending, awaiting_price: isPriceFlow ? { labels: [prev?.slots?.product_label].filter(Boolean) } : null, pending_item: null },
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
}
}
// 0a) Si venimos de una selección (pending_item) y el usuario responde cantidad/unidad,
// completamos basket_resolved y confirmamos sin volver a preguntar por "qué producto".
if (prev.pending_item) {
const defUnit = inferDefaultUnit({ name: prev.pending_item.name, categories: prev.pending_item.categories });
const parsed = parseQuantityUnit(text, defUnit);
if (parsed?.quantity) {
const it = {
product_id: Number(prev.pending_item.product_id),
variation_id: prev.pending_item.variation_id ?? null,
quantity: parsed.quantity,
unit: parsed.unit,
label: prev.pending_item.name,
};
const qtyStr =
parsed.unit === "unit"
? `${parsed.quantity}u`
: parsed.unit === "g"
? `${parsed.quantity}g`
: `${parsed.quantity}kg`;
const decision = {
mode: "resolved_from_pending",
reply: `Perfecto, anoto ${qtyStr} de ${prev.pending_item.name}. ¿Algo más?`,
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [it] },
meta: { server: "pending_item_qty" },
context_patch: {
pending_item: null,
pending_clarification: null,
awaiting_price: null,
order_basket: mergeBasket(prev.order_basket, { items: [it] }),
},
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
}
}
// 0b) Browse: "qué opciones de X tenés" => listar opciones server-side y dejar pending_clarification
if (classification.kind === "browse" && products_context?.items?.length) {
const uniq = new Map();
for (const p of products_context.items) {
if (!p?.woo_product_id) continue;
if (!uniq.has(p.woo_product_id)) uniq.set(p.woo_product_id, p);
}
const candidates = [...uniq.values()].slice(0, 60).map((p) => ({
woo_product_id: p.woo_product_id,
name: p.name,
price: p.price ?? null,
attributes: p.attributes || [],
}));
const label = (products_context.queries || []).join(" / ") || "ese producto";
const { question, pending } = buildPagedOptions({ candidates, candidateOffset: 0, baseIdx: 1, pageSize: 9 });
const decision = {
mode: "ask_clarification",
reply: `Tengo estas opciones de "${label}":\n${question.split("\n").slice(1).join("\n")}`,
next_state: "BROWSING",
intent: "browse_products",
missing_fields: ["product_confirmation"],
order_action: "none",
basket_resolved: { items: [] },
meta: { server: "browse_options" },
// Importante: al entrar en modo selección por lista, anulamos pending_item viejo para que un "4" no se tome como cantidad.
context_patch: { pending_clarification: pending, pending_item: null },
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
}
// 1) Ambiguo / sin resolver => 1 pregunta (pero guardando candidatos para colapsar luego)
if (unresolved?.length) {
const first = unresolved[0];
@@ -609,19 +990,31 @@ function applyBusinessLogic({
: null;
if (r?.kind === "resolved" && r.chosen) {
const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories });
const decision = {
mode: "resolved_from_pending",
resolved_product: r.chosen,
reply: r.chosen.price != null
? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} el kilo. ¿Cuántos kilos querés?`
: `Perfecto. ¿Cuántos kilos querés llevar de ${r.chosen.name}?`,
? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
: `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`,
next_state: "BROWSING",
intent: "ask_price",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
meta: { collapsed: true, debug: r.debug || null },
context_patch: { pending_clarification: null },
context_patch: {
pending_clarification: null,
pending_item: {
product_id: Number(r.chosen.woo_product_id),
variation_id: null,
name: r.chosen.name,
price: r.chosen.price ?? null,
categories: r.chosen.categories || [],
attributes: r.chosen.attributes || [],
default_unit: unit,
},
},
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
@@ -668,30 +1061,71 @@ function applyBusinessLogic({
return decision;
}
// 2) Precio: si tenemos precio real, responder server-side y no dejar al LLM inventar "$X"
// 2) Precio: siempre server-side (si no, el LLM inventa o pregunta cosas raras)
if (isPriceFlow && products_context && Array.isArray(products_context.items)) {
// Preferimos mostrar 12 precios por “label” consultado (si vienen múltiples queries)
const priced = products_context.items
.filter((p) => p && p.name && p.price != null)
.slice(0, 6);
const uniq = new Map();
for (const p of products_context.items) {
if (!p?.woo_product_id || !p?.name) continue;
if (!uniq.has(p.woo_product_id)) uniq.set(p.woo_product_id, p);
}
const candidates = [...uniq.values()].slice(0, 60).map((p) => ({
woo_product_id: p.woo_product_id,
name: p.name,
price: p.price ?? null,
categories: p.categories || [],
attributes: p.attributes || [],
}));
if (priced.length) {
const lines = priced
.map((p) => {
const prefix = p._query ? `${p._query}: ` : "";
return `- ${prefix}${p.name}: $${formatARS(p.price)}`;
})
.join("\n");
// Si hay un único candidato, respondemos el precio directo (y guardamos pending_item para cantidad)
if (candidates.length === 1) {
const c = candidates[0];
const price = c.price ?? null;
const unit = inferDefaultUnit({ name: c.name, categories: c.categories });
const decision = {
mode: "server_price",
reply: `Perfecto 🙂\n${lines}\n¿Cuántos kilos querés?`,
reply: price != null
? `Perfecto. ${c.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
: `Perfecto. ${askQtyText(unit)} de ${c.name}?`,
next_state: "BROWSING",
intent: "ask_price",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
meta: { skipped: "price_server_side", source: products_context.source || null },
context_patch: { pending_clarification: null, awaiting_price: null },
meta: { server: "price_single_candidate" },
context_patch: {
pending_clarification: null,
awaiting_price: null,
pending_item: {
product_id: Number(c.woo_product_id),
variation_id: null,
name: c.name,
price: c.price ?? null,
categories: c.categories || [],
attributes: c.attributes || [],
default_unit: unit,
},
},
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
}
if (candidates.length) {
const { question, pending } = buildPagedOptions({ candidates, candidateOffset: 0, baseIdx: 1, pageSize: 9 });
const label =
(Array.isArray(products_context.queries) ? products_context.queries.join(" / ") : null) ||
extracted?.items?.[0]?.label ||
"ese producto";
const decision = {
mode: "ask_clarification",
reply: `Para cotizar "${label}", ¿cuál es?\n${question.split("\n").slice(1).join("\n")}`,
next_state: "BROWSING",
intent: "ask_price",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
meta: { server: "price_pick_product" },
context_patch: { pending_clarification: pending, awaiting_price: { labels: [label] } },
};
logStage(stageDebug, "applyBusinessLogic", decision);
return decision;
@@ -923,7 +1357,8 @@ export async function processMessage({
mark("after_insertMessage_in");
mark("before_classifyIntent");
const classification = classifyIntent(text, prev?.context);
const useTurnV2 = String(process.env.TURN_ENGINE || "").toLowerCase() === "v2";
const classification = useTurnV2 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context);
mark("after_classifyIntent");
logStage(stageDebug, "classifyIntent", classification);
@@ -938,10 +1373,31 @@ export async function processMessage({
});
logStage(stageDebug, "llmInput.base", { has_history: Array.isArray(llmInput.conversation_history), state: llmInput.current_conversation_state });
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
let decision;
let plan;
let llmMeta;
let resolvedBasket = null;
if (useTurnV2) {
mark("before_turn_v2");
const out = await runTurnV2({
tenantId,
chat_id,
text,
prev_state,
prev_context: reducedContext,
conversation_history: llmInput.conversation_history || [],
});
plan = out.plan;
llmMeta = out.llmMeta;
decision = out.decision || { context_patch: {} };
mark("after_turn_v2");
} else {
// Reducer de contexto: consolidar slots y evitar “olvidos”
mark("before_reduceContext");
const resolveDebug = dbg.resolve;
const { extracted, resolvedBasket, unresolved, products_context } = await extractProducts({
const { extracted, resolvedBasket: rb, unresolved, products_context } = await extractProducts({
tenantId,
text,
llmInput,
@@ -950,7 +1406,8 @@ export async function processMessage({
classification,
stageDebug,
});
const reducedContext = reduceConversationContext(prev?.context, { text, extracted, products_context: llmInput.products_context || products_context });
resolvedBasket = rb || null;
reducedContext = reduceConversationContext(prev?.context, { text, extracted, products_context: llmInput.products_context || products_context });
mark("after_reduceContext");
logStage(stageDebug, "reduceContext", reducedContext?.slots || {});
@@ -973,7 +1430,7 @@ export async function processMessage({
}
mark("before_applyBusinessLogic");
const decision = applyBusinessLogic({
decision = applyBusinessLogic({
text,
classification,
extracted,
@@ -985,7 +1442,7 @@ export async function processMessage({
});
mark("after_applyBusinessLogic");
const { plan, llmMeta } = await composeReply({
const out = await composeReply({
text,
llmInput,
decision,
@@ -993,6 +1450,9 @@ export async function processMessage({
mark,
stageDebug,
});
plan = out.plan;
llmMeta = out.llmMeta;
}
sanitizeIntentAndState({ plan, text, classification, prev_state });

View File

@@ -0,0 +1,415 @@
import { z } from "zod";
import { llmNlu } from "./openai.js";
import { searchProducts } from "./wooProducts.js";
// --- Types / Contracts (runtime-validated where it matters) ---
const TurnActionSchema = z.object({
type: z.enum(["show_options", "quote_price", "add_to_cart", "ask_clarification"]),
payload: z.record(z.any()).default({}),
});
function normalizeUnit(unit) {
const u = unit == null ? null : String(unit).toLowerCase();
if (!u) return null;
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
if (u === "g" || u === "gramo" || u === "gramos") return "g";
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
if (u === "docena" || u === "docenas") return "docena";
return null;
}
function pickProductQuery({ nlu, prevContext }) {
const q = nlu?.entities?.product_query || null;
if (q && String(q).trim()) return String(q).trim();
const last = prevContext?.slots?.product_label || prevContext?.pending_item?.name || null;
return last && String(last).trim() ? String(last).trim() : null;
}
function mapIntentToLegacy(intent) {
switch (intent) {
case "price_query":
return "ask_price";
case "browse":
return "browse_products";
case "add_to_cart":
return "add_item";
case "remove_from_cart":
return "remove_item";
case "checkout":
return "checkout";
case "greeting":
return "other";
default:
return "other";
}
}
function formatARS(n) {
const num = Number(n);
if (!Number.isFinite(num)) return String(n);
// simple ARS formatting (miles con punto)
return Math.round(num).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
function inferUnitHintByName(name) {
const n = String(name || "").toLowerCase();
// Embutidos / parrilleros: puede ser kg o unidad
if (/\b(chorizo|chorizos|morcilla|morcillas|salchicha|salchichas|parrillera|parrilleras)\b/.test(n)) {
return { defaultUnit: "unit", ask: "¿Los querés por kg o por unidad?" };
}
// Bebidas: unidad
if (/\b(vino|vinos|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/.test(n)) {
return { defaultUnit: "unit", ask: "¿Cuántas unidades querés?" };
}
// Carnes: kg (no preguntar “por kilo”, preguntar cantidad)
return { defaultUnit: "kg", ask: "¿Cuántos kilos querés?" };
}
function unitAskFor(unit) {
if (unit === "g") return "¿Cuántos gramos querés?";
if (unit === "unit") return "¿Cuántas unidades querés?";
return "¿Cuántos kilos querés?";
}
function formatQty({ quantity, unit }) {
const q = Number(quantity);
if (!Number.isFinite(q) || q <= 0) return String(quantity);
if (unit === "g") return `${q}g`;
if (unit === "unit") return `${q}u`;
return `${q}kg`;
}
function buildOptionsList(candidates, { baseIdx = 1, pageSize = 9 } = {}) {
const slice = candidates.slice(0, Math.max(1, Math.min(20, pageSize)));
const options = slice.map((c, i) => ({
idx: baseIdx + i,
woo_product_id: c.woo_product_id,
name: c.name,
price: c.price ?? null,
}));
const list = options.map((o) => `- ${o.idx}) ${o.name}`).join("\n");
return { options, text: `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.` };
}
function parseIndexSelection(text) {
const t = String(text || "").toLowerCase();
const m = /\b(\d{1,2})\b/.exec(t);
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) ? n : null;
}
/**
* Turn Engine v2: “LLM-first NLU, deterministic core”
*
* Devuelve un objeto compatible con el pipeline actual:
* { plan, llmMeta, decision }
* - plan: { reply, next_state, intent, missing_fields, order_action, basket_resolved }
* - decision.context_patch: para que pipeline lo persista
*/
export async function runTurnV2({
tenantId,
chat_id,
text,
prev_state,
prev_context,
conversation_history,
} = {}) {
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
const last_shown_options = Array.isArray(prev?.pending_clarification?.options)
? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null }))
: [];
const nluInput = {
last_user_message: text,
conversation_state: prev_state || "IDLE",
memory_summary: Array.isArray(conversation_history)
? conversation_history
.slice(-6)
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
.join(" | ")
: "",
pending_context: {
pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
pending_item: prev?.pending_item?.name || null,
},
last_shown_options,
locale: "es-AR",
customer_profile: prev?.customer_profile || null,
feature_flags: { turn_engine: "v2" },
};
const { nlu, raw_text, model, usage } = await llmNlu({ input: nluInput });
const llmMeta = { model, usage, raw_text, kind: "nlu_v2" };
const actions = [];
const context_patch = {};
// --- AWAITING_QUANTITY / pending_item ---
// Si ya hay un producto elegido, el siguiente turno suele ser cantidad/unidad.
if (prev?.pending_item?.product_id && !prev?.pending_clarification?.candidates?.length) {
const q0 = nlu?.entities?.quantity;
const u0 = normalizeUnit(nlu?.entities?.unit);
// Permitir "por kg"/"por unidad" sin número: actualizamos default_unit y preguntamos cantidad.
if (!q0 && u0) {
context_patch.pending_item = { ...(prev.pending_item || {}), default_unit: u0 === "g" ? "g" : u0 === "unit" ? "unit" : "kg" };
const plan = {
reply: unitAskFor(context_patch.pending_item.default_unit),
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_set_unit" } };
return { plan, llmMeta, decision };
}
// Número solo (ej "4") -> quantity, con unit por default_unit del pending_item.
if (q0 && Number(q0) > 0) {
const defaultUnit = prev.pending_item.default_unit || "kg";
const unit = u0 === "g" ? "g" : u0 === "unit" || u0 === "docena" ? "unit" : defaultUnit === "g" ? "g" : defaultUnit === "unit" ? "unit" : "kg";
const qty = u0 === "docena" ? Number(q0) * 12 : Number(q0);
const it = {
product_id: Number(prev.pending_item.product_id),
variation_id: prev.pending_item.variation_id ?? null,
quantity: qty,
unit,
label: prev.pending_item.name || "ese producto",
};
context_patch.pending_item = null;
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
const plan = {
reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${it.label}. ¿Algo más?`,
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [it] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_qty" } };
return { plan, llmMeta, decision };
}
// Si no entendimos cantidad, re-preguntamos con la unidad correcta.
const fallbackUnit = prev.pending_item.default_unit || inferUnitHintByName(prev.pending_item.name || "").defaultUnit;
const plan = {
reply: unitAskFor(fallbackUnit === "unit" ? "unit" : fallbackUnit === "g" ? "g" : "kg"),
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_reask" } };
return { plan, llmMeta, decision };
}
// --- Deterministic overrides from state/pending ---
// Si hay pending_clarification, una respuesta numérica debe interpretarse como selección.
const selectionIdx =
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
(prev?.pending_clarification?.candidates?.length ? parseIndexSelection(text) : null);
// Si hay selectionIdx y options vigentes, elegimos producto.
let chosen = null;
if (selectionIdx && Array.isArray(prev?.pending_clarification?.options)) {
const opt = prev.pending_clarification.options.find((o) => o.idx === selectionIdx && o.type === "product");
if (opt) {
chosen = prev.pending_clarification.candidates?.find((c) => c.woo_product_id === opt.woo_product_id) || null;
}
}
// --- Retrieval (catálogo) ---
const productQuery = pickProductQuery({ nlu, prevContext: prev });
let candidates = [];
if (nlu?.needs?.catalog_lookup || ["price_query", "browse", "add_to_cart"].includes(nlu.intent)) {
if (productQuery) {
const { items } = await searchProducts({ tenantId, q: productQuery, limit: 12 });
candidates = Array.isArray(items) ? items : [];
}
}
// Si no venía de pending_clarification pero NLU pidió browse, y tenemos candidatos, mostramos lista.
if (!chosen && (nlu.intent === "browse" || (nlu.intent === "price_query" && candidates.length > 1))) {
const { options, text: listText } = buildOptionsList(candidates, { baseIdx: 1, pageSize: 9 });
actions.push({ type: "show_options", payload: { count: options.length } });
context_patch.pending_clarification = {
candidates: candidates.map((c) => ({
woo_product_id: c.woo_product_id,
name: c.name,
price: c.price ?? null,
categories: c.categories || [],
attributes: c.attributes || [],
})),
options: options.map((o) => ({ idx: o.idx, type: "product", woo_product_id: o.woo_product_id, name: o.name })),
candidate_offset: 0,
page_size: 9,
base_idx: 1,
has_more: false,
next_candidate_offset: options.length,
next_base_idx: options.length + 1,
};
// clave: al entrar en selección por lista, anulamos pending_item viejo
context_patch.pending_item = null;
const plan = {
reply: listText,
next_state: "BROWSING",
intent: "browse_products",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } };
return { plan, llmMeta, decision };
}
// Si hubo elección por índice (chosen), pasamos a AWAITING_QUANTITY (representado como BUILDING_ORDER).
if (chosen) {
actions.push({ type: "ask_clarification", payload: { reason: "missing_quantity" } });
const unitHint = inferUnitHintByName(chosen.name);
context_patch.pending_clarification = null;
context_patch.pending_item = {
product_id: Number(chosen.woo_product_id),
variation_id: null,
name: chosen.name,
price: chosen.price ?? null,
categories: chosen.categories || [],
attributes: chosen.attributes || [],
default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg",
};
// si el mismo turno ya trajo cantidad, agregamos al carrito directo
const q = nlu?.entities?.quantity;
const u = normalizeUnit(nlu?.entities?.unit);
if (q && Number(q) > 0) {
const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : "kg";
const qty = u === "docena" ? Number(q) * 12 : Number(q);
const it = {
product_id: Number(chosen.woo_product_id),
variation_id: null,
quantity: qty,
unit,
label: chosen.name,
};
context_patch.pending_item = null;
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
const plan = {
reply: `Perfecto, anoto ${unit === "kg" ? `${qty}kg` : `${qty}u`} de ${chosen.name}. ¿Algo más?`,
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [it] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } };
return { plan, llmMeta, decision };
}
const plan = {
reply: unitHint.ask,
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } };
return { plan, llmMeta, decision };
}
// price_query: si hay 1 candidato claro => cotizar
if (nlu.intent === "price_query" && candidates.length === 1) {
const c = candidates[0];
actions.push({ type: "quote_price", payload: { product_id: c.woo_product_id } });
const unitHint = inferUnitHintByName(c.name);
const price = c.price;
const per = unitHint.defaultUnit === "unit" ? "por unidad" : "el kilo";
const reply =
price != null
? `${c.name} está $${formatARS(price)} ${per}. ${unitHint.ask}`
: `Dale, lo reviso y te confirmo el precio en un momento. ${unitHint.ask}`;
// en price, dejamos pending_item para que el siguiente turno sea cantidad
context_patch.pending_item = {
product_id: Number(c.woo_product_id),
variation_id: null,
name: c.name,
price: c.price ?? null,
categories: c.categories || [],
attributes: c.attributes || [],
default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg",
};
context_patch.pending_clarification = null;
const plan = {
reply,
next_state: "BROWSING",
intent: "ask_price",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, candidates_count: candidates.length } };
return { plan, llmMeta, decision };
}
// add_to_cart sin candidates: preguntamos producto
if (nlu.intent === "add_to_cart" && !productQuery) {
actions.push({ type: "ask_clarification", payload: { reason: "missing_product" } });
const plan = {
reply: "Dale. ¿Qué producto querés agregar?",
next_state: prev_state || "IDLE",
intent: "add_item",
missing_fields: ["product_query"],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu } };
return { plan, llmMeta, decision };
}
// add_to_cart con candidates y cantidad: si hay 1 candidato claro, agregamos directo.
if (nlu.intent === "add_to_cart" && candidates.length === 1 && nlu?.entities?.quantity && Number(nlu.entities.quantity) > 0) {
const c = candidates[0];
const u = normalizeUnit(nlu?.entities?.unit);
const unitHint = inferUnitHintByName(c.name);
const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : unitHint.defaultUnit === "unit" ? "unit" : "kg";
const qty = u === "docena" ? Number(nlu.entities.quantity) * 12 : Number(nlu.entities.quantity);
const it = { product_id: Number(c.woo_product_id), variation_id: null, quantity: qty, unit, label: c.name };
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
const plan = {
reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${c.name}. ¿Algo más?`,
next_state: "BUILDING_ORDER",
intent: "add_item",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [it] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "add_to_cart_direct" } };
return { plan, llmMeta, decision };
}
// Fallback: respuesta segura y corta, sin inventar.
const legacyIntent = mapIntentToLegacy(nlu.intent);
const plan = {
reply: nlu?.clarification?.question || "Dale. ¿Qué necesitás exactamente?",
next_state: prev_state || "IDLE",
intent: legacyIntent,
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
};
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } };
// validate actions shape (best-effort, no throw)
try {
decision.actions = Array.isArray(actions) ? actions.map((a) => TurnActionSchema.parse(a)) : [];
} catch {
decision.actions = [];
}
return { plan, llmMeta, decision };
}

View File

@@ -95,6 +95,13 @@ function normalizeWooProduct(p) {
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
currency: null,
type: p?.type || null, // simple | variable | grouped | external
categories: Array.isArray(p?.categories)
? p.categories.map((c) => ({
id: c?.id ?? null,
name: c?.name ?? null,
slug: c?.slug ?? null,
}))
: [],
attributes: Array.isArray(p?.attributes)
? p.attributes.map((a) => ({
name: a?.name || null,
@@ -126,8 +133,9 @@ export async function searchProducts({
// 1) Cache en Postgres
const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim });
// 2) Si no hay nada (o force), buscamos en Woo y cacheamos
const needWooSearch = forceWoo || cached.length === 0;
// 2) Si no hay suficiente (o force), buscamos en Woo y cacheamos
// Nota: si el cache tiene 3 items pero pedimos 12, igual necesitamos ir a Woo para no “recortar” catálogo.
const needWooSearch = forceWoo || cached.length < lim;
const client = await getWooClient({ tenantId });
let wooItems = [];
@@ -228,6 +236,7 @@ export async function searchProducts({
price: p.price,
currency: p.currency,
type: p.type,
categories: p.categories,
attributes: p.attributes,
raw_price: p.raw_price,
source: "woo",
@@ -240,6 +249,13 @@ export async function searchProducts({
currency: c.currency,
refreshed_at: c.refreshed_at,
type: c?.payload?.type || null,
categories: Array.isArray(c?.payload?.categories)
? c.payload.categories.map((cat) => ({
id: cat?.id ?? null,
name: cat?.name ?? null,
slug: cat?.slug ?? null,
}))
: [],
attributes: Array.isArray(c?.payload?.attributes)
? c.payload.attributes.map((a) => ({
name: a?.name || null,