163 lines
4.6 KiB
JavaScript
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) },
|
|
};
|
|
}
|