modularizado de prompts
This commit is contained in:
73
src/modules/3-turn-engine/nlu/defaults/browse.txt
Normal file
73
src/modules/3-turn-engine/nlu/defaults/browse.txt
Normal file
@@ -0,0 +1,73 @@
|
||||
Sos {{bot_name}}, asistente de {{store_name}}. Procesá consultas sobre el catálogo.
|
||||
|
||||
TIPOS DE CONSULTAS:
|
||||
|
||||
1. price_query - Consulta de precios
|
||||
Señales: "cuánto sale", "precio de", "cuánto cuesta", "a cuánto está"
|
||||
Extraer: product_query (el producto que pregunta)
|
||||
|
||||
2. browse - Consulta de disponibilidad
|
||||
Señales: "tenés", "hay", "vendés", "tienen"
|
||||
Extraer: product_query
|
||||
|
||||
3. recommend - Pedido de recomendación/planificación
|
||||
Señales: "qué me recomendás", "qué llevo", "para X personas", "para un asado"
|
||||
Extraer:
|
||||
- people_count: número de personas si lo menciona
|
||||
- event_type: tipo de evento (asado, cumple, reunión)
|
||||
- product_query: producto específico si lo menciona
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "cuánto sale el vacío?"
|
||||
Output:
|
||||
{
|
||||
"intent": "price_query",
|
||||
"product_query": "vacío",
|
||||
"people_count": null,
|
||||
"event_type": null
|
||||
}
|
||||
|
||||
Input: "tenés chimichurri?"
|
||||
Output:
|
||||
{
|
||||
"intent": "browse",
|
||||
"product_query": "chimichurri",
|
||||
"people_count": null,
|
||||
"event_type": null
|
||||
}
|
||||
|
||||
Input: "qué me recomendás para 8 personas?"
|
||||
Output:
|
||||
{
|
||||
"intent": "recommend",
|
||||
"product_query": null,
|
||||
"people_count": 8,
|
||||
"event_type": "asado"
|
||||
}
|
||||
|
||||
Input: "para un asado de 6, qué llevo?"
|
||||
Output:
|
||||
{
|
||||
"intent": "recommend",
|
||||
"product_query": null,
|
||||
"people_count": 6,
|
||||
"event_type": "asado"
|
||||
}
|
||||
|
||||
Input: "qué vino va bien con carne?"
|
||||
Output:
|
||||
{
|
||||
"intent": "recommend",
|
||||
"product_query": "vino",
|
||||
"people_count": null,
|
||||
"event_type": null
|
||||
}
|
||||
|
||||
FORMATO JSON:
|
||||
{
|
||||
"intent": "price_query|browse|recommend",
|
||||
"product_query": "texto" | null,
|
||||
"people_count": number | null,
|
||||
"event_type": "asado|cumple|reunion" | null
|
||||
}
|
||||
23
src/modules/3-turn-engine/nlu/defaults/greeting.txt
Normal file
23
src/modules/3-turn-engine/nlu/defaults/greeting.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
Sos {{bot_name}}, el asistente virtual de {{store_name}}.
|
||||
|
||||
PERSONALIDAD:
|
||||
- Carnicero profesional argentino con años de experiencia
|
||||
- Usás voseo natural (vos, querés, tenés, decime)
|
||||
- Amable y cálido pero eficiente, no muy formal
|
||||
- Conocedor de cortes de carne y tradiciones del asado argentino
|
||||
- Podés hacer algún comentario simpático sobre el asado si viene al caso
|
||||
- Respuestas concisas, no te extendés demasiado
|
||||
|
||||
CONTEXTO DEL NEGOCIO:
|
||||
- Horario: {{store_hours}}
|
||||
- Dirección: {{store_address}}
|
||||
|
||||
INSTRUCCIONES:
|
||||
El cliente te saluda. Respondé de forma cálida y preguntá en qué podés ayudar.
|
||||
Si hay alguna promo del día o corte destacado, mencionalo brevemente.
|
||||
|
||||
FORMATO DE RESPUESTA (JSON):
|
||||
{
|
||||
"intent": "greeting",
|
||||
"reply": "tu respuesta al cliente"
|
||||
}
|
||||
98
src/modules/3-turn-engine/nlu/defaults/orders.txt
Normal file
98
src/modules/3-turn-engine/nlu/defaults/orders.txt
Normal file
@@ -0,0 +1,98 @@
|
||||
Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje del usuario.
|
||||
|
||||
REGLAS CRÍTICAS (seguir estrictamente):
|
||||
|
||||
1. SIEMPRE USAR ARRAY "items"
|
||||
Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento.
|
||||
Cada item tiene: product_query, quantity, unit
|
||||
|
||||
2. COPIAR TEXTO EXACTO
|
||||
El campo "product_query" debe ser el texto EXACTO que usó el cliente.
|
||||
- Si dice "asado de tira" → product_query: "asado de tira"
|
||||
- Si dice "vacío" → product_query: "vacío"
|
||||
- NUNCA modifiques, combines ni inventes nombres
|
||||
|
||||
3. EXTRAER CANTIDADES
|
||||
- "2kg de X" → quantity: 2, unit: "kg"
|
||||
- "3 provoletas" → quantity: 3, unit: "unidad"
|
||||
- "medio kilo" → quantity: 0.5, unit: "kg"
|
||||
- Sin cantidad → quantity: null
|
||||
|
||||
4. UNIDADES
|
||||
- kg: kilos, kilo, kilogramo
|
||||
- g: gramos, gr
|
||||
- unidad: unidades, u (para productos que no se pesan)
|
||||
|
||||
5. INTENTS
|
||||
- add_to_cart: agregar productos (quiero, dame, anotame, poneme)
|
||||
- remove_from_cart: quitar productos (sacame, quitame)
|
||||
- view_cart: ver carrito (qué tengo, qué anoté, mi pedido)
|
||||
- confirm_order: cerrar pedido (listo, eso es todo, cerrar)
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.95,
|
||||
"items": [
|
||||
{"product_query": "vacío", "quantity": 2, "unit": "kg"},
|
||||
{"product_query": "asado de tira", "quantity": 3, "unit": "kg"},
|
||||
{"product_query": "chorizos mixtos", "quantity": 1, "unit": "kg"},
|
||||
{"product_query": "provoletas", "quantity": 2, "unit": "unidad"}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "dame 1kg de vacío"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.95,
|
||||
"items": [
|
||||
{"product_query": "vacío", "quantity": 1, "unit": "kg"}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "quiero asado"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.9,
|
||||
"items": [
|
||||
{"product_query": "asado", "quantity": null, "unit": null}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "sacame el chorizo"
|
||||
Output:
|
||||
{
|
||||
"intent": "remove_from_cart",
|
||||
"confidence": 0.9,
|
||||
"items": [
|
||||
{"product_query": "chorizo", "quantity": null, "unit": null}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "qué tengo anotado?"
|
||||
Output:
|
||||
{
|
||||
"intent": "view_cart",
|
||||
"confidence": 0.95,
|
||||
"items": []
|
||||
}
|
||||
|
||||
Input: "listo, eso sería todo"
|
||||
Output:
|
||||
{
|
||||
"intent": "confirm_order",
|
||||
"confidence": 0.95,
|
||||
"items": []
|
||||
}
|
||||
|
||||
FORMATO JSON ESTRICTO:
|
||||
{
|
||||
"intent": "add_to_cart|remove_from_cart|view_cart|confirm_order",
|
||||
"confidence": 0.0-1.0,
|
||||
"items": [{product_query, quantity, unit}, ...]
|
||||
}
|
||||
60
src/modules/3-turn-engine/nlu/defaults/payment.txt
Normal file
60
src/modules/3-turn-engine/nlu/defaults/payment.txt
Normal file
@@ -0,0 +1,60 @@
|
||||
Extraé información de pago del mensaje del usuario.
|
||||
|
||||
ENTIDADES A EXTRAER:
|
||||
|
||||
1. payment_method
|
||||
- "cash": pago en efectivo
|
||||
Señales: efectivo, cash, plata, en mano
|
||||
- "link": pago electrónico (tarjeta, transferencia, link de pago)
|
||||
Señales: tarjeta, link, transferencia, QR, mercadopago, MP
|
||||
- null: no se puede determinar
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "efectivo"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash"
|
||||
}
|
||||
|
||||
Input: "con tarjeta"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "link"
|
||||
}
|
||||
|
||||
Input: "link de pago"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "link"
|
||||
}
|
||||
|
||||
Input: "pago cuando llega"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash"
|
||||
}
|
||||
|
||||
Input: "transferencia"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "link"
|
||||
}
|
||||
|
||||
Input: "1" (si el contexto indica que 1=efectivo)
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash"
|
||||
}
|
||||
|
||||
FORMATO JSON:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash" | "link" | null
|
||||
}
|
||||
33
src/modules/3-turn-engine/nlu/defaults/router.txt
Normal file
33
src/modules/3-turn-engine/nlu/defaults/router.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
Clasificá el dominio del mensaje del usuario. Respondé SOLO JSON válido.
|
||||
|
||||
{"domain":"greeting|orders|shipping|payment|browse|other"}
|
||||
|
||||
REGLAS DE CLASIFICACIÓN:
|
||||
|
||||
1. greeting - Saludos sin mención de productos
|
||||
- "hola", "buen día", "buenas tardes", "qué tal", "hey"
|
||||
- NO si menciona productos junto al saludo
|
||||
|
||||
2. orders - Todo relacionado con pedidos y productos
|
||||
- Agregar productos: "quiero", "dame", "anotame", "poneme", cantidad + producto
|
||||
- Quitar productos: "sacame", "quitame", "no quiero"
|
||||
- Ver carrito: "qué tengo", "qué anoté", "mi pedido"
|
||||
- Confirmar: "listo", "eso es todo", "cerrar pedido"
|
||||
|
||||
3. shipping - Envío y entrega
|
||||
- Método: "delivery", "envío", "retiro", "buscar", "sucursal"
|
||||
- Dirección: textos con calle, número, barrio
|
||||
|
||||
4. payment - Métodos de pago
|
||||
- "efectivo", "tarjeta", "transferencia", "link", "mercadopago"
|
||||
|
||||
5. browse - Consultas de catálogo
|
||||
- Precios: "cuánto sale", "precio de"
|
||||
- Disponibilidad: "tenés", "hay", "vendés"
|
||||
- Recomendaciones: "qué me recomendás", "para X personas"
|
||||
|
||||
6. other - Cualquier otra cosa
|
||||
|
||||
Estado actual: {{state}}
|
||||
|
||||
Mensaje a clasificar: [se provee en el input]
|
||||
64
src/modules/3-turn-engine/nlu/defaults/shipping.txt
Normal file
64
src/modules/3-turn-engine/nlu/defaults/shipping.txt
Normal file
@@ -0,0 +1,64 @@
|
||||
Extraé información de envío del mensaje del usuario.
|
||||
|
||||
ENTIDADES A EXTRAER:
|
||||
|
||||
1. shipping_method
|
||||
- "delivery": el cliente quiere que le lleven el pedido
|
||||
Señales: delivery, envío, enviar, que me lo traigan, llevar
|
||||
- "pickup": el cliente pasa a buscar
|
||||
Señales: retiro, retirar, buscar, paso, sucursal
|
||||
- null: no se puede determinar
|
||||
|
||||
2. address
|
||||
- Texto de la dirección de entrega
|
||||
- Solo extraer si hay datos concretos (calle, número, barrio, etc.)
|
||||
- null: si no hay dirección
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "delivery"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "delivery",
|
||||
"address": null
|
||||
}
|
||||
|
||||
Input: "paso a buscar"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "pickup",
|
||||
"address": null
|
||||
}
|
||||
|
||||
Input: "Av. Corrientes 1234, Almagro"
|
||||
Output:
|
||||
{
|
||||
"intent": "provide_address",
|
||||
"shipping_method": null,
|
||||
"address": "Av. Corrientes 1234, Almagro"
|
||||
}
|
||||
|
||||
Input: "delivery a Palermo, calle Honduras 5000"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "delivery",
|
||||
"address": "Palermo, calle Honduras 5000"
|
||||
}
|
||||
|
||||
Input: "1" (si el contexto indica que 1=delivery)
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "delivery",
|
||||
"address": null
|
||||
}
|
||||
|
||||
FORMATO JSON:
|
||||
{
|
||||
"intent": "select_shipping|provide_address",
|
||||
"shipping_method": "delivery" | "pickup" | null,
|
||||
"address": "texto de dirección" | null
|
||||
}
|
||||
164
src/modules/3-turn-engine/nlu/humanFallback.js
Normal file
164
src/modules/3-turn-engine/nlu/humanFallback.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Human Fallback - Lógica para escalar conversaciones a humanos
|
||||
*
|
||||
* Se activa cuando:
|
||||
* - No se encuentra un producto en el catálogo
|
||||
* - El NLU tiene baja confianza
|
||||
* - Casos especiales que requieren atención humana
|
||||
*/
|
||||
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { createEmptyOrder } from "../orderModel.js";
|
||||
|
||||
/**
|
||||
* Crea una respuesta de takeover para cuando no se encuentra un producto
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.pendingQuery - La query/producto que no se encontró
|
||||
* @param {Object} params.order - Estado actual del pedido
|
||||
* @param {Object} params.context - Contexto adicional para el humano
|
||||
* @returns {Object} Resultado con plan y decision para el pipeline
|
||||
*/
|
||||
export function createHumanTakeoverResponse({ pendingQuery, order, context = {} }) {
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
// Mensaje amigable para el usuario
|
||||
const reply = `Dejame consultar con el equipo sobre "${pendingQuery}". Te respondo en un momento.`;
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.AWAITING_HUMAN,
|
||||
intent: "human_takeover",
|
||||
missing_fields: ["human_response"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: {
|
||||
actions: [
|
||||
{
|
||||
type: "request_human_takeover",
|
||||
payload: {
|
||||
pending_query: pendingQuery,
|
||||
reason: "product_not_found",
|
||||
context_snapshot: {
|
||||
order: currentOrder,
|
||||
...context,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
order: currentOrder,
|
||||
audit: {
|
||||
human_takeover_requested: true,
|
||||
pending_query: pendingQuery,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si debería escalar a humano basado en los resultados del catálogo
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Array} params.candidates - Candidatos encontrados en el catálogo
|
||||
* @param {string} params.query - Query original del usuario
|
||||
* @param {number} params.confidenceThreshold - Umbral de confianza mínimo
|
||||
* @returns {boolean} true si debería escalar a humano
|
||||
*/
|
||||
export function shouldEscalateToHuman({ candidates = [], query, confidenceThreshold = 0.3 }) {
|
||||
// Si no hay candidatos, escalar
|
||||
if (!candidates || candidates.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si el mejor candidato tiene score muy bajo, escalar
|
||||
const bestScore = candidates[0]?._score || 0;
|
||||
if (bestScore < confidenceThreshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si la query es muy diferente al nombre del mejor candidato (por nombre)
|
||||
// Esto es un heurístico simple para detectar confusiones
|
||||
const bestName = (candidates[0]?.name || "").toLowerCase();
|
||||
const queryLower = (query || "").toLowerCase();
|
||||
|
||||
// Si no hay overlap significativo de palabras, podría ser confusión
|
||||
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
||||
const nameWords = bestName.split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
if (queryWords.length > 0 && nameWords.length > 0) {
|
||||
const overlap = queryWords.filter(qw =>
|
||||
nameWords.some(nw => nw.includes(qw) || qw.includes(nw))
|
||||
);
|
||||
|
||||
// Si hay muy poco overlap y el score no es muy alto, escalar
|
||||
if (overlap.length === 0 && bestScore < 0.7) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera mensaje de respuesta cuando el humano responde al takeover
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.humanResponse - Respuesta del humano
|
||||
* @param {Object} params.order - Estado actual del pedido
|
||||
* @returns {Object} Resultado para continuar el flujo normal
|
||||
*/
|
||||
export function createHumanResponseResult({ humanResponse, order }) {
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: humanResponse,
|
||||
next_state: ConversationState.CART, // Volver al flujo normal
|
||||
intent: "human_response",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: {
|
||||
actions: [
|
||||
{
|
||||
type: "human_response_sent",
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
order: currentOrder,
|
||||
audit: {
|
||||
human_response_processed: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el estado actual es AWAITING_HUMAN
|
||||
*/
|
||||
export function isAwaitingHuman(state) {
|
||||
return state === ConversationState.AWAITING_HUMAN || state === "AWAITING_HUMAN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera respuesta cuando el usuario envía mensaje mientras está en AWAITING_HUMAN
|
||||
*/
|
||||
export function createWaitingForHumanResponse({ order }) {
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: "Estoy esperando respuesta del equipo sobre tu consulta. Te aviso apenas tenga novedades.",
|
||||
next_state: ConversationState.AWAITING_HUMAN,
|
||||
intent: "other",
|
||||
missing_fields: ["human_response"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: {
|
||||
actions: [],
|
||||
order: currentOrder,
|
||||
audit: { still_waiting_human: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
189
src/modules/3-turn-engine/nlu/index.js
Normal file
189
src/modules/3-turn-engine/nlu/index.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* NLU Modular - Punto de entrada principal
|
||||
*
|
||||
* Orquesta el Router + Specialists para procesar mensajes de usuario.
|
||||
* Reemplaza a llmNluV3 con una arquitectura modular y prompts editables.
|
||||
*/
|
||||
|
||||
import { routerClassify, quickDomainDetect } from "./router.js";
|
||||
import { greetingNlu } from "./specialists/greeting.js";
|
||||
import { ordersNlu } from "./specialists/orders.js";
|
||||
import { shippingNlu } from "./specialists/shipping.js";
|
||||
import { paymentNlu } from "./specialists/payment.js";
|
||||
import { browseNlu } from "./specialists/browse.js";
|
||||
import { createEmptyNlu } from "./schemas.js";
|
||||
|
||||
// Re-exportar utilidades útiles
|
||||
export { loadPrompt, invalidatePromptCache, AVAILABLE_VARIABLES } from "./promptLoader.js";
|
||||
export { PROMPT_KEYS, DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js";
|
||||
|
||||
/**
|
||||
* Procesa un mensaje con el sistema NLU modular
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.input - Input del NLU
|
||||
* @param {string} params.input.last_user_message - Mensaje del usuario
|
||||
* @param {string} params.input.conversation_state - Estado actual de la conversación
|
||||
* @param {Object} params.input.pending_context - Contexto de items pendientes
|
||||
* @param {string} params.input.locale - Locale (default: es-AR)
|
||||
* @param {number} params.tenantId - ID del tenant
|
||||
* @param {Object} params.storeConfig - Configuración de la tienda (para variables)
|
||||
* @returns {Object} { nlu, raw_text, model, usage, schema, validation, routing }
|
||||
*/
|
||||
export async function llmNluModular({ input, tenantId, storeConfig = {} } = {}) {
|
||||
const text = input?.last_user_message || "";
|
||||
const state = input?.conversation_state || "IDLE";
|
||||
const startTime = Date.now();
|
||||
|
||||
// Tracking para debug
|
||||
const routing = {
|
||||
quick_detect: null,
|
||||
router_result: null,
|
||||
final_domain: null,
|
||||
specialist_used: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// 1) Quick detection: si es un caso obvio, evitar llamar al router LLM
|
||||
const quickDomain = quickDomainDetect(text, state);
|
||||
routing.quick_detect = quickDomain;
|
||||
|
||||
// Casos donde podemos saltar el router:
|
||||
// - Saludos simples
|
||||
// - Números solos (1, 2) en estados SHIPPING/PAYMENT
|
||||
// - Patrones muy claros
|
||||
const skipRouter = shouldSkipRouter(text, state, quickDomain);
|
||||
|
||||
let domain;
|
||||
if (skipRouter) {
|
||||
domain = quickDomain;
|
||||
routing.router_result = { skipped: true, quick_domain: quickDomain };
|
||||
} else {
|
||||
// 2) Router LLM: clasificar dominio
|
||||
const routerResult = await routerClassify({ tenantId, text, state, storeConfig });
|
||||
domain = routerResult.domain;
|
||||
routing.router_result = routerResult;
|
||||
}
|
||||
|
||||
routing.final_domain = domain;
|
||||
|
||||
// 3) Dispatch al specialist correspondiente
|
||||
let result;
|
||||
|
||||
switch (domain) {
|
||||
case "greeting":
|
||||
routing.specialist_used = "greeting";
|
||||
result = await greetingNlu({ tenantId, text, storeConfig });
|
||||
break;
|
||||
|
||||
case "orders":
|
||||
routing.specialist_used = "orders";
|
||||
result = await ordersNlu({ tenantId, text, storeConfig });
|
||||
break;
|
||||
|
||||
case "shipping":
|
||||
routing.specialist_used = "shipping";
|
||||
result = await shippingNlu({ tenantId, text, storeConfig });
|
||||
break;
|
||||
|
||||
case "payment":
|
||||
routing.specialist_used = "payment";
|
||||
result = await paymentNlu({ tenantId, text, storeConfig });
|
||||
break;
|
||||
|
||||
case "browse":
|
||||
routing.specialist_used = "browse";
|
||||
result = await browseNlu({ tenantId, text, storeConfig });
|
||||
break;
|
||||
|
||||
default:
|
||||
// Fallback: usar orders como default si hay texto con posibles productos
|
||||
routing.specialist_used = "orders_fallback";
|
||||
result = await ordersNlu({ tenantId, text, storeConfig });
|
||||
// Pero marcar como "other" si el resultado no es claro
|
||||
if (result.nlu.confidence < 0.7) {
|
||||
result.nlu.intent = "other";
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar metadata de routing
|
||||
result.routing = routing;
|
||||
result.schema = "modular_v1";
|
||||
result.processing_time_ms = Date.now() - startTime;
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error("[nluModular] Error:", error);
|
||||
|
||||
// Fallback completo
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = "other";
|
||||
nlu.confidence = 0;
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: "",
|
||||
model: null,
|
||||
usage: null,
|
||||
schema: "modular_v1",
|
||||
validation: { ok: false, error: error.message },
|
||||
routing: { ...routing, error: error.message },
|
||||
processing_time_ms: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina si podemos saltar el router LLM y usar quick detection
|
||||
*/
|
||||
function shouldSkipRouter(text, state, quickDomain) {
|
||||
const t = String(text || "").trim();
|
||||
|
||||
// Saludos simples (sin productos)
|
||||
if (quickDomain === "greeting" && t.length < 20) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Números solos en estados específicos
|
||||
if (/^[12]$/.test(t)) {
|
||||
if (state === "SHIPPING" || state === "PAYMENT") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// "efectivo" o "tarjeta" solos en estado PAYMENT
|
||||
if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// "delivery" o "retiro" solos en estado SHIPPING
|
||||
if (state === "SHIPPING" && /^(delivery|retiro|buscar|sucursal)$/i.test(t)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versión compatible con la firma de llmNluV3
|
||||
* Para usar con el feature flag sin cambiar mucho código
|
||||
*/
|
||||
export async function llmNluModularCompat({ input, model } = {}) {
|
||||
// Extraer tenantId del input si está disponible, o usar 1 como default
|
||||
// En producción, esto debería pasarse explícitamente
|
||||
const tenantId = input?.tenantId || 1;
|
||||
|
||||
// Construir storeConfig básico (en producción se cargaría de la DB)
|
||||
const storeConfig = {
|
||||
name: input?.store_name || "la carnicería",
|
||||
botName: input?.bot_name || "Piaf",
|
||||
hours: input?.store_hours || "",
|
||||
address: input?.store_address || "",
|
||||
};
|
||||
|
||||
return llmNluModular({ input, tenantId, storeConfig });
|
||||
}
|
||||
|
||||
// Export default para compatibilidad
|
||||
export default llmNluModular;
|
||||
204
src/modules/3-turn-engine/nlu/promptLoader.js
Normal file
204
src/modules/3-turn-engine/nlu/promptLoader.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Prompt Loader - Carga prompts de DB con fallback a defaults
|
||||
*
|
||||
* Características:
|
||||
* - Cache en memoria con TTL configurable
|
||||
* - Fallback a archivos default si no hay prompt custom
|
||||
* - Reemplazo de variables básicas ({{store_name}}, etc.)
|
||||
*/
|
||||
|
||||
import { getActivePrompt } from "../../0-ui/db/promptsRepo.js";
|
||||
import { DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DEFAULTS_DIR = path.join(__dirname, "defaults");
|
||||
|
||||
// Cache en memoria
|
||||
const cache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutos
|
||||
|
||||
/**
|
||||
* Variables disponibles para reemplazo en prompts
|
||||
*/
|
||||
export const AVAILABLE_VARIABLES = [
|
||||
{ key: "store_name", description: "Nombre del negocio", example: "Carnicería Don Pedro" },
|
||||
{ key: "store_hours", description: "Horario de atención", example: "Lun-Sab 8-20hs" },
|
||||
{ key: "store_address", description: "Dirección del local", example: "Av. Corrientes 1234" },
|
||||
{ key: "store_phone", description: "Teléfono", example: "+54 11 1234-5678" },
|
||||
{ key: "bot_name", description: "Nombre del bot", example: "Piaf" },
|
||||
{ key: "current_date", description: "Fecha actual", example: "25 de enero" },
|
||||
{ key: "customer_name", description: "Nombre del cliente (si lo tiene)", example: "Juan" },
|
||||
{ key: "state", description: "Estado actual de la conversación", example: "CART" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Carga un prompt de la DB o usa el default
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.tenantId - ID del tenant
|
||||
* @param {string} params.promptKey - Key del prompt ('router', 'greeting', 'orders', etc.)
|
||||
* @param {Object} params.variables - Variables para reemplazar en el prompt
|
||||
* @param {boolean} params.skipCache - Si es true, no usa cache
|
||||
* @returns {Object} { content: string, model: string, isDefault: boolean, version: number|null }
|
||||
*/
|
||||
export async function loadPrompt({ tenantId, promptKey, variables = {}, skipCache = false }) {
|
||||
const cacheKey = `${tenantId}:${promptKey}`;
|
||||
|
||||
// Verificar cache
|
||||
if (!skipCache) {
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return applyVariables(cached.content, cached.model, cached.isDefault, cached.version, variables);
|
||||
}
|
||||
}
|
||||
|
||||
// Intentar cargar de DB
|
||||
let content, model, isDefault = false, version = null;
|
||||
|
||||
try {
|
||||
const dbPrompt = await getActivePrompt({ tenantId, promptKey });
|
||||
|
||||
if (dbPrompt) {
|
||||
content = dbPrompt.content;
|
||||
model = dbPrompt.model;
|
||||
version = dbPrompt.version;
|
||||
isDefault = false;
|
||||
} else {
|
||||
// Fallback a archivo default
|
||||
const defaultContent = loadDefaultPrompt(promptKey);
|
||||
content = defaultContent;
|
||||
model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
|
||||
isDefault = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Si falla la DB, usar default
|
||||
console.error(`[promptLoader] Error loading prompt from DB: ${error.message}`);
|
||||
const defaultContent = loadDefaultPrompt(promptKey);
|
||||
content = defaultContent;
|
||||
model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
|
||||
isDefault = true;
|
||||
}
|
||||
|
||||
// Guardar en cache
|
||||
cache.set(cacheKey, { content, model, isDefault, version, timestamp: Date.now() });
|
||||
|
||||
return applyVariables(content, model, isDefault, version, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga el prompt default desde archivo
|
||||
*/
|
||||
export function loadDefaultPrompt(promptKey) {
|
||||
const filePath = path.join(DEFAULTS_DIR, `${promptKey}.txt`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Default prompt file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reemplaza variables en el contenido del prompt
|
||||
*/
|
||||
function applyVariables(content, model, isDefault, version, variables) {
|
||||
let result = content;
|
||||
|
||||
// Agregar fecha actual si no está en variables
|
||||
if (!variables.current_date) {
|
||||
const now = new Date();
|
||||
const months = ["enero", "febrero", "marzo", "abril", "mayo", "junio",
|
||||
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"];
|
||||
variables.current_date = `${now.getDate()} de ${months[now.getMonth()]}`;
|
||||
}
|
||||
|
||||
// Reemplazar todas las variables
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const regex = new RegExp(`{{${key}}}`, "g");
|
||||
result = result.replace(regex, value || "");
|
||||
}
|
||||
|
||||
// Limpiar variables no reemplazadas (dejar vacío)
|
||||
result = result.replace(/\{\{[^}]+\}\}/g, "");
|
||||
|
||||
return { content: result, model, isDefault, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida el cache de un prompt específico
|
||||
*/
|
||||
export function invalidatePromptCache(tenantId, promptKey) {
|
||||
const cacheKey = `${tenantId}:${promptKey}`;
|
||||
cache.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida todo el cache de un tenant
|
||||
*/
|
||||
export function invalidateTenantCache(tenantId) {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(`${tenantId}:`)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia todo el cache
|
||||
*/
|
||||
export function clearAllCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas del cache (para debugging)
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
const entries = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, value] of cache.entries()) {
|
||||
entries.push({
|
||||
key,
|
||||
age: Math.round((now - value.timestamp) / 1000),
|
||||
isExpired: now - value.timestamp >= CACHE_TTL,
|
||||
isDefault: value.isDefault,
|
||||
version: value.version,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
size: cache.size,
|
||||
ttlSeconds: CACHE_TTL / 1000,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-carga todos los prompts de un tenant (útil al inicio)
|
||||
*/
|
||||
export async function preloadPrompts({ tenantId, storeConfig = {} }) {
|
||||
const promptKeys = ["router", "greeting", "orders", "shipping", "payment", "browse"];
|
||||
const results = {};
|
||||
|
||||
for (const key of promptKeys) {
|
||||
try {
|
||||
results[key] = await loadPrompt({
|
||||
tenantId,
|
||||
promptKey: key,
|
||||
variables: storeConfig,
|
||||
skipCache: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[promptLoader] Error preloading ${key}: ${error.message}`);
|
||||
results[key] = { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
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);
|
||||
}
|
||||
283
src/modules/3-turn-engine/nlu/schemas.js
Normal file
283
src/modules/3-turn-engine/nlu/schemas.js
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Schemas JSON para validación de respuestas NLU
|
||||
*/
|
||||
|
||||
import Ajv from "ajv";
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: Router
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const RouterSchema = {
|
||||
$id: "Router",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["domain"],
|
||||
properties: {
|
||||
domain: {
|
||||
type: "string",
|
||||
enum: ["greeting", "orders", "shipping", "payment", "browse", "other"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const validateRouter = ajv.compile(RouterSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: Greeting
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const GreetingSchema = {
|
||||
$id: "Greeting",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent", "reply"],
|
||||
properties: {
|
||||
intent: { type: "string", enum: ["greeting"] },
|
||||
reply: { type: "string", minLength: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
export const validateGreeting = ajv.compile(GreetingSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: Orders
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const OrdersSchema = {
|
||||
$id: "Orders",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent", "confidence"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["add_to_cart", "remove_from_cart", "view_cart", "confirm_order"],
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["product_query"],
|
||||
properties: {
|
||||
product_query: { type: "string", minLength: 1 },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
},
|
||||
};
|
||||
|
||||
export const validateOrders = ajv.compile(OrdersSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: Shipping
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const ShippingSchema = {
|
||||
$id: "Shipping",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["select_shipping", "provide_address"],
|
||||
},
|
||||
shipping_method: {
|
||||
anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }],
|
||||
},
|
||||
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
};
|
||||
|
||||
export const validateShipping = ajv.compile(ShippingSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: Payment
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const PaymentSchema = {
|
||||
$id: "Payment",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["select_payment"],
|
||||
},
|
||||
payment_method: {
|
||||
anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const validatePayment = ajv.compile(PaymentSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: Browse
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const BrowseSchema = {
|
||||
$id: "Browse",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["price_query", "browse", "recommend"],
|
||||
},
|
||||
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
people_count: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
event_type: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
};
|
||||
|
||||
export const validateBrowse = ajv.compile(BrowseSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Schema: NLU Unificado (output final)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const UnifiedNluSchema = {
|
||||
$id: "UnifiedNlu",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent", "confidence", "language", "entities", "needs"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"price_query", "browse", "add_to_cart", "remove_from_cart",
|
||||
"checkout", "confirm_order", "select_payment", "select_shipping",
|
||||
"provide_address", "greeting", "recommend", "view_cart", "other"
|
||||
],
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
language: { type: "string" },
|
||||
entities: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation", "items"],
|
||||
properties: {
|
||||
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
selection: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["type", "value"],
|
||||
properties: {
|
||||
type: { type: "string", enum: ["index", "text", "sku"] },
|
||||
value: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attributes: { type: "array", items: { type: "string" } },
|
||||
preparation: { type: "array", items: { type: "string" } },
|
||||
payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] },
|
||||
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
|
||||
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["product_query"],
|
||||
properties: {
|
||||
product_query: { type: "string", minLength: 1 },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Browse-specific
|
||||
people_count: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
event_type: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
needs: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["catalog_lookup", "knowledge_lookup"],
|
||||
properties: {
|
||||
catalog_lookup: { type: "boolean" },
|
||||
knowledge_lookup: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
// Greeting-specific: reply del LLM
|
||||
reply: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
};
|
||||
|
||||
export const validateUnifiedNlu = ajv.compile(UnifiedNluSchema);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Obtiene errores de validación formateados
|
||||
*/
|
||||
export function getValidationErrors(validate) {
|
||||
const errors = validate.errors || [];
|
||||
return errors.map((e) => ({
|
||||
path: e.instancePath,
|
||||
message: e.message,
|
||||
params: e.params,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un NLU unificado vacío (fallback)
|
||||
*/
|
||||
export function createEmptyNlu() {
|
||||
return {
|
||||
intent: "other",
|
||||
confidence: 0,
|
||||
language: "es-AR",
|
||||
entities: {
|
||||
product_query: null,
|
||||
quantity: null,
|
||||
unit: null,
|
||||
selection: null,
|
||||
attributes: [],
|
||||
preparation: [],
|
||||
payment_method: null,
|
||||
shipping_method: null,
|
||||
address: null,
|
||||
items: null,
|
||||
people_count: null,
|
||||
event_type: null,
|
||||
},
|
||||
needs: {
|
||||
catalog_lookup: false,
|
||||
knowledge_lookup: false,
|
||||
},
|
||||
reply: null,
|
||||
};
|
||||
}
|
||||
170
src/modules/3-turn-engine/nlu/specialists/browse.js
Normal file
170
src/modules/3-turn-engine/nlu/specialists/browse.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Browse Specialist - Consultas de catálogo, precios y recomendaciones
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { loadPrompt } from "../promptLoader.js";
|
||||
import { validateBrowse, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta tipo de consulta por patrones simples
|
||||
*/
|
||||
function detectBrowseType(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
|
||||
// Price query
|
||||
if (/\b(cu[aá]nto (sale|cuesta|est[aá])|precio|precios)\b/i.test(t)) {
|
||||
return "price_query";
|
||||
}
|
||||
|
||||
// Recommend
|
||||
if (/\b(recomend[aá]|qu[eé] llevo|para \d+ personas?|para un asado)\b/i.test(t)) {
|
||||
return "recommend";
|
||||
}
|
||||
|
||||
// Browse (availability)
|
||||
if (/\b(ten[eé]s|tienen|hay|vend[eé]s)\b/i.test(t)) {
|
||||
return "browse";
|
||||
}
|
||||
|
||||
return "browse";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae número de personas del texto
|
||||
*/
|
||||
function extractPeopleCount(text) {
|
||||
const t = String(text || "");
|
||||
|
||||
// "para X personas"
|
||||
let match = /para\s+(\d+)\s*(personas?|comensales?|invitados?)?/i.exec(t);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
|
||||
// "somos X"
|
||||
match = /somos\s+(\d+)/i.exec(t);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
|
||||
// "X personas"
|
||||
match = /(\d+)\s*(personas?|comensales?)/i.exec(t);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae producto mencionado (simple)
|
||||
*/
|
||||
function extractProductMention(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
|
||||
// Patrones comunes de preguntas
|
||||
const patterns = [
|
||||
/(?:ten[eé]s|hay|vend[eé]s|precio de|cu[aá]nto (?:sale|cuesta) (?:el|la|los|las)?)\s*(.+?)(?:\?|$)/i,
|
||||
/(.+?)\s*(?:tienen|hay|venden)\?/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(t);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa una consulta de catálogo
|
||||
*
|
||||
* @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 browseNlu({ tenantId, text, storeConfig = {} }) {
|
||||
const openai = getClient();
|
||||
|
||||
// Cargar prompt de browse
|
||||
const { content: systemPrompt, model } = await loadPrompt({
|
||||
tenantId,
|
||||
promptKey: "browse",
|
||||
variables: {
|
||||
bot_name: storeConfig.botName || "Piaf",
|
||||
store_name: storeConfig.name || "la carnicería",
|
||||
...storeConfig,
|
||||
},
|
||||
});
|
||||
|
||||
// Hacer la llamada al LLM
|
||||
const response = await openai.chat.completions.create({
|
||||
model: model || "gpt-4-turbo",
|
||||
temperature: 0.2,
|
||||
max_tokens: 200,
|
||||
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
|
||||
if (!parsed || !validateBrowse(parsed)) {
|
||||
// Fallback con detección por patrones
|
||||
const browseType = detectBrowseType(text);
|
||||
parsed = {
|
||||
intent: browseType,
|
||||
product_query: extractProductMention(text),
|
||||
people_count: extractPeopleCount(text),
|
||||
event_type: /asado/i.test(text) ? "asado" : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Convertir a formato NLU unificado
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = parsed.intent || "browse";
|
||||
nlu.confidence = 0.85;
|
||||
nlu.entities.product_query = parsed.product_query || null;
|
||||
nlu.entities.people_count = parsed.people_count || null;
|
||||
nlu.entities.event_type = parsed.event_type || null;
|
||||
nlu.needs.catalog_lookup = true;
|
||||
nlu.needs.knowledge_lookup = nlu.intent === "recommend";
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: rawText,
|
||||
model,
|
||||
usage: response?.usage || null,
|
||||
validation: { ok: true },
|
||||
};
|
||||
}
|
||||
100
src/modules/3-turn-engine/nlu/specialists/greeting.js
Normal file
100
src/modules/3-turn-engine/nlu/specialists/greeting.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Greeting Specialist - Maneja saludos con personalidad de carnicero argentino
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { loadPrompt } from "../promptLoader.js";
|
||||
import { validateGreeting, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un saludo y genera respuesta con personalidad
|
||||
*
|
||||
* @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 con reply
|
||||
*/
|
||||
export async function greetingNlu({ tenantId, text, storeConfig = {} }) {
|
||||
const openai = getClient();
|
||||
|
||||
// Cargar prompt de greeting
|
||||
const { content: systemPrompt, model } = await loadPrompt({
|
||||
tenantId,
|
||||
promptKey: "greeting",
|
||||
variables: {
|
||||
bot_name: storeConfig.botName || "Piaf",
|
||||
store_name: storeConfig.name || "la carnicería",
|
||||
store_hours: storeConfig.hours || "",
|
||||
store_address: storeConfig.address || "",
|
||||
store_phone: storeConfig.phone || "",
|
||||
},
|
||||
});
|
||||
|
||||
// Hacer la llamada al LLM
|
||||
const response = await openai.chat.completions.create({
|
||||
model: model || "gpt-4-turbo",
|
||||
temperature: 0.7, // Un poco más de creatividad para saludos
|
||||
max_tokens: 200,
|
||||
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 || !validateGreeting(parsed)) {
|
||||
// Fallback con respuesta genérica
|
||||
parsed = {
|
||||
intent: "greeting",
|
||||
reply: "¡Hola! ¿En qué te puedo ayudar?",
|
||||
};
|
||||
}
|
||||
|
||||
// Convertir a formato NLU unificado
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = "greeting";
|
||||
nlu.confidence = 0.95;
|
||||
nlu.reply = parsed.reply;
|
||||
nlu.needs.catalog_lookup = false;
|
||||
nlu.needs.knowledge_lookup = false;
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: rawText,
|
||||
model,
|
||||
usage: response?.usage || null,
|
||||
validation: { ok: true },
|
||||
};
|
||||
}
|
||||
162
src/modules/3-turn-engine/nlu/specialists/orders.js
Normal file
162
src/modules/3-turn-engine/nlu/specialists/orders.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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) },
|
||||
};
|
||||
}
|
||||
135
src/modules/3-turn-engine/nlu/specialists/payment.js
Normal file
135
src/modules/3-turn-engine/nlu/specialists/payment.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Payment Specialist - Extracción de método de pago
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { loadPrompt } from "../promptLoader.js";
|
||||
import { validatePayment, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta método de pago por patrones simples
|
||||
*/
|
||||
function detectPaymentMethod(text) {
|
||||
const t = String(text || "").toLowerCase().trim();
|
||||
|
||||
// Números (asumiendo 1=efectivo, 2=link del contexto)
|
||||
if (/^1$/.test(t)) return "cash";
|
||||
if (/^2$/.test(t)) return "link";
|
||||
|
||||
// Cash patterns
|
||||
if (/\b(efectivo|cash|plata|billete|cuando (llega|llegue)|en mano)\b/i.test(t)) {
|
||||
return "cash";
|
||||
}
|
||||
|
||||
// Link patterns
|
||||
if (/\b(tarjeta|link|transfer|qr|mercadopago|mp|d[eé]bito|cr[eé]dito)\b/i.test(t)) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un mensaje de pago
|
||||
*
|
||||
* @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 paymentNlu({ tenantId, text, storeConfig = {} }) {
|
||||
// Intentar detección rápida primero
|
||||
const quickMethod = detectPaymentMethod(text);
|
||||
|
||||
// Si es claramente un número o patrón simple, no llamar al LLM
|
||||
if (quickMethod && text.trim().length < 30) {
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = "select_payment";
|
||||
nlu.confidence = 0.9;
|
||||
nlu.entities.payment_method = quickMethod;
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: "",
|
||||
model: null,
|
||||
usage: null,
|
||||
validation: { ok: true, skipped_llm: true },
|
||||
};
|
||||
}
|
||||
|
||||
const openai = getClient();
|
||||
|
||||
// Cargar prompt de payment
|
||||
const { content: systemPrompt, model } = await loadPrompt({
|
||||
tenantId,
|
||||
promptKey: "payment",
|
||||
variables: storeConfig,
|
||||
});
|
||||
|
||||
// Hacer la llamada al LLM
|
||||
const response = await openai.chat.completions.create({
|
||||
model: model || "gpt-4o-mini",
|
||||
temperature: 0.1,
|
||||
max_tokens: 100,
|
||||
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
|
||||
if (!parsed || !validatePayment(parsed)) {
|
||||
// Fallback con detección por patrones
|
||||
parsed = {
|
||||
intent: "select_payment",
|
||||
payment_method: quickMethod,
|
||||
};
|
||||
}
|
||||
|
||||
// Convertir a formato NLU unificado
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = "select_payment";
|
||||
nlu.confidence = 0.85;
|
||||
nlu.entities.payment_method = parsed.payment_method || null;
|
||||
nlu.needs.catalog_lookup = false;
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: rawText,
|
||||
model,
|
||||
usage: response?.usage || null,
|
||||
validation: { ok: true },
|
||||
};
|
||||
}
|
||||
157
src/modules/3-turn-engine/nlu/specialists/shipping.js
Normal file
157
src/modules/3-turn-engine/nlu/specialists/shipping.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Shipping Specialist - Extracción de método de envío y dirección
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { loadPrompt } from "../promptLoader.js";
|
||||
import { validateShipping, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta método de envío por patrones simples
|
||||
*/
|
||||
function detectShippingMethod(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
|
||||
// Números (asumiendo 1=delivery, 2=pickup del contexto)
|
||||
if (/^1$/.test(t.trim())) return "delivery";
|
||||
if (/^2$/.test(t.trim())) return "pickup";
|
||||
|
||||
// Delivery patterns
|
||||
if (/\b(delivery|env[ií]o|enviar|traigan|llev|domicilio)\b/i.test(t)) {
|
||||
return "delivery";
|
||||
}
|
||||
|
||||
// Pickup patterns
|
||||
if (/\b(retiro|retirar|buscar|paso|sucursal|local)\b/i.test(t)) {
|
||||
return "pickup";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si el texto parece una dirección
|
||||
*/
|
||||
function looksLikeAddress(text) {
|
||||
const t = String(text || "").trim();
|
||||
|
||||
// Tiene números y letras, más de 10 caracteres
|
||||
if (t.length > 10 && /\d/.test(t) && /[a-záéíóú]/i.test(t)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Menciona calles, avenidas, barrios
|
||||
if (/\b(calle|av|avenida|entre|esquina|piso|depto|dto|barrio)\b/i.test(t)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un mensaje de shipping
|
||||
*
|
||||
* @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 shippingNlu({ tenantId, text, storeConfig = {} }) {
|
||||
const openai = getClient();
|
||||
|
||||
// Intentar detección rápida primero
|
||||
const quickMethod = detectShippingMethod(text);
|
||||
const isAddress = looksLikeAddress(text);
|
||||
|
||||
// Si es claramente un número o patrón simple, no llamar al LLM
|
||||
if (quickMethod && !isAddress && text.trim().length < 20) {
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = "select_shipping";
|
||||
nlu.confidence = 0.9;
|
||||
nlu.entities.shipping_method = quickMethod;
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: "",
|
||||
model: null,
|
||||
usage: null,
|
||||
validation: { ok: true, skipped_llm: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Cargar prompt de shipping
|
||||
const { content: systemPrompt, model } = await loadPrompt({
|
||||
tenantId,
|
||||
promptKey: "shipping",
|
||||
variables: storeConfig,
|
||||
});
|
||||
|
||||
// Hacer la llamada al LLM
|
||||
const response = await openai.chat.completions.create({
|
||||
model: model || "gpt-4o-mini",
|
||||
temperature: 0.1,
|
||||
max_tokens: 150,
|
||||
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
|
||||
if (!parsed || !validateShipping(parsed)) {
|
||||
// Fallback con detección por patrones
|
||||
parsed = {
|
||||
intent: isAddress ? "provide_address" : "select_shipping",
|
||||
shipping_method: quickMethod,
|
||||
address: isAddress ? text.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Convertir a formato NLU unificado
|
||||
const nlu = createEmptyNlu();
|
||||
nlu.intent = parsed.intent || "select_shipping";
|
||||
nlu.confidence = 0.85;
|
||||
nlu.entities.shipping_method = parsed.shipping_method || null;
|
||||
nlu.entities.address = parsed.address || null;
|
||||
nlu.needs.catalog_lookup = false;
|
||||
|
||||
return {
|
||||
nlu,
|
||||
raw_text: rawText,
|
||||
model,
|
||||
usage: response?.usage || null,
|
||||
validation: { ok: true },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user