modularizado de prompts
This commit is contained in:
175
src/modules/3-turn-engine/nlu/router.js
Normal file
175
src/modules/3-turn-engine/nlu/router.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user