Files
botino/src/modules/3-turn-engine/nlu/specialists/orders.js
2026-01-25 20:51:33 -03:00

163 lines
4.6 KiB
JavaScript

/**
* Orders Specialist - Extracción de productos y cantidades
*
* El specialist más importante: maneja add_to_cart, remove_from_cart,
* view_cart, confirm_order con soporte para multi-items.
*/
import OpenAI from "openai";
import { loadPrompt } from "../promptLoader.js";
import { validateOrders, getValidationErrors, createEmptyNlu } from "../schemas.js";
let _client = null;
function getClient() {
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY is not set");
}
if (!_client) {
_client = new OpenAI({ apiKey });
}
return _client;
}
function extractJson(text) {
const s = String(text || "");
const i = s.indexOf("{");
const j = s.lastIndexOf("}");
if (i >= 0 && j > i) {
try {
return JSON.parse(s.slice(i, j + 1));
} catch {
return null;
}
}
return null;
}
/**
* Normaliza unidades a formato estándar
*/
function normalizeUnit(unit) {
if (!unit) return null;
const u = String(unit).toLowerCase().trim();
if (["kg", "kilo", "kilos", "kilogramo", "kilogramos"].includes(u)) return "kg";
if (["g", "gr", "gramo", "gramos"].includes(u)) return "g";
if (["unidad", "unidades", "u", "un"].includes(u)) return "unidad";
return null;
}
/**
* Normaliza items extraídos
*/
function normalizeItems(items) {
if (!Array.isArray(items) || items.length === 0) return null;
return items
.filter(item => item && item.product_query)
.map(item => ({
product_query: String(item.product_query || "").trim(),
quantity: typeof item.quantity === "number" ? item.quantity : null,
unit: normalizeUnit(item.unit),
}))
.filter(item => item.product_query.length > 0);
}
/**
* Procesa un mensaje de pedido
*
* @param {Object} params
* @param {number} params.tenantId - ID del tenant
* @param {string} params.text - Mensaje del usuario
* @param {Object} params.storeConfig - Config de la tienda
* @returns {Object} NLU unificado
*/
export async function ordersNlu({ tenantId, text, storeConfig = {} }) {
const openai = getClient();
// Cargar prompt de orders
const { content: systemPrompt, model } = await loadPrompt({
tenantId,
promptKey: "orders",
variables: storeConfig,
});
// Hacer la llamada al LLM
const response = await openai.chat.completions.create({
model: model || "gpt-4-turbo",
temperature: 0.1, // Baja temperatura para extracción precisa
max_tokens: 500,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: text },
],
});
const rawText = response?.choices?.[0]?.message?.content || "";
let parsed = extractJson(rawText);
// Intentar validar
let validationOk = false;
if (parsed && validateOrders(parsed)) {
validationOk = true;
} else if (parsed) {
// Intentar normalizar respuesta parcialmente válida
parsed = {
intent: parsed.intent || "add_to_cart",
confidence: parsed.confidence || 0.8,
items: parsed.items || null,
product_query: parsed.product_query || null,
quantity: parsed.quantity || null,
unit: parsed.unit || null,
};
validationOk = true;
} else {
// Fallback total
parsed = {
intent: "add_to_cart",
confidence: 0.5,
items: null,
product_query: text.length < 50 ? text : null,
quantity: null,
unit: null,
};
}
// Normalizar items - SIEMPRE convertir a array
let normalizedItems = normalizeItems(parsed.items);
// Si no hay items pero hay product_query en raíz, convertir a array
if ((!normalizedItems || normalizedItems.length === 0) && parsed.product_query) {
normalizedItems = [{
product_query: String(parsed.product_query).trim(),
quantity: typeof parsed.quantity === "number" ? parsed.quantity : null,
unit: normalizeUnit(parsed.unit),
}];
}
// Convertir a formato NLU unificado
const nlu = createEmptyNlu();
nlu.intent = parsed.intent || "add_to_cart";
nlu.confidence = parsed.confidence || 0.8;
// Entities - siempre usar items[], nunca campos individuales
nlu.entities.items = normalizedItems || [];
nlu.entities.product_query = null; // Deprecado, usar items[]
nlu.entities.quantity = null;
nlu.entities.unit = null;
// Needs
nlu.needs.catalog_lookup = ["add_to_cart", "remove_from_cart"].includes(nlu.intent);
nlu.needs.knowledge_lookup = false;
return {
nlu,
raw_text: rawText,
model,
usage: response?.usage || null,
validation: { ok: validationOk, errors: validationOk ? [] : getValidationErrors(validateOrders) },
};
}