travel to another computer
This commit is contained in:
@@ -17,6 +17,12 @@
|
|||||||
- **`OPENAI_API_KEY`** (o `OPENAI_APIKEY`): API key.
|
- **`OPENAI_API_KEY`** (o `OPENAI_APIKEY`): API key.
|
||||||
- **`OPENAI_MODEL`**: modelo (default `gpt-4o-mini`).
|
- **`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)
|
### WooCommerce (solo fallback si falta config por tenant)
|
||||||
|
|
||||||
- **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`).
|
- **`WOO_CONSUMER_KEY`**: consumer key (se usa solo si en `tenant_ecommerce_config` falta `consumer_key`).
|
||||||
|
|||||||
17
docs/llm_regionalismos.md
Normal file
17
docs/llm_regionalismos.md
Normal 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
100
scripts/scan-unused.mjs
Normal 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));
|
||||||
|
|
||||||
@@ -171,3 +171,93 @@ export async function llmExtract({ input, model } = {}) {
|
|||||||
const extracted = ExtractSchema.parse(parsed);
|
const extracted = ExtractSchema.parse(parsed);
|
||||||
return { extracted, raw_text, model: chosenModel, usage };
|
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 };
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
|||||||
import { llmExtract, llmPlan } from "./openai.js";
|
import { llmExtract, llmPlan } from "./openai.js";
|
||||||
import { searchProducts } from "./wooProducts.js";
|
import { searchProducts } from "./wooProducts.js";
|
||||||
import { debug as dbg } from "./debug.js";
|
import { debug as dbg } from "./debug.js";
|
||||||
|
import { runTurnV2 } from "./turnEngineV2.js";
|
||||||
|
|
||||||
|
|
||||||
function nowIso() {
|
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 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.
|
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)
|
basket_resolved (MANDATORY)
|
||||||
|
|
||||||
@@ -86,6 +97,8 @@ Before returning, double-check: did I include all required keys? Did I move the
|
|||||||
|
|
||||||
function isPriceQuestion(text) {
|
function isPriceQuestion(text) {
|
||||||
const t = String(text || "").toLowerCase();
|
const t = String(text || "").toLowerCase();
|
||||||
|
// Si el usuario está pidiendo "opciones" / "variedades", priorizamos browse y NO price.
|
||||||
|
if (isBrowseQuestion(t)) return false;
|
||||||
return (
|
return (
|
||||||
t.includes("precio") ||
|
t.includes("precio") ||
|
||||||
t.includes("cuánto") ||
|
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) {
|
function extractProductQuery(text) {
|
||||||
// Heurística simple: remover palabras comunes de pregunta de precio y unidades.
|
// Heurística simple: remover palabras comunes de pregunta de precio y unidades.
|
||||||
// Esto se puede reemplazar por un extractor LLM más adelante.
|
// Esto se puede reemplazar por un extractor LLM más adelante.
|
||||||
@@ -109,10 +154,28 @@ function extractProductQuery(text) {
|
|||||||
|
|
||||||
const stop = new Set([
|
const stop = new Set([
|
||||||
"precio",
|
"precio",
|
||||||
|
"precios",
|
||||||
"cuanto",
|
"cuanto",
|
||||||
"cuánto",
|
"cuánto",
|
||||||
"sale",
|
"sale",
|
||||||
"vale",
|
"vale",
|
||||||
|
"quiero",
|
||||||
|
"saber",
|
||||||
|
"decime",
|
||||||
|
"decir",
|
||||||
|
"dime",
|
||||||
|
"dame",
|
||||||
|
"pasame",
|
||||||
|
"mandame",
|
||||||
|
"tenes",
|
||||||
|
"tenés",
|
||||||
|
"vendes",
|
||||||
|
"hay",
|
||||||
|
"opciones",
|
||||||
|
"variedades",
|
||||||
|
"variedad",
|
||||||
|
"tipos",
|
||||||
|
"tipo",
|
||||||
"el",
|
"el",
|
||||||
"la",
|
"la",
|
||||||
"los",
|
"los",
|
||||||
@@ -176,6 +239,59 @@ function formatARS(n) {
|
|||||||
return x.toLocaleString("es-AR", { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
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() {
|
function makePerf() {
|
||||||
const started_at = Date.now();
|
const started_at = Date.now();
|
||||||
const perf = { t0: started_at, marks: {} };
|
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) {
|
function isGreeting(text) {
|
||||||
const t = String(text || "").trim().toLowerCase();
|
const t = String(text || "").trim().toLowerCase();
|
||||||
if (!t) return false;
|
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 prev = prev_context && typeof prev_context === "object" ? prev_context : {};
|
||||||
const hasPending = Boolean(prev.pending_clarification?.candidates?.length);
|
const hasPending = Boolean(prev.pending_clarification?.candidates?.length);
|
||||||
const hasBasket = Array.isArray(prev.order_basket?.items) && prev.order_basket.items.length > 0;
|
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)) {
|
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 };
|
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)) {
|
if (isOrderish(text)) {
|
||||||
return { kind: "order", intent_hint: "create_order", needs_extract: true, needs_products: true };
|
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)) {
|
if (isPriceQuestion(text)) {
|
||||||
return { kind: "price", intent_hint: "ask_price", needs_extract: true, needs_products: true };
|
return { kind: "price", intent_hint: "ask_price", needs_extract: true, needs_products: true };
|
||||||
}
|
}
|
||||||
@@ -255,6 +419,12 @@ function tokenize(s) {
|
|||||||
|
|
||||||
function candidateText(c) {
|
function candidateText(c) {
|
||||||
const parts = [c?.name || ""];
|
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)) {
|
if (Array.isArray(c?.attributes)) {
|
||||||
for (const a of c.attributes) {
|
for (const a of c.attributes) {
|
||||||
if (a?.name) parts.push(a.name);
|
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 }) {
|
function resolveAmbiguity({ userText, candidates }) {
|
||||||
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
|
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
|
||||||
if (cands.length <= 1) {
|
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 })) } };
|
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 full = scored.map((x) => x.c).slice(0, 60);
|
||||||
const options = scored.slice(0, 3).map((x, i) => {
|
const { question, pending } = buildPagedOptions({ candidates: full, candidateOffset: 0, baseIdx: 1, pageSize: 9 });
|
||||||
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}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "ask",
|
kind: "ask",
|
||||||
question,
|
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 })) },
|
debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionSelection(userText) {
|
function parseOptionSelection(userText) {
|
||||||
const t = String(userText || "").toLowerCase();
|
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 (m) return parseInt(m[1], 10);
|
||||||
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
|
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
|
||||||
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
|
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
|
||||||
if (/\btercera\b|\btercero\b/.test(t)) return 3;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +644,8 @@ async function extractProducts({
|
|||||||
|
|
||||||
for (const it of extracted.items) {
|
for (const it of extracted.items) {
|
||||||
try {
|
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) {
|
if (!items?.length) {
|
||||||
unresolved.push({ ...it, reason: "not_found" });
|
unresolved.push({ ...it, reason: "not_found" });
|
||||||
continue;
|
continue;
|
||||||
@@ -454,8 +672,13 @@ async function extractProducts({
|
|||||||
...it,
|
...it,
|
||||||
reason: "ambiguous",
|
reason: "ambiguous",
|
||||||
candidates: scored
|
candidates: scored
|
||||||
.slice(0, 3)
|
.slice(0, 25)
|
||||||
.map((x) => ({ woo_product_id: x.p.woo_product_id, name: x.p.name, price: x.p.price })),
|
.map((x) => ({
|
||||||
|
woo_product_id: x.p.woo_product_id,
|
||||||
|
name: x.p.name,
|
||||||
|
price: x.p.price,
|
||||||
|
attributes: x.p.attributes || [],
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -480,19 +703,27 @@ async function extractProducts({
|
|||||||
|
|
||||||
// products_context para precios / browse (soporta múltiples labels)
|
// products_context para precios / browse (soporta múltiples labels)
|
||||||
let products_context = null;
|
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 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");
|
mark("before_product_lookup");
|
||||||
try {
|
try {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const q of queries) {
|
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 });
|
results.push({ label: q, source, items });
|
||||||
}
|
}
|
||||||
// flatten para UI/LLM (top 1 por label primero)
|
// flatten para UI/LLM
|
||||||
const flat = results.flatMap((r) => (r.items || []).slice(0, 2).map((p) => ({ ...p, _query: r.label })));
|
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 };
|
products_context = { queries, results, items: flat };
|
||||||
llmInput.products_context = products_context;
|
llmInput.products_context = products_context;
|
||||||
logStage(stageDebug, "extractProducts.products_context", { queries, results: results.map((r) => ({ q: r.label, count: r.items?.length || 0 })) });
|
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" ||
|
classification.kind === "price" ||
|
||||||
extracted?.intent === "ask_price" ||
|
extracted?.intent === "ask_price" ||
|
||||||
Boolean(prev.awaiting_price?.labels?.length) ||
|
Boolean(prev.awaiting_price?.labels?.length) ||
|
||||||
|
Boolean(prev.pending_item) ||
|
||||||
isPriceQuestion(text);
|
isPriceQuestion(text);
|
||||||
|
|
||||||
// 0) Si hay una ambigüedad pendiente, intentamos colapsarla con la respuesta del usuario
|
// 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) {
|
if (prev.pending_clarification?.candidates?.length) {
|
||||||
// Selección explícita por número (1/2/3)
|
// Selección explícita por número (1/2/3)
|
||||||
const sel = parseOptionSelection(text);
|
const sel = parseOptionSelection(text);
|
||||||
if (sel && Array.isArray(prev.pending_clarification.options)) {
|
if (sel && Array.isArray(prev.pending_clarification.options)) {
|
||||||
const opt = prev.pending_clarification.options.find((o) => o.idx === sel);
|
const opt = prev.pending_clarification.options.find((o) => o.idx === sel);
|
||||||
if (opt) {
|
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) {
|
if (chosen) {
|
||||||
const price = chosen.price ?? null;
|
const price = chosen.price ?? null;
|
||||||
|
const unit = inferDefaultUnit({ name: chosen.name, categories: chosen.categories });
|
||||||
const decision = {
|
const decision = {
|
||||||
mode: "resolved_from_pending",
|
mode: "resolved_from_pending",
|
||||||
resolved_product: chosen,
|
resolved_product: chosen,
|
||||||
reply:
|
reply:
|
||||||
price != null
|
price != null
|
||||||
? `Perfecto. ${chosen.name} está $${formatARS(price)} el kilo. ¿Cuántos kilos querés?`
|
? `Perfecto. ${chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
|
||||||
: `Perfecto. ¿Cuántos kilos querés llevar de ${chosen.name}?`,
|
: `Perfecto. ${askQtyText(unit)} de ${chosen.name}?`,
|
||||||
next_state: "BROWSING",
|
next_state: "BROWSING",
|
||||||
intent: "ask_price",
|
intent: "ask_price",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
basket_resolved: { items: [] },
|
basket_resolved: { items: [] },
|
||||||
meta: { collapsed: true, via: "option_number" },
|
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);
|
logStage(stageDebug, "applyBusinessLogic", decision);
|
||||||
return 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 });
|
const r = resolveAmbiguity({ userText: text, candidates: prev.pending_clarification.candidates });
|
||||||
if (r.kind === "resolved" && r.chosen) {
|
if (r.kind === "resolved" && r.chosen) {
|
||||||
// si era un price inquiry, resolvemos precio directo en base a ese candidato
|
// si era un price inquiry, resolvemos precio directo en base a ese candidato
|
||||||
const price = r.chosen.price ?? null;
|
const price = r.chosen.price ?? null;
|
||||||
|
const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories });
|
||||||
const decision = {
|
const decision = {
|
||||||
mode: "resolved_from_pending",
|
mode: "resolved_from_pending",
|
||||||
resolved_product: r.chosen,
|
resolved_product: r.chosen,
|
||||||
reply: price != null
|
reply: price != null
|
||||||
? `Perfecto. ${r.chosen.name} está $${formatARS(price)} el kilo. ¿Cuántos kilos querés?`
|
? `Perfecto. ${r.chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
|
||||||
: `Perfecto. ¿Cuántos kilos querés llevar de ${r.chosen.name}?`,
|
: `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`,
|
||||||
next_state: "BROWSING",
|
next_state: "BROWSING",
|
||||||
intent: "ask_price",
|
intent: "ask_price",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
basket_resolved: { items: [] },
|
basket_resolved: { items: [] },
|
||||||
meta: { collapsed: true, debug: r.debug || null },
|
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);
|
logStage(stageDebug, "applyBusinessLogic", decision);
|
||||||
return decision;
|
return decision;
|
||||||
@@ -592,13 +901,85 @@ function applyBusinessLogic({
|
|||||||
order_action: "none",
|
order_action: "none",
|
||||||
basket_resolved: resolvedBasket || { items: [] },
|
basket_resolved: resolvedBasket || { items: [] },
|
||||||
meta: { skipped: "pending_still_ambiguous", debug: r.debug || null },
|
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);
|
logStage(stageDebug, "applyBusinessLogic", decision);
|
||||||
return 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)
|
// 1) Ambiguo / sin resolver => 1 pregunta (pero guardando candidatos para colapsar luego)
|
||||||
if (unresolved?.length) {
|
if (unresolved?.length) {
|
||||||
const first = unresolved[0];
|
const first = unresolved[0];
|
||||||
@@ -609,19 +990,31 @@ function applyBusinessLogic({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (r?.kind === "resolved" && r.chosen) {
|
if (r?.kind === "resolved" && r.chosen) {
|
||||||
|
const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories });
|
||||||
const decision = {
|
const decision = {
|
||||||
mode: "resolved_from_pending",
|
mode: "resolved_from_pending",
|
||||||
resolved_product: r.chosen,
|
resolved_product: r.chosen,
|
||||||
reply: r.chosen.price != null
|
reply: r.chosen.price != null
|
||||||
? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} el kilo. ¿Cuántos kilos querés?`
|
? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} ${pricePerText(unit)}. ${askQtyText(unit)}`
|
||||||
: `Perfecto. ¿Cuántos kilos querés llevar de ${r.chosen.name}?`,
|
: `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`,
|
||||||
next_state: "BROWSING",
|
next_state: "BROWSING",
|
||||||
intent: "ask_price",
|
intent: "ask_price",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
basket_resolved: { items: [] },
|
basket_resolved: { items: [] },
|
||||||
meta: { collapsed: true, debug: r.debug || null },
|
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);
|
logStage(stageDebug, "applyBusinessLogic", decision);
|
||||||
return decision;
|
return decision;
|
||||||
@@ -668,30 +1061,71 @@ function applyBusinessLogic({
|
|||||||
return decision;
|
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)) {
|
if (isPriceFlow && products_context && Array.isArray(products_context.items)) {
|
||||||
// Preferimos mostrar 1–2 precios por “label” consultado (si vienen múltiples queries)
|
const uniq = new Map();
|
||||||
const priced = products_context.items
|
for (const p of products_context.items) {
|
||||||
.filter((p) => p && p.name && p.price != null)
|
if (!p?.woo_product_id || !p?.name) continue;
|
||||||
.slice(0, 6);
|
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) {
|
// Si hay un único candidato, respondemos el precio directo (y guardamos pending_item para cantidad)
|
||||||
const lines = priced
|
if (candidates.length === 1) {
|
||||||
.map((p) => {
|
const c = candidates[0];
|
||||||
const prefix = p._query ? `${p._query}: ` : "";
|
const price = c.price ?? null;
|
||||||
return `- ${prefix}${p.name}: $${formatARS(p.price)}`;
|
const unit = inferDefaultUnit({ name: c.name, categories: c.categories });
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
const decision = {
|
const decision = {
|
||||||
mode: "server_price",
|
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",
|
next_state: "BROWSING",
|
||||||
intent: "ask_price",
|
intent: "ask_price",
|
||||||
missing_fields: [],
|
missing_fields: [],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
basket_resolved: { items: [] },
|
basket_resolved: { items: [] },
|
||||||
meta: { skipped: "price_server_side", source: products_context.source || null },
|
meta: { server: "price_single_candidate" },
|
||||||
context_patch: { pending_clarification: null, awaiting_price: null },
|
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);
|
logStage(stageDebug, "applyBusinessLogic", decision);
|
||||||
return decision;
|
return decision;
|
||||||
@@ -923,7 +1357,8 @@ export async function processMessage({
|
|||||||
mark("after_insertMessage_in");
|
mark("after_insertMessage_in");
|
||||||
|
|
||||||
mark("before_classifyIntent");
|
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");
|
mark("after_classifyIntent");
|
||||||
logStage(stageDebug, "classifyIntent", classification);
|
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 });
|
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”
|
// Reducer de contexto: consolidar slots y evitar “olvidos”
|
||||||
mark("before_reduceContext");
|
mark("before_reduceContext");
|
||||||
const resolveDebug = dbg.resolve;
|
const resolveDebug = dbg.resolve;
|
||||||
const { extracted, resolvedBasket, unresolved, products_context } = await extractProducts({
|
const { extracted, resolvedBasket: rb, unresolved, products_context } = await extractProducts({
|
||||||
tenantId,
|
tenantId,
|
||||||
text,
|
text,
|
||||||
llmInput,
|
llmInput,
|
||||||
@@ -950,7 +1406,8 @@ export async function processMessage({
|
|||||||
classification,
|
classification,
|
||||||
stageDebug,
|
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");
|
mark("after_reduceContext");
|
||||||
logStage(stageDebug, "reduceContext", reducedContext?.slots || {});
|
logStage(stageDebug, "reduceContext", reducedContext?.slots || {});
|
||||||
|
|
||||||
@@ -973,7 +1430,7 @@ export async function processMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
mark("before_applyBusinessLogic");
|
mark("before_applyBusinessLogic");
|
||||||
const decision = applyBusinessLogic({
|
decision = applyBusinessLogic({
|
||||||
text,
|
text,
|
||||||
classification,
|
classification,
|
||||||
extracted,
|
extracted,
|
||||||
@@ -985,7 +1442,7 @@ export async function processMessage({
|
|||||||
});
|
});
|
||||||
mark("after_applyBusinessLogic");
|
mark("after_applyBusinessLogic");
|
||||||
|
|
||||||
const { plan, llmMeta } = await composeReply({
|
const out = await composeReply({
|
||||||
text,
|
text,
|
||||||
llmInput,
|
llmInput,
|
||||||
decision,
|
decision,
|
||||||
@@ -993,6 +1450,9 @@ export async function processMessage({
|
|||||||
mark,
|
mark,
|
||||||
stageDebug,
|
stageDebug,
|
||||||
});
|
});
|
||||||
|
plan = out.plan;
|
||||||
|
llmMeta = out.llmMeta;
|
||||||
|
}
|
||||||
|
|
||||||
sanitizeIntentAndState({ plan, text, classification, prev_state });
|
sanitizeIntentAndState({ plan, text, classification, prev_state });
|
||||||
|
|
||||||
|
|||||||
415
src/services/turnEngineV2.js
Normal file
415
src/services/turnEngineV2.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,13 @@ function normalizeWooProduct(p) {
|
|||||||
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
|
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
|
||||||
currency: null,
|
currency: null,
|
||||||
type: p?.type || null, // simple | variable | grouped | external
|
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)
|
attributes: Array.isArray(p?.attributes)
|
||||||
? p.attributes.map((a) => ({
|
? p.attributes.map((a) => ({
|
||||||
name: a?.name || null,
|
name: a?.name || null,
|
||||||
@@ -126,8 +133,9 @@ export async function searchProducts({
|
|||||||
// 1) Cache en Postgres
|
// 1) Cache en Postgres
|
||||||
const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim });
|
const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim });
|
||||||
|
|
||||||
// 2) Si no hay nada (o force), buscamos en Woo y cacheamos
|
// 2) Si no hay suficiente (o force), buscamos en Woo y cacheamos
|
||||||
const needWooSearch = forceWoo || cached.length === 0;
|
// 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 });
|
const client = await getWooClient({ tenantId });
|
||||||
|
|
||||||
let wooItems = [];
|
let wooItems = [];
|
||||||
@@ -228,6 +236,7 @@ export async function searchProducts({
|
|||||||
price: p.price,
|
price: p.price,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
categories: p.categories,
|
||||||
attributes: p.attributes,
|
attributes: p.attributes,
|
||||||
raw_price: p.raw_price,
|
raw_price: p.raw_price,
|
||||||
source: "woo",
|
source: "woo",
|
||||||
@@ -240,6 +249,13 @@ export async function searchProducts({
|
|||||||
currency: c.currency,
|
currency: c.currency,
|
||||||
refreshed_at: c.refreshed_at,
|
refreshed_at: c.refreshed_at,
|
||||||
type: c?.payload?.type || null,
|
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)
|
attributes: Array.isArray(c?.payload?.attributes)
|
||||||
? c.payload.attributes.map((a) => ({
|
? c.payload.attributes.map((a) => ({
|
||||||
name: a?.name || null,
|
name: a?.name || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user