/** * Router NLU - Clasifica el dominio del mensaje * * Usa un prompt ligero para clasificar rápidamente el tipo de mensaje * antes de enviarlo al specialist correspondiente. */ import OpenAI from "openai"; import { loadPrompt } from "./promptLoader.js"; import { validateRouter, getValidationErrors } 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; } /** * Extrae JSON de una respuesta de texto */ 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; } /** * Clasifica el dominio del mensaje * * @param {Object} params * @param {number} params.tenantId - ID del tenant * @param {string} params.text - Mensaje del usuario * @param {string} params.state - Estado actual de la conversación * @param {Object} params.storeConfig - Config de la tienda (para variables) * @returns {Object} { domain: string, raw_text: string, model: string } */ export async function routerClassify({ tenantId, text, state, storeConfig = {} }) { const openai = getClient(); // Cargar prompt del router const { content: systemPrompt, model } = await loadPrompt({ tenantId, promptKey: "router", variables: { state: state || "IDLE", ...storeConfig, }, }); // Hacer la llamada al LLM const response = await openai.chat.completions.create({ model: model || "gpt-4o-mini", temperature: 0.1, max_tokens: 50, 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); // Validar respuesta if (!parsed || !validateRouter(parsed)) { // Fallback: intentar detectar por patrones simples parsed = { domain: detectDomainByPatterns(text, state) }; } return { domain: parsed.domain || "other", raw_text: rawText, model: model, usage: response?.usage || null, }; } /** * Detección de dominio por patrones (fallback) */ function detectDomainByPatterns(text, state) { const t = String(text || "").toLowerCase().trim(); // Greeting patterns (solo si no menciona productos) const greetingPatterns = /^(hola|buenas?|buen d[ií]a|buenas tardes|buenas noches|qu[eé] tal|hey|hi|holis)\s*[!?.,]*$/i; if (greetingPatterns.test(t)) { return "greeting"; } // Si el estado ya es SHIPPING o PAYMENT, priorizar esos dominios if (state === "SHIPPING") { if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) { return "shipping"; } // Si parece una dirección (tiene números y palabras) if (/\d+/.test(t) && /[a-záéíóú]{3,}/i.test(t)) { return "shipping"; } } if (state === "PAYMENT") { if (/efectivo|cash|tarjeta|link|transfer|mercadopago|mp|qr/i.test(t)) { return "payment"; } // Números simples (1 o 2) en estado PAYMENT if (/^[12]$/.test(t.trim())) { return "payment"; } } // Orders patterns const orderPatterns = [ /\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i, /\b(sacame|quitame|eliminame)\b/i, /\b(qu[eé] tengo|qu[eé] anot[eé]|mi pedido|ver carrito)\b/i, /\b(listo|eso es todo|cerrar|confirmar)\b/i, /\d+\s*(kg|kilo|gramo|g|unidad)/i, // cantidad + unidad ]; if (orderPatterns.some(p => p.test(t))) { return "orders"; } // Browse patterns const browsePatterns = [ /\b(cu[aá]nto (sale|cuesta|est[aá]))\b/i, /\b(precio de|precios)\b/i, /\b(ten[eé]s|hay|vend[eé]s|tienen)\b/i, /\b(qu[eé] me recomend[aá]s|recomendaci[oó]n)\b/i, /\bpara\s+\d+\s*(personas?|comensales?)\b/i, ]; if (browsePatterns.some(p => p.test(t))) { return "browse"; } // Shipping patterns if (/\b(delivery|env[ií]o|retiro|buscar|sucursal)\b/i.test(t)) { return "shipping"; } // Payment patterns if (/\b(efectivo|tarjeta|link|transfer|mercadopago)\b/i.test(t)) { return "payment"; } // Default basado en estado if (state === "CART") return "orders"; if (state === "SHIPPING") return "shipping"; if (state === "PAYMENT") return "payment"; return "other"; } /** * Detecta dominio solo por patrones (sin LLM) * Útil para casos obvios o cuando queremos ahorrar latencia */ export function quickDomainDetect(text, state) { return detectDomainByPatterns(text, state); }