separated in modules
This commit is contained in:
210
src/modules/3-turn-engine/catalogRetrieval.js
Normal file
210
src/modules/3-turn-engine/catalogRetrieval.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import crypto from "crypto";
|
||||
import OpenAI from "openai";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
import { searchSnapshotItems } from "../shared/wooSnapshot.js";
|
||||
import {
|
||||
searchProductAliases,
|
||||
getProductEmbedding,
|
||||
upsertProductEmbedding,
|
||||
} from "../2-identity/db/repo.js";
|
||||
|
||||
function getOpenAiKey() {
|
||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||
}
|
||||
|
||||
function getEmbeddingsModel() {
|
||||
return process.env.OPENAI_EMBEDDINGS_MODEL || "text-embedding-3-small";
|
||||
}
|
||||
|
||||
function normalizeText(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[¿?¡!.,;:()"]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hashText(s) {
|
||||
return crypto.createHash("sha256").update(String(s || "")).digest("hex");
|
||||
}
|
||||
|
||||
function cosine(a, b) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) return 0;
|
||||
let dot = 0;
|
||||
let na = 0;
|
||||
let nb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = Number(a[i]) || 0;
|
||||
const y = Number(b[i]) || 0;
|
||||
dot += x * y;
|
||||
na += x * x;
|
||||
nb += y * y;
|
||||
}
|
||||
if (na === 0 || nb === 0) return 0;
|
||||
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
||||
}
|
||||
|
||||
function candidateText(c) {
|
||||
const parts = [c?.name || ""];
|
||||
if (Array.isArray(c?.categories)) {
|
||||
for (const cat of c.categories) {
|
||||
if (cat?.name) parts.push(cat.name);
|
||||
if (cat?.slug) parts.push(cat.slug);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(c?.attributes)) {
|
||||
for (const a of c.attributes) {
|
||||
if (a?.name) parts.push(a.name);
|
||||
if (Array.isArray(a?.options)) parts.push(a.options.join(" "));
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function literalScore(query, candidate) {
|
||||
const q = normalizeText(query);
|
||||
const n = normalizeText(candidate?.name || "");
|
||||
if (!q || !n) return 0;
|
||||
if (n === q) return 1.0;
|
||||
if (n.includes(q)) return 0.7;
|
||||
const qt = new Set(q.split(" ").filter(Boolean));
|
||||
const nt = new Set(n.split(" ").filter(Boolean));
|
||||
let hits = 0;
|
||||
for (const w of qt) if (nt.has(w)) hits++;
|
||||
return hits / Math.max(qt.size, 1);
|
||||
}
|
||||
|
||||
async function embedText({ tenantId, text }) {
|
||||
const key = getOpenAiKey();
|
||||
if (!key) return { embedding: null, cached: false, model: null, error: "OPENAI_NO_KEY" };
|
||||
|
||||
const content = normalizeText(text);
|
||||
const contentHash = hashText(content);
|
||||
const cached = await getProductEmbedding({ tenant_id: tenantId, content_hash: contentHash });
|
||||
if (cached?.embedding) {
|
||||
return { embedding: cached.embedding, cached: true, model: cached.model || null };
|
||||
}
|
||||
|
||||
const client = new OpenAI({ apiKey: key });
|
||||
const model = getEmbeddingsModel();
|
||||
const resp = await client.embeddings.create({
|
||||
model,
|
||||
input: content,
|
||||
});
|
||||
const vector = resp?.data?.[0]?.embedding || null;
|
||||
if (Array.isArray(vector)) {
|
||||
await upsertProductEmbedding({
|
||||
tenant_id: tenantId,
|
||||
content_hash: contentHash,
|
||||
content_text: content,
|
||||
embedding: vector,
|
||||
model,
|
||||
});
|
||||
}
|
||||
return { embedding: vector, cached: false, model };
|
||||
}
|
||||
|
||||
function mergeCandidates(list) {
|
||||
const map = new Map();
|
||||
for (const c of list) {
|
||||
if (!c?.woo_product_id) continue;
|
||||
const id = Number(c.woo_product_id);
|
||||
if (!map.has(id)) {
|
||||
map.set(id, { ...c });
|
||||
} else {
|
||||
const prev = map.get(id);
|
||||
map.set(id, { ...prev, ...c, _score: Math.max(prev._score || 0, c._score || 0) });
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveCandidates: combina Woo literal + alias + embeddings.
|
||||
*/
|
||||
export async function retrieveCandidates({
|
||||
tenantId,
|
||||
query,
|
||||
attributes = [],
|
||||
preparation = [],
|
||||
limit = 12,
|
||||
}) {
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12));
|
||||
const q = String(query || "").trim();
|
||||
if (!q) {
|
||||
return { candidates: [], audit: { reason: "empty_query" } };
|
||||
}
|
||||
|
||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||
|
||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
||||
const aliasBoostByProduct = new Map();
|
||||
for (const a of aliases) {
|
||||
if (a?.woo_product_id) {
|
||||
const id = Number(a.woo_product_id);
|
||||
const boost = Number(a.boost || 0);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
||||
}
|
||||
}
|
||||
audit.sources.aliases = aliases.length;
|
||||
|
||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
limit: lim,
|
||||
});
|
||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||
|
||||
let candidates = (wooItems || []).map((c) => {
|
||||
const lit = literalScore(q, c);
|
||||
const boost = aliasBoostByProduct.get(Number(c.woo_product_id)) || 0;
|
||||
return { ...c, _score: lit + boost, _score_detail: { literal: lit, alias_boost: boost } };
|
||||
});
|
||||
|
||||
// embeddings: opcional, si hay key y tenemos candidatos
|
||||
if (candidates.length) {
|
||||
try {
|
||||
const queryEmb = await embedText({ tenantId, text: q });
|
||||
if (Array.isArray(queryEmb.embedding)) {
|
||||
audit.embeddings.query = { cached: queryEmb.cached, model: queryEmb.model };
|
||||
const enriched = [];
|
||||
for (const c of candidates.slice(0, 25)) {
|
||||
const text = candidateText(c);
|
||||
const emb = await embedText({ tenantId, text });
|
||||
const cos = Array.isArray(emb.embedding) ? cosine(queryEmb.embedding, emb.embedding) : 0;
|
||||
const prev = c._score || 0;
|
||||
enriched.push({
|
||||
...c,
|
||||
_score: prev + Math.max(0, cos),
|
||||
_score_detail: { ...(c._score_detail || {}), cosine: cos, emb_cached: emb.cached },
|
||||
});
|
||||
}
|
||||
// merge con el resto sin embeddings
|
||||
const tail = candidates.slice(25);
|
||||
candidates = mergeCandidates([...enriched, ...tail]);
|
||||
} else {
|
||||
audit.embeddings.query = { error: queryEmb.error || "no_embedding" };
|
||||
}
|
||||
} catch (e) {
|
||||
audit.embeddings.error = String(e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => (b._score || 0) - (a._score || 0));
|
||||
const finalList = candidates.slice(0, lim);
|
||||
|
||||
if (dbg.resolve) {
|
||||
console.log("[catalogRetrieval] candidates", {
|
||||
query: q,
|
||||
top: finalList.slice(0, 5).map((c) => ({
|
||||
id: c.woo_product_id,
|
||||
name: c.name,
|
||||
score: c._score,
|
||||
detail: c._score_detail,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { candidates: finalList, audit };
|
||||
}
|
||||
|
||||
153
src/modules/3-turn-engine/fsm.js
Normal file
153
src/modules/3-turn-engine/fsm.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* FSM autoritativa (server-side) para el flujo conversacional.
|
||||
*
|
||||
* Principios:
|
||||
* - El LLM NO decide estados. Solo NLU.
|
||||
* - El backend deriva el estado objetivo a partir del contexto + acciones.
|
||||
* - Validamos transiciones y, si algo queda inconsistente, caemos a ERROR_RECOVERY.
|
||||
*/
|
||||
|
||||
export const ConversationState = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
BROWSING: "BROWSING",
|
||||
AWAITING_QUANTITY: "AWAITING_QUANTITY",
|
||||
CART_ACTIVE: "CART_ACTIVE",
|
||||
AWAITING_ADDRESS: "AWAITING_ADDRESS",
|
||||
AWAITING_PAYMENT: "AWAITING_PAYMENT",
|
||||
COMPLETED: "COMPLETED",
|
||||
ERROR_RECOVERY: "ERROR_RECOVERY",
|
||||
});
|
||||
|
||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||
|
||||
function hasBasketItems(ctx) {
|
||||
const items = ctx?.basket?.items || ctx?.order_basket?.items;
|
||||
return Array.isArray(items) && items.length > 0;
|
||||
}
|
||||
|
||||
function hasPendingClarification(ctx) {
|
||||
const pc = ctx?.pending_clarification;
|
||||
return Boolean(pc?.candidates?.length) || Boolean(pc?.options?.length);
|
||||
}
|
||||
|
||||
function hasPendingItem(ctx) {
|
||||
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
|
||||
}
|
||||
|
||||
function hasAddress(ctx) {
|
||||
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
||||
}
|
||||
|
||||
function hasWooOrder(ctx) {
|
||||
return Boolean(ctx?.woo_order_id || ctx?.last_order_id);
|
||||
}
|
||||
|
||||
function hasPaymentLink(ctx) {
|
||||
return Boolean(ctx?.mp?.init_point || ctx?.payment?.init_point || ctx?.payment_link);
|
||||
}
|
||||
|
||||
function isPaid(ctx) {
|
||||
const st =
|
||||
ctx?.mp?.payment_status ||
|
||||
ctx?.payment?.status ||
|
||||
ctx?.payment_status ||
|
||||
null;
|
||||
return st === "approved" || st === "paid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva el estado objetivo según el contexto actual y señales del turno.
|
||||
* `signals` es información determinística del motor del turno (no del LLM),
|
||||
* por ejemplo: { requested_checkout: true }.
|
||||
*/
|
||||
export function deriveNextState(prevState, ctx = {}, signals = {}) {
|
||||
// Regla 1: pago confirmado gana siempre
|
||||
if (isPaid(ctx)) return ConversationState.COMPLETED;
|
||||
|
||||
// Regla 2: si ya existe orden + link de pago, estamos esperando pago
|
||||
if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT;
|
||||
|
||||
// Regla 3: si intentó checkout pero falta dirección
|
||||
if ((signals.requested_checkout || signals.requested_address) && hasBasketItems(ctx) && !hasAddress(ctx)) {
|
||||
return ConversationState.AWAITING_ADDRESS;
|
||||
}
|
||||
|
||||
// Regla 4: si hay item pendiente sin completar cantidad
|
||||
if (hasPendingItem(ctx) && !signals.pending_item_completed) {
|
||||
return ConversationState.AWAITING_QUANTITY;
|
||||
}
|
||||
|
||||
// Regla 5: si hay carrito activo
|
||||
if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE;
|
||||
|
||||
// Regla 6: si estamos mostrando opciones / esperando selección
|
||||
if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) {
|
||||
return ConversationState.BROWSING;
|
||||
}
|
||||
|
||||
return ConversationState.IDLE;
|
||||
}
|
||||
|
||||
const ALLOWED = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
ConversationState.IDLE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.BROWSING]: [
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_QUANTITY]: [
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.CART_ACTIVE]: [
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
ConversationState.BROWSING,
|
||||
],
|
||||
[ConversationState.AWAITING_ADDRESS]: [
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_PAYMENT]: [
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.COMPLETED,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.COMPLETED]: [
|
||||
ConversationState.COMPLETED,
|
||||
ConversationState.IDLE, // nueva conversación / reinicio natural
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.ERROR_RECOVERY]: ALL_STATES,
|
||||
});
|
||||
|
||||
export function validateTransition(prevState, nextState) {
|
||||
const p = prevState || ConversationState.IDLE;
|
||||
const n = nextState || ConversationState.IDLE;
|
||||
if (!ALLOWED[p]) return { ok: false, reason: "unknown_prev_state", prev: p, next: n };
|
||||
if (!ALL_STATES.includes(n)) return { ok: false, reason: "unknown_next_state", prev: p, next: n };
|
||||
const ok = ALLOWED[p].includes(n);
|
||||
return ok ? { ok: true } : { ok: false, reason: "invalid_transition", prev: p, next: n };
|
||||
}
|
||||
|
||||
export function safeNextState(prevState, ctx, signals) {
|
||||
const desired = deriveNextState(prevState, ctx, signals);
|
||||
const v = validateTransition(prevState, desired);
|
||||
if (v.ok) return { next_state: desired, validation: v };
|
||||
return { next_state: ConversationState.ERROR_RECOVERY, validation: v };
|
||||
}
|
||||
|
||||
203
src/modules/3-turn-engine/openai.js
Normal file
203
src/modules/3-turn-engine/openai.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import OpenAI from "openai";
|
||||
import Ajv from "ajv";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
|
||||
let _client = null;
|
||||
let _clientKey = null;
|
||||
|
||||
function getApiKey() {
|
||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
const err = new Error("OPENAI_API_KEY is not set");
|
||||
err.code = "OPENAI_NO_KEY";
|
||||
throw err;
|
||||
}
|
||||
if (_client && _clientKey === apiKey) return _client;
|
||||
_clientKey = apiKey;
|
||||
_client = new OpenAI({ apiKey });
|
||||
return _client;
|
||||
}
|
||||
|
||||
function extractJsonObject(text) {
|
||||
const s = String(text || "");
|
||||
const i = s.indexOf("{");
|
||||
const j = s.lastIndexOf("}");
|
||||
if (i >= 0 && j > i) return s.slice(i, j + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function jsonCompletion({ system, user, model }) {
|
||||
const openai = getClient();
|
||||
const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
const debug = dbg.llm;
|
||||
if (debug) console.log("[llm] openai.request", { model: chosenModel });
|
||||
|
||||
const resp = await openai.chat.completions.create({
|
||||
model: chosenModel,
|
||||
temperature: 0.2,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
});
|
||||
|
||||
if (debug)
|
||||
console.log("[llm] openai.response", {
|
||||
id: resp?.id || null,
|
||||
model: resp?.model || null,
|
||||
usage: resp?.usage || null,
|
||||
});
|
||||
|
||||
const text = resp?.choices?.[0]?.message?.content || "";
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
const extracted = extractJsonObject(text);
|
||||
if (!extracted) throw new Error("openai_invalid_json");
|
||||
parsed = JSON.parse(extracted);
|
||||
}
|
||||
return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || null };
|
||||
}
|
||||
|
||||
// --- NLU v3 (single-step, schema-strict) ---
|
||||
|
||||
const NluV3JsonSchema = {
|
||||
$id: "NluV3",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent", "confidence", "language", "entities", "needs"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "other"],
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
language: { type: "string" },
|
||||
entities: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation"],
|
||||
properties: {
|
||||
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
selection: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["type", "value"],
|
||||
properties: {
|
||||
type: { type: "string", enum: ["index", "text", "sku"] },
|
||||
value: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attributes: { type: "array", items: { type: "string" } },
|
||||
preparation: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
needs: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["catalog_lookup", "knowledge_lookup"],
|
||||
properties: {
|
||||
catalog_lookup: { type: "boolean" },
|
||||
knowledge_lookup: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
const validateNluV3 = ajv.compile(NluV3JsonSchema);
|
||||
|
||||
function nluV3Fallback() {
|
||||
return {
|
||||
intent: "other",
|
||||
confidence: 0,
|
||||
language: "es-AR",
|
||||
entities: {
|
||||
product_query: null,
|
||||
quantity: null,
|
||||
unit: null,
|
||||
selection: null,
|
||||
attributes: [],
|
||||
preparation: [],
|
||||
},
|
||||
needs: { catalog_lookup: false, knowledge_lookup: false },
|
||||
};
|
||||
}
|
||||
|
||||
function nluV3Errors() {
|
||||
const errs = validateNluV3.errors || [];
|
||||
return errs.map((e) => ({
|
||||
instancePath: e.instancePath,
|
||||
schemaPath: e.schemaPath,
|
||||
keyword: e.keyword,
|
||||
message: e.message,
|
||||
params: e.params,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function llmNluV3({ input, model } = {}) {
|
||||
const systemBase =
|
||||
"Sos un servicio NLU (es-AR). Extraés intención y entidades del mensaje del usuario.\n" +
|
||||
"IMPORTANTE:\n" +
|
||||
"- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" +
|
||||
"- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" +
|
||||
"- Si hay opciones mostradas y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" +
|
||||
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
|
||||
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n";
|
||||
|
||||
const user = JSON.stringify(input ?? {});
|
||||
|
||||
// intento 1
|
||||
const first = await jsonCompletion({ system: systemBase, user, model });
|
||||
if (validateNluV3(first.parsed)) {
|
||||
return { nlu: first.parsed, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } };
|
||||
}
|
||||
|
||||
const errors1 = nluV3Errors();
|
||||
|
||||
// retry 1 vez
|
||||
const systemRetry =
|
||||
systemBase +
|
||||
"\nTu respuesta anterior no validó el JSON Schema. Corregí el JSON para que cumpla estrictamente.\n" +
|
||||
`Errores: ${JSON.stringify(errors1).slice(0, 1800)}\n`;
|
||||
|
||||
try {
|
||||
const second = await jsonCompletion({ system: systemRetry, user, model });
|
||||
if (validateNluV3(second.parsed)) {
|
||||
return { nlu: second.parsed, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } };
|
||||
}
|
||||
const errors2 = nluV3Errors();
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: second.raw_text,
|
||||
model: second.model,
|
||||
usage: second.usage,
|
||||
schema: "v3",
|
||||
validation: { ok: false, retried: true, errors: errors2 },
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: first.raw_text,
|
||||
model: first.model,
|
||||
usage: first.usage,
|
||||
schema: "v3",
|
||||
validation: { ok: false, retried: true, error: String(e?.message || e), errors: errors1 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy llmPlan/llmExtract y NLU v2 removidos.
|
||||
585
src/modules/3-turn-engine/turnEngineV3.js
Normal file
585
src/modules/3-turn-engine/turnEngineV3.js
Normal 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 } },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user