/** * 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) }, }; }