176 lines
4.7 KiB
JavaScript
176 lines
4.7 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|