separated in modules

This commit is contained in:
Lucas Tettamanti
2026-01-15 22:45:33 -03:00
parent eedd16afdb
commit ea62385e3d
41 changed files with 1116 additions and 2918 deletions

View File

@@ -0,0 +1,585 @@
import { llmNluV3 } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
import { safeNextState } from "./fsm.js";
function unitAskFor(displayUnit) {
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
if (displayUnit === "g") return "¿Cuántos gramos querés?";
return "¿Cuántos kilos querés?";
}
function unitDisplay(unit) {
if (unit === "unit") return "unidades";
if (unit === "g") return "gramos";
return "kilos";
}
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);
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
return "unit";
}
return "kg";
}
function parseIndexSelection(text) {
const t = String(text || "").toLowerCase();
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;
}
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 normalizeText(s) {
return String(s || "")
.toLowerCase()
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreTextMatch(query, candidateName) {
const qt = new Set(normalizeText(query).split(" ").filter(Boolean));
const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean));
let hits = 0;
for (const w of qt) if (nt.has(w)) hits++;
return hits / Math.max(qt.size, 1);
}
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 options = slice.map((c, i) => ({
idx: baseIdx + i,
type: "product",
woo_product_id: c.woo_product_id,
name: c.name,
}));
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}`))
.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 resolvePendingSelection({ text, nlu, pending }) {
if (!pending?.candidates?.length) return { kind: "none" };
if (isShowMoreRequest(text)) {
const { question, pending: nextPending } = buildPagedOptions({
candidates: pending.candidates,
candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)),
baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1),
pageSize: pending.page_size || 9,
});
return { kind: "more", question, pending: nextPending };
}
const idx =
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
parseIndexSelection(text);
if (idx && Array.isArray(pending.options)) {
const opt = pending.options.find((o) => o.idx === idx);
if (opt?.type === "more") return { kind: "more", question: null, pending };
if (opt?.woo_product_id) {
const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
if (chosen) return { kind: "chosen", chosen };
}
}
const selText = nlu?.entities?.selection?.type === "text"
? String(nlu.entities.selection.value || "").trim()
: null;
const q = selText || nlu?.entities?.product_query || null;
if (q) {
const scored = pending.candidates
.map((c) => ({ c, s: scoreTextMatch(q, c?.name) }))
.sort((a, b) => b.s - a.s);
if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) {
return { kind: "chosen", chosen: scored[0].c };
}
}
return { kind: "ask" };
}
function normalizeUnit(unit) {
if (!unit) return null;
const u = String(unit).toLowerCase();
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";
return null;
}
function resolveQuantity({ quantity, unit, displayUnit }) {
if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null;
const q = Number(quantity);
const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg");
if (u === "unit") return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" };
if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" };
// kg -> gramos enteros
return {
quantity: Math.round(q * 1000),
unit: "g",
display_unit: "kg",
display_quantity: q,
};
}
function buildPendingItemFromCandidate(candidate) {
const displayUnit = inferDefaultUnit({ name: candidate.name, categories: candidate.categories });
return {
product_id: Number(candidate.woo_product_id),
variation_id: null,
name: candidate.name,
price: candidate.price ?? null,
categories: candidate.categories || [],
attributes: candidate.attributes || [],
display_unit: displayUnit,
};
}
function askClarificationReply() {
return "Dale, ¿qué producto querés exactamente?";
}
function shortSummary(history) {
if (!Array.isArray(history)) return "";
return history
.slice(-5)
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
.join(" | ");
}
function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
}
export async function runTurnV3({
tenantId,
chat_id,
text,
prev_state,
prev_context,
conversation_history,
tenant_config = {},
} = {}) {
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
const actions = [];
const context_patch = {};
const audit = {};
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: shortSummary(conversation_history),
pending_context: {
pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
pending_item: prev?.pending_item?.name || null,
},
last_shown_options,
locale: tenant_config?.locale || "es-AR",
};
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
// 1) Resolver pending_clarification primero
if (prev?.pending_clarification?.candidates?.length) {
const resolved = resolvePendingSelection({ text, nlu, pending: prev.pending_clarification });
if (resolved.kind === "more") {
const nextPending = resolved.pending || prev.pending_clarification;
const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question;
context_patch.pending_clarification = nextPending;
context_patch.pending_item = null;
actions.push({ type: "show_options", payload: { count: nextPending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
plan: {
reply,
next_state,
intent: "browse",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (resolved.kind === "chosen" && resolved.chosen) {
const pendingItem = buildPendingItemFromCandidate(resolved.chosen);
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
displayUnit: pendingItem.display_unit,
});
if (qty?.quantity) {
const item = {
product_id: pendingItem.product_id,
variation_id: pendingItem.variation_id,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name,
};
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
context_patch.pending_clarification = null;
actions.push({ type: "add_to_cart", payload: item });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
: qty.display_unit === "unit"
? `${qty.display_quantity}u`
: `${qty.display_quantity}g`;
return {
plan: {
reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [item] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
context_patch.pending_item = pendingItem;
context_patch.pending_clarification = null;
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const { question, pending } = buildPagedOptions({ candidates: prev.pending_clarification.candidates });
context_patch.pending_clarification = pending;
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
plan: {
reply: question,
next_state,
intent: "browse",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// 2) Si hay pending_item, esperamos cantidad
if (prev?.pending_item?.product_id) {
const pendingItem = prev.pending_item;
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
displayUnit: pendingItem.display_unit || "kg",
});
if (qty?.quantity) {
const item = {
product_id: Number(pendingItem.product_id),
variation_id: pendingItem.variation_id ?? null,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name || "ese producto",
};
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
actions.push({ type: "add_to_cart", payload: item });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
: qty.display_unit === "unit"
? `${qty.display_quantity}u`
: `${qty.display_quantity}g`;
return {
plan: {
reply: `Perfecto, anoto ${display} de ${item.label}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [item] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// 3) Intento normal
const intent = nlu?.intent || "other";
const productQuery = String(nlu?.entities?.product_query || "").trim();
const needsCatalog = Boolean(nlu?.needs?.catalog_lookup);
if (intent === "greeting") {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: "¡Hola! ¿Qué te gustaría pedir hoy?",
next_state,
intent: "greeting",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (intent === "checkout") {
const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
if (!basketItems.length) {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: "Para avanzar necesito al menos un producto. ¿Qué querés pedir?",
next_state,
intent: "checkout",
missing_fields: ["basket_items"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (!hasAddress(prev)) {
actions.push({ type: "ask_address", payload: {} });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true });
return {
plan: {
reply: "Perfecto. ¿Me pasás la dirección de entrega?",
next_state,
intent: "checkout",
missing_fields: ["address"],
order_action: "checkout",
basket_resolved: { items: basketItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
actions.push({ type: "create_order", payload: {} });
actions.push({ type: "send_payment_link", payload: {} });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true });
return {
plan: {
reply: "Genial, ya genero el link de pago y te lo paso.",
next_state,
intent: "checkout",
missing_fields: [],
order_action: "checkout",
basket_resolved: { items: basketItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (needsCatalog && !productQuery) {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: askClarificationReply(),
next_state,
intent,
missing_fields: ["product_query"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (needsCatalog) {
const { candidates, audit: catAudit } = await retrieveCandidates({
tenantId,
query: productQuery,
attributes: nlu?.entities?.attributes || [],
preparation: nlu?.entities?.preparation || [],
limit: 12,
});
audit.catalog = catAudit;
if (!candidates.length) {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: `No encontré "${productQuery}" en el catálogo. ¿Podés decirme el nombre exacto o un corte similar?`,
next_state,
intent,
missing_fields: ["product_query"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const best = candidates[0];
const second = candidates[1];
const strong = candidates.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
if (!strong) {
const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
plan: {
reply: question,
next_state,
intent: "browse",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const pendingItem = buildPendingItemFromCandidate(best);
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
displayUnit: pendingItem.display_unit,
});
if (intent === "price_query") {
context_patch.pending_item = pendingItem;
const price = best.price != null ? `está $${best.price} ${pendingItem.display_unit === "unit" ? "por unidad" : "el kilo"}` : "no tengo el precio confirmado ahora";
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
reply: `${best.name} ${price}. ${unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg")}`,
next_state,
intent: "price_query",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (intent === "add_to_cart" && qty?.quantity) {
const item = {
product_id: pendingItem.product_id,
variation_id: pendingItem.variation_id,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name,
};
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
actions.push({ type: "add_to_cart", payload: item });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
: qty.display_unit === "unit"
? `${qty.display_quantity}u`
: `${qty.display_quantity}g`;
return {
plan: {
reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [item] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
context_patch.pending_item = pendingItem;
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Fallback seguro
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: "Dale, ¿qué necesitás exactamente?",
next_state,
intent: "other",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}