modularizado de prompts

This commit is contained in:
Lucas Tettamanti
2026-01-25 20:51:33 -03:00
parent b91ece867b
commit a489ec66a2
43 changed files with 5408 additions and 89 deletions

View File

@@ -34,7 +34,8 @@ export function createApp({ tenantId }) {
// SPA catch-all - sirve index.html para todas las rutas del frontend
const spaRoutes = [
'/chat', '/conversaciones', '/usuarios', '/productos',
'/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test'
'/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test',
'/config-prompts', '/atencion-humana', '/configuracion'
];
app.get(spaRoutes, (req, res) => {
res.sendFile(path.join(publicDir, "index.html"));

View File

@@ -0,0 +1,158 @@
import {
handleListPrompts,
handleGetPrompt,
handleSavePrompt,
handleRollbackPrompt,
handleResetPrompt,
handleGetPromptVersion,
handleTestPrompt,
} from "../handlers/prompts.js";
/**
* GET /prompts - Lista todos los prompts del tenant
*/
export const makeListPrompts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleListPrompts({ tenantId });
res.json(result);
} catch (err) {
console.error("[prompts] List error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /prompts/:key - Obtiene un prompt específico con versiones
*/
export const makeGetPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const result = await handleGetPrompt({ tenantId, promptKey });
res.json(result);
} catch (err) {
console.error("[prompts] Get error:", err);
if (err.message.includes("Invalid prompt_key")) {
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key - Crea/actualiza un prompt (nueva versión)
*/
export const makeSavePrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const { content, model, created_by } = req.body || {};
if (!content) {
return res.status(400).json({ ok: false, error: "content_required" });
}
const result = await handleSavePrompt({
tenantId,
promptKey,
content,
model,
createdBy: created_by || null,
});
res.json(result);
} catch (err) {
console.error("[prompts] Save error:", err);
if (err.message.includes("Invalid prompt_key")) {
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key/rollback/:version - Restaura una versión anterior
*/
export const makeRollbackPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { key, version } = req.params;
const { created_by } = req.body || {};
const result = await handleRollbackPrompt({
tenantId,
promptKey: key,
toVersion: version,
createdBy: created_by || null,
});
res.json(result);
} catch (err) {
console.error("[prompts] Rollback error:", err);
if (err.message.includes("not found")) {
return res.status(404).json({ ok: false, error: "version_not_found" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key/reset - Resetea al default
*/
export const makeResetPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const result = await handleResetPrompt({ tenantId, promptKey });
res.json(result);
} catch (err) {
console.error("[prompts] Reset error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /prompts/:key/versions/:version - Obtiene contenido de una versión específica
*/
export const makeGetPromptVersion = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { key, version } = req.params;
const result = await handleGetPromptVersion({ tenantId, promptKey: key, version });
res.json(result);
} catch (err) {
console.error("[prompts] GetVersion error:", err);
if (err.message.includes("not found")) {
return res.status(404).json({ ok: false, error: "version_not_found" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key/test - Prueba un prompt con un mensaje
*/
export const makeTestPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const { content, test_message, store_config } = req.body || {};
if (!test_message) {
return res.status(400).json({ ok: false, error: "test_message_required" });
}
const result = await handleTestPrompt({
tenantId,
promptKey,
content,
testMessage: test_message,
storeConfig: store_config || {},
});
res.json(result);
} catch (err) {
console.error("[prompts] Test error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};

View File

@@ -0,0 +1,36 @@
import { handleGetSettings, handleSaveSettings } from "../handlers/settings.js";
/**
* GET /settings - Obtiene la configuración del tenant
*/
export const makeGetSettings = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleGetSettings({ tenantId });
res.json(result);
} catch (err) {
console.error("[settings] Get error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /settings - Guarda la configuración del tenant
*/
export const makeSaveSettings = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const settings = req.body || {};
const result = await handleSaveSettings({ tenantId, settings });
res.json(result);
} catch (err) {
console.error("[settings] Save error:", err);
if (err.message.includes("required") || err.message.includes("Invalid")) {
return res.status(400).json({ ok: false, error: "validation_error", message: err.message });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};

View File

@@ -0,0 +1,126 @@
import {
handleListPendingTakeovers,
handleListAllTakeovers,
handleGetTakeover,
handleRespondToTakeover,
handleCancelTakeover,
handleCheckPendingTakeover,
} from "../handlers/takeovers.js";
/**
* GET /takeovers - Lista takeovers pendientes
*/
export const makeListPendingTakeovers = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const limit = parseInt(req.query.limit, 10) || 50;
const result = await handleListPendingTakeovers({ tenantId, limit });
res.json(result);
} catch (err) {
console.error("[takeovers] List pending error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /takeovers/all - Lista todos los takeovers
*/
export const makeListAllTakeovers = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const status = req.query.status || null;
const limit = parseInt(req.query.limit, 10) || 100;
const result = await handleListAllTakeovers({ tenantId, status, limit });
res.json(result);
} catch (err) {
console.error("[takeovers] List all error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /takeovers/:id - Obtiene detalles de un takeover
*/
export const makeGetTakeover = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = parseInt(req.params.id, 10);
const result = await handleGetTakeover({ tenantId, id });
res.json(result);
} catch (err) {
console.error("[takeovers] Get error:", err);
if (err.message.includes("not found")) {
return res.status(404).json({ ok: false, error: "takeover_not_found" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /takeovers/:id/respond - Responde a un takeover
*/
export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = parseInt(req.params.id, 10);
const { response, responded_by, add_alias } = req.body || {};
if (!response) {
return res.status(400).json({ ok: false, error: "response_required" });
}
const result = await handleRespondToTakeover({
tenantId,
id,
response,
respondedBy: responded_by || null,
addAlias: add_alias || null,
});
res.json(result);
} catch (err) {
console.error("[takeovers] Respond error:", err);
if (err.message.includes("not found") || err.message.includes("already")) {
return res.status(404).json({ ok: false, error: "takeover_not_found_or_processed" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /takeovers/:id/cancel - Cancela un takeover
*/
export const makeCancelTakeover = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = parseInt(req.params.id, 10);
const { responded_by } = req.body || {};
const result = await handleCancelTakeover({
tenantId,
id,
respondedBy: responded_by || null,
});
res.json(result);
} catch (err) {
console.error("[takeovers] Cancel error:", err);
if (err.message.includes("not found") || err.message.includes("already")) {
return res.status(404).json({ ok: false, error: "takeover_not_found_or_processed" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /takeovers/check/:chatId - Verifica si hay takeover pendiente para un chat
*/
export const makeCheckPendingTakeover = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const chatId = req.params.chatId;
const result = await handleCheckPendingTakeover({ tenantId, chatId });
res.json(result);
} catch (err) {
console.error("[takeovers] Check pending error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};

View File

@@ -0,0 +1,183 @@
import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Prompt Templates - CRUD con versionado
// ─────────────────────────────────────────────────────────────
// Prompt keys válidos
export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"];
// Modelos por defecto para cada prompt
export const DEFAULT_MODELS = {
router: "gpt-4o-mini",
greeting: "gpt-4-turbo",
orders: "gpt-4-turbo",
shipping: "gpt-4o-mini",
payment: "gpt-4o-mini",
browse: "gpt-4-turbo",
};
/**
* Obtiene el prompt activo para un tenant y key
* @returns {Object|null} { id, prompt_key, content, model, version, is_active, created_at, created_by }
*/
export async function getActivePrompt({ tenantId, promptKey }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId, promptKey]);
return rows[0] || null;
}
/**
* Lista todos los prompts activos de un tenant
*/
export async function listActivePrompts({ tenantId }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND is_active = true
ORDER BY prompt_key
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}
/**
* Obtiene todas las versiones de un prompt
*/
export async function getPromptVersions({ tenantId, promptKey, limit = 20 }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2
ORDER BY version DESC
LIMIT $3
`;
const { rows } = await pool.query(sql, [tenantId, promptKey, limit]);
return rows;
}
/**
* Obtiene una versión específica de un prompt
*/
export async function getPromptVersion({ tenantId, promptKey, version }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2 AND version = $3
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId, promptKey, version]);
return rows[0] || null;
}
/**
* Desactiva el prompt activo actual (para crear nueva versión)
*/
export async function deactivatePrompt({ tenantId, promptKey }) {
const sql = `
UPDATE prompt_templates
SET is_active = false
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
`;
await pool.query(sql, [tenantId, promptKey]);
}
/**
* Crea una nueva versión del prompt (automáticamente desactiva la anterior)
* @returns {Object} El prompt creado con su versión
*/
export async function createPrompt({ tenantId, promptKey, content, model, createdBy = null }) {
// Validar prompt_key
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}. Valid keys: ${PROMPT_KEYS.join(", ")}`);
}
// Desactivar versión anterior
await deactivatePrompt({ tenantId, promptKey });
// Insertar nueva versión (el trigger calcula la versión automáticamente)
const sql = `
INSERT INTO prompt_templates (tenant_id, prompt_key, content, model, is_active, created_by)
VALUES ($1, $2, $3, $4, true, $5)
RETURNING id, prompt_key, content, model, version, is_active, created_at, created_by
`;
const { rows } = await pool.query(sql, [
tenantId,
promptKey,
content,
model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
createdBy,
]);
return rows[0];
}
/**
* Restaura una versión anterior del prompt (crea nueva versión con el contenido antiguo)
*/
export async function rollbackPrompt({ tenantId, promptKey, toVersion, createdBy = null }) {
// Obtener la versión a restaurar
const oldVersion = await getPromptVersion({ tenantId, promptKey, version: toVersion });
if (!oldVersion) {
throw new Error(`Version ${toVersion} not found for prompt ${promptKey}`);
}
// Crear nueva versión con el contenido antiguo
return createPrompt({
tenantId,
promptKey,
content: oldVersion.content,
model: oldVersion.model,
createdBy,
});
}
/**
* Resetea un prompt a su default (desactiva todas las versiones custom)
*/
export async function resetPromptToDefault({ tenantId, promptKey }) {
const sql = `
UPDATE prompt_templates
SET is_active = false
WHERE tenant_id = $1 AND prompt_key = $2
`;
await pool.query(sql, [tenantId, promptKey]);
return { success: true, message: `Prompt ${promptKey} reset to default` };
}
/**
* Elimina todas las versiones de un prompt (usar con cuidado)
*/
export async function deleteAllPromptVersions({ tenantId, promptKey }) {
const sql = `
DELETE FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2
RETURNING id
`;
const { rows } = await pool.query(sql, [tenantId, promptKey]);
return { deleted: rows.length };
}
/**
* Obtiene estadísticas de prompts de un tenant
*/
export async function getPromptStats({ tenantId }) {
const sql = `
SELECT
prompt_key,
COUNT(*) as total_versions,
MAX(version) as latest_version,
MAX(CASE WHEN is_active THEN version END) as active_version,
MAX(created_at) as last_updated
FROM prompt_templates
WHERE tenant_id = $1
GROUP BY prompt_key
ORDER BY prompt_key
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}

View File

@@ -0,0 +1,174 @@
import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Tenant Settings - CRUD
// ─────────────────────────────────────────────────────────────
/**
* Obtiene la configuración del tenant
*/
export async function getSettings({ tenantId }) {
const sql = `
SELECT
id, tenant_id,
store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days,
delivery_hours_start::text as delivery_hours_start,
delivery_hours_end::text as delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
created_at, updated_at
FROM tenant_settings
WHERE tenant_id = $1
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows[0] || null;
}
/**
* Crea o actualiza la configuración del tenant (upsert)
*/
export async function upsertSettings({ tenantId, settings }) {
const {
store_name,
bot_name,
store_address,
store_phone,
delivery_enabled,
delivery_days,
delivery_hours_start,
delivery_hours_end,
delivery_min_order,
pickup_enabled,
pickup_days,
pickup_hours_start,
pickup_hours_end,
} = settings;
const sql = `
INSERT INTO tenant_settings (
tenant_id, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (tenant_id) DO UPDATE SET
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled),
delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days),
delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start),
delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end),
delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order),
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
updated_at = NOW()
RETURNING
id, tenant_id,
store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days,
delivery_hours_start::text as delivery_hours_start,
delivery_hours_end::text as delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
created_at, updated_at
`;
const params = [
tenantId,
store_name || null,
bot_name || null,
store_address || null,
store_phone || null,
delivery_enabled ?? null,
delivery_days || null,
delivery_hours_start || null,
delivery_hours_end || null,
delivery_min_order ?? null,
pickup_enabled ?? null,
pickup_days || null,
pickup_hours_start || null,
pickup_hours_end || null,
];
console.log("[settingsRepo] upsertSettings params:", params);
const { rows } = await pool.query(sql, params);
console.log("[settingsRepo] upsertSettings result:", rows[0]);
return rows[0];
}
/**
* Obtiene la configuración formateada para usar en prompts (storeConfig)
*/
export async function getStoreConfig({ tenantId }) {
const settings = await getSettings({ tenantId });
if (!settings) {
// Valores por defecto si no hay configuración
return {
name: "la carnicería",
botName: "Piaf",
hours: "",
address: "",
phone: "",
deliveryHours: "",
pickupHours: "",
};
}
// Formatear horarios para mostrar
const formatHours = (enabled, days, start, end) => {
if (!enabled) return "No disponible";
if (!days || !start || !end) return "";
const daysFormatted = days.split(",").map(d => d.trim()).join(", ");
const startFormatted = start?.slice(0, 5) || "";
const endFormatted = end?.slice(0, 5) || "";
return `${daysFormatted} de ${startFormatted} a ${endFormatted}`;
};
const deliveryHours = formatHours(
settings.delivery_enabled,
settings.delivery_days,
settings.delivery_hours_start,
settings.delivery_hours_end
);
const pickupHours = formatHours(
settings.pickup_enabled,
settings.pickup_days,
settings.pickup_hours_start,
settings.pickup_hours_end
);
// Combinar horarios para store_hours
let storeHours = "";
if (settings.pickup_enabled && settings.pickup_days) {
storeHours = `${settings.pickup_days.split(",").join(", ")} ${settings.pickup_hours_start?.slice(0,5) || ""}-${settings.pickup_hours_end?.slice(0,5) || ""}`;
}
return {
name: settings.store_name || "la carnicería",
botName: settings.bot_name || "Piaf",
hours: storeHours,
address: settings.store_address || "",
phone: settings.store_phone || "",
deliveryHours,
pickupHours,
deliveryEnabled: settings.delivery_enabled,
pickupEnabled: settings.pickup_enabled,
};
}

View File

@@ -0,0 +1,182 @@
import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Human Takeovers - CRUD
// ─────────────────────────────────────────────────────────────
/**
* Crea un nuevo takeover (conversación esperando respuesta humana)
*/
export async function createTakeover({
tenantId,
chatId,
pendingQuery,
reason = "product_not_found",
contextSnapshot = null
}) {
const sql = `
INSERT INTO human_takeovers (tenant_id, chat_id, pending_query, reason, context_snapshot)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tenant_id, chat_id, pending_query, reason, status, created_at
`;
const { rows } = await pool.query(sql, [
tenantId,
chatId,
pendingQuery,
reason,
contextSnapshot ? JSON.stringify(contextSnapshot) : null,
]);
return rows[0];
}
/**
* Lista takeovers pendientes de un tenant
*/
export async function listPendingTakeovers({ tenantId, limit = 50 }) {
const sql = `
SELECT
id, tenant_id, chat_id, pending_query, reason,
context_snapshot, status, created_at, updated_at
FROM human_takeovers
WHERE tenant_id = $1 AND status = 'pending'
ORDER BY created_at DESC
LIMIT $2
`;
const { rows } = await pool.query(sql, [tenantId, limit]);
return rows;
}
/**
* Lista todos los takeovers de un tenant (incluyendo respondidos)
*/
export async function listAllTakeovers({ tenantId, status = null, limit = 100 }) {
let sql, params;
if (status) {
sql = `
SELECT
id, tenant_id, chat_id, pending_query, reason,
context_snapshot, status, human_response, responded_at, responded_by,
created_at, updated_at
FROM human_takeovers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT $3
`;
params = [tenantId, status, limit];
} else {
sql = `
SELECT
id, tenant_id, chat_id, pending_query, reason,
context_snapshot, status, human_response, responded_at, responded_by,
created_at, updated_at
FROM human_takeovers
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`;
params = [tenantId, limit];
}
const { rows } = await pool.query(sql, params);
return rows;
}
/**
* Obtiene un takeover por ID
*/
export async function getTakeoverById({ tenantId, id }) {
const sql = `
SELECT
id, tenant_id, chat_id, pending_query, reason,
context_snapshot, status, human_response, responded_at, responded_by,
created_at, updated_at
FROM human_takeovers
WHERE tenant_id = $1 AND id = $2
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows[0] || null;
}
/**
* Obtiene takeover pendiente de un chat específico
*/
export async function getPendingTakeoverByChat({ tenantId, chatId }) {
const sql = `
SELECT
id, tenant_id, chat_id, pending_query, reason,
context_snapshot, status, created_at, updated_at
FROM human_takeovers
WHERE tenant_id = $1 AND chat_id = $2 AND status = 'pending'
ORDER BY created_at DESC
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId, chatId]);
return rows[0] || null;
}
/**
* Responde a un takeover
*/
export async function respondToTakeover({ tenantId, id, humanResponse, respondedBy = null }) {
const sql = `
UPDATE human_takeovers
SET
status = 'responded',
human_response = $3,
responded_at = NOW(),
responded_by = $4
WHERE tenant_id = $1 AND id = $2 AND status = 'pending'
RETURNING id, chat_id, pending_query, human_response, responded_at, responded_by, status
`;
const { rows } = await pool.query(sql, [tenantId, id, humanResponse, respondedBy]);
return rows[0] || null;
}
/**
* Cancela un takeover
*/
export async function cancelTakeover({ tenantId, id, respondedBy = null }) {
const sql = `
UPDATE human_takeovers
SET
status = 'cancelled',
responded_at = NOW(),
responded_by = $3
WHERE tenant_id = $1 AND id = $2 AND status = 'pending'
RETURNING id, chat_id, status
`;
const { rows } = await pool.query(sql, [tenantId, id, respondedBy]);
return rows[0] || null;
}
/**
* Cuenta takeovers pendientes por tenant
*/
export async function countPendingTakeovers({ tenantId }) {
const sql = `
SELECT COUNT(*) as count
FROM human_takeovers
WHERE tenant_id = $1 AND status = 'pending'
`;
const { rows } = await pool.query(sql, [tenantId]);
return parseInt(rows[0]?.count || 0, 10);
}
/**
* Obtiene estadísticas de takeovers
*/
export async function getTakeoverStats({ tenantId }) {
const sql = `
SELECT
status,
COUNT(*) as count,
AVG(EXTRACT(EPOCH FROM (COALESCE(responded_at, NOW()) - created_at))) as avg_response_time_seconds
FROM human_takeovers
WHERE tenant_id = $1
GROUP BY status
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}

View File

@@ -0,0 +1,258 @@
import {
getActivePrompt,
listActivePrompts,
getPromptVersions,
getPromptVersion,
createPrompt,
rollbackPrompt,
resetPromptToDefault,
getPromptStats,
PROMPT_KEYS,
DEFAULT_MODELS,
} from "../db/promptsRepo.js";
import { loadDefaultPrompt, AVAILABLE_VARIABLES, invalidatePromptCache } from "../../3-turn-engine/nlu/promptLoader.js";
/**
* Lista todos los prompts del tenant (activos + defaults para los que no tienen custom)
*/
export async function handleListPrompts({ tenantId }) {
const activePrompts = await listActivePrompts({ tenantId });
const stats = await getPromptStats({ tenantId });
// Construir lista completa con defaults para los que no tienen custom
const promptsMap = new Map(activePrompts.map(p => [p.prompt_key, p]));
const items = PROMPT_KEYS.map(key => {
const custom = promptsMap.get(key);
const stat = stats.find(s => s.prompt_key === key);
if (custom) {
return {
prompt_key: key,
content: custom.content,
model: custom.model,
version: custom.version,
is_default: false,
total_versions: stat?.total_versions || 1,
last_updated: custom.created_at,
created_by: custom.created_by,
};
} else {
// Cargar default
let defaultContent = "";
try {
defaultContent = loadDefaultPrompt(key);
} catch (e) {
defaultContent = `[Error loading default: ${e.message}]`;
}
return {
prompt_key: key,
content: defaultContent,
model: DEFAULT_MODELS[key] || "gpt-4-turbo",
version: null,
is_default: true,
total_versions: stat?.total_versions || 0,
last_updated: null,
created_by: null,
};
}
});
return {
items,
available_variables: AVAILABLE_VARIABLES,
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
};
}
/**
* Obtiene un prompt específico con su historial de versiones
*/
export async function handleGetPrompt({ tenantId, promptKey }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
const current = await getActivePrompt({ tenantId, promptKey });
const versions = await getPromptVersions({ tenantId, promptKey, limit: 20 });
let defaultContent = "";
try {
defaultContent = loadDefaultPrompt(promptKey);
} catch (e) {
defaultContent = `[Error: ${e.message}]`;
}
return {
prompt_key: promptKey,
current: current || {
content: defaultContent,
model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
version: null,
is_default: true,
},
default_content: defaultContent,
default_model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
versions: versions.map(v => ({
version: v.version,
is_active: v.is_active,
created_at: v.created_at,
created_by: v.created_by,
content_preview: v.content.slice(0, 100) + (v.content.length > 100 ? "..." : ""),
})),
available_variables: AVAILABLE_VARIABLES,
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
};
}
/**
* Crea o actualiza un prompt (crea nueva versión)
*/
export async function handleSavePrompt({ tenantId, promptKey, content, model, createdBy }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
if (!content || content.trim().length === 0) {
throw new Error("Content is required");
}
const result = await createPrompt({
tenantId,
promptKey,
content: content.trim(),
model: model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
createdBy,
});
// Invalidar cache
invalidatePromptCache(tenantId, promptKey);
return {
ok: true,
item: result,
message: `Prompt ${promptKey} saved as version ${result.version}`,
};
}
/**
* Restaura una versión anterior del prompt
*/
export async function handleRollbackPrompt({ tenantId, promptKey, toVersion, createdBy }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
const result = await rollbackPrompt({
tenantId,
promptKey,
toVersion: parseInt(toVersion, 10),
createdBy,
});
// Invalidar cache
invalidatePromptCache(tenantId, promptKey);
return {
ok: true,
item: result,
message: `Prompt ${promptKey} rolled back to version ${toVersion}, new version is ${result.version}`,
};
}
/**
* Resetea un prompt al default (desactiva todas las versiones custom)
*/
export async function handleResetPrompt({ tenantId, promptKey }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
await resetPromptToDefault({ tenantId, promptKey });
// Invalidar cache
invalidatePromptCache(tenantId, promptKey);
return {
ok: true,
message: `Prompt ${promptKey} reset to default`,
};
}
/**
* Obtiene el contenido de una versión específica
*/
export async function handleGetPromptVersion({ tenantId, promptKey, version }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
const versionData = await getPromptVersion({
tenantId,
promptKey,
version: parseInt(version, 10)
});
if (!versionData) {
throw new Error(`Version ${version} not found for prompt ${promptKey}`);
}
return { item: versionData };
}
/**
* Prueba un prompt con un mensaje de ejemplo
*/
export async function handleTestPrompt({ tenantId, promptKey, content, testMessage, storeConfig = {} }) {
// Importar dinámicamente para evitar dependencias circulares
const { loadPrompt } = await import("../../3-turn-engine/nlu/promptLoader.js");
// Si se proporciona content, usarlo directamente
// Si no, cargar el prompt actual
let promptContent = content;
let model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
if (!promptContent) {
const loaded = await loadPrompt({ tenantId, promptKey, variables: storeConfig });
promptContent = loaded.content;
model = loaded.model;
} else {
// Aplicar variables al content proporcionado
for (const [key, value] of Object.entries(storeConfig)) {
promptContent = promptContent.replace(new RegExp(`{{${key}}}`, "g"), value || "");
}
promptContent = promptContent.replace(/\{\{[^}]+\}\}/g, "");
}
// Importar OpenAI
const OpenAI = (await import("openai")).default;
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY not configured");
}
const openai = new OpenAI({ apiKey });
// Hacer la llamada de prueba
const startTime = Date.now();
const response = await openai.chat.completions.create({
model,
temperature: 0.2,
max_tokens: 500,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: promptContent },
{ role: "user", content: testMessage },
],
});
const endTime = Date.now();
return {
ok: true,
response: response?.choices?.[0]?.message?.content || "",
model,
usage: response?.usage,
latency_ms: endTime - startTime,
};
}

View File

@@ -0,0 +1,114 @@
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
// Días de la semana para validación
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
/**
* Obtiene la configuración actual del tenant
*/
export async function handleGetSettings({ tenantId }) {
const settings = await getSettings({ tenantId });
// Si no hay configuración, devolver defaults
if (!settings) {
return {
store_name: "Mi Negocio",
bot_name: "Piaf",
store_address: "",
store_phone: "",
delivery_enabled: true,
delivery_days: "lun,mar,mie,jue,vie,sab",
delivery_hours_start: "09:00",
delivery_hours_end: "18:00",
delivery_min_order: 0,
pickup_enabled: true,
pickup_days: "lun,mar,mie,jue,vie,sab",
pickup_hours_start: "08:00",
pickup_hours_end: "20:00",
is_default: true,
};
}
return {
...settings,
// Formatear horarios TIME a HH:MM
delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00",
delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00",
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
is_default: false,
};
}
/**
* Guarda la configuración del tenant
*/
export async function handleSaveSettings({ tenantId, settings }) {
console.log("[settings] handleSaveSettings", { tenantId, settings });
// Validaciones básicas
if (!settings.store_name?.trim()) {
throw new Error("store_name is required");
}
if (!settings.bot_name?.trim()) {
throw new Error("bot_name is required");
}
// Validar días
if (settings.delivery_days) {
const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid delivery day: ${day}`);
}
}
settings.delivery_days = days.join(",");
}
if (settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid pickup day: ${day}`);
}
}
settings.pickup_days = days.join(",");
}
// Validar horarios
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
}
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
}
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
}
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
}
const result = await upsertSettings({ tenantId, settings });
return {
ok: true,
settings: {
...result,
delivery_hours_start: result.delivery_hours_start?.slice(0, 5),
delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
},
message: "Configuración guardada correctamente",
};
}
/**
* Obtiene el storeConfig formateado para prompts
*/
export async function handleGetStoreConfig({ tenantId }) {
return await getStoreConfig({ tenantId });
}

View File

@@ -0,0 +1,246 @@
import {
createTakeover,
listPendingTakeovers,
listAllTakeovers,
getTakeoverById,
getPendingTakeoverByChat,
respondToTakeover,
cancelTakeover,
countPendingTakeovers,
getTakeoverStats,
} from "../db/takeoverRepo.js";
import { insertAlias, upsertAliasMapping } from "../db/repo.js";
import { getRecentMessagesForLLM } from "../../2-identity/db/repo.js";
/**
* Lista takeovers pendientes de respuesta
*/
export async function handleListPendingTakeovers({ tenantId, limit = 50 }) {
const items = await listPendingTakeovers({ tenantId, limit });
const count = await countPendingTakeovers({ tenantId });
// Enriquecer con contexto resumido
const enrichedItems = await Promise.all(items.map(async (item) => {
// Obtener últimos mensajes de la conversación
let recentMessages = [];
try {
recentMessages = await getRecentMessagesForLLM({
tenantId,
chat_id: item.chat_id,
limit: 5
});
} catch (e) {
// Ignorar errores al cargar mensajes
}
return {
...item,
context_summary: item.context_snapshot
? summarizeContext(item.context_snapshot)
: null,
recent_messages: recentMessages.map(m => ({
role: m.role,
content: m.content?.slice(0, 200),
created_at: m.created_at,
})),
};
}));
return { items: enrichedItems, pending_count: count };
}
/**
* Lista todos los takeovers (con filtro opcional por status)
*/
export async function handleListAllTakeovers({ tenantId, status = null, limit = 100 }) {
const items = await listAllTakeovers({ tenantId, status, limit });
const stats = await getTakeoverStats({ tenantId });
return { items, stats };
}
/**
* Obtiene detalles de un takeover específico
*/
export async function handleGetTakeover({ tenantId, id }) {
const takeover = await getTakeoverById({ tenantId, id });
if (!takeover) {
throw new Error("Takeover not found");
}
// Obtener historial de la conversación
let conversationHistory = [];
try {
conversationHistory = await getRecentMessagesForLLM({
tenantId,
chat_id: takeover.chat_id,
limit: 20
});
} catch (e) {
// Ignorar errores
}
return {
...takeover,
context_summary: takeover.context_snapshot
? summarizeContext(takeover.context_snapshot)
: null,
conversation_history: conversationHistory,
};
}
/**
* Responde a un takeover (el humano envía respuesta como el bot)
*/
export async function handleRespondToTakeover({
tenantId,
id,
response,
respondedBy = null,
addAlias = null, // { query: string, woo_product_id: number }
}) {
// Responder al takeover
const result = await respondToTakeover({
tenantId,
id,
humanResponse: response,
respondedBy,
});
if (!result) {
throw new Error("Takeover not found or already responded");
}
// Si se pidió agregar alias, hacerlo
if (addAlias && addAlias.query && addAlias.woo_product_id) {
try {
await insertAlias({
tenantId,
alias: addAlias.query.toLowerCase().trim(),
woo_product_id: addAlias.woo_product_id,
boost: 0.5,
metadata: { created_from_takeover: id },
});
} catch (e) {
// Si el alias ya existe, intentar agregar mapping
if (e.code === "23505") {
await upsertAliasMapping({
tenantId,
alias: addAlias.query.toLowerCase().trim(),
woo_product_id: addAlias.woo_product_id,
score: 0.8,
});
}
}
}
return {
ok: true,
takeover: result,
message: "Response sent successfully",
};
}
/**
* Cancela un takeover
*/
export async function handleCancelTakeover({ tenantId, id, respondedBy = null }) {
const result = await cancelTakeover({ tenantId, id, respondedBy });
if (!result) {
throw new Error("Takeover not found or already processed");
}
return {
ok: true,
takeover: result,
message: "Takeover cancelled",
};
}
/**
* Verifica si hay un takeover pendiente para un chat
*/
export async function handleCheckPendingTakeover({ tenantId, chatId }) {
const pending = await getPendingTakeoverByChat({ tenantId, chatId });
return {
has_pending: !!pending,
pending: pending || null,
};
}
/**
* Crea un nuevo takeover (llamado desde el pipeline cuando no se encuentra un producto)
*/
export async function handleCreateTakeover({
tenantId,
chatId,
pendingQuery,
reason = "product_not_found",
contextSnapshot = null,
}) {
// Verificar si ya hay un takeover pendiente para este chat
const existing = await getPendingTakeoverByChat({ tenantId, chatId });
if (existing) {
// Actualizar el existente con la nueva query
// Por ahora, retornamos el existente
return {
ok: true,
takeover: existing,
message: "Takeover already pending for this chat",
already_existed: true,
};
}
const takeover = await createTakeover({
tenantId,
chatId,
pendingQuery,
reason,
contextSnapshot,
});
return {
ok: true,
takeover,
message: "Takeover created",
already_existed: false,
};
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function summarizeContext(contextSnapshot) {
if (!contextSnapshot) return null;
const ctx = typeof contextSnapshot === "string"
? JSON.parse(contextSnapshot)
: contextSnapshot;
const summary = [];
// Cart items
if (ctx.order?.cart?.length > 0) {
const cartItems = ctx.order.cart.map(i => `${i.qty}${i.unit} ${i.name}`).join(", ");
summary.push(`Carrito: ${cartItems}`);
}
// Pending items
if (ctx.order?.pending?.length > 0) {
const pendingItems = ctx.order.pending.map(i => i.query).join(", ");
summary.push(`Pendiente: ${pendingItems}`);
}
// Shipping/Payment
if (ctx.order?.is_delivery !== null) {
summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro");
}
if (ctx.order?.payment_type) {
summary.push(`Pago: ${ctx.order.payment_type}`);
}
return summary.join(" | ") || "Sin contexto";
}

View File

@@ -10,6 +10,9 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts,
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, makeResetPrompt, makeGetPromptVersion, makeTestPrompt } from "../../0-ui/controllers/prompts.js";
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
@@ -77,6 +80,27 @@ export function createSimulatorRouter({ tenantId }) {
router.get("/quantities/:wooProductId", makeGetProductQtyRules(getTenantId));
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId));
// --- Prompts routes ---
router.get("/prompts", makeListPrompts(getTenantId));
router.get("/prompts/:key", makeGetPrompt(getTenantId));
router.post("/prompts/:key", makeSavePrompt(getTenantId));
router.post("/prompts/:key/rollback/:version", makeRollbackPrompt(getTenantId));
router.post("/prompts/:key/reset", makeResetPrompt(getTenantId));
router.get("/prompts/:key/versions/:version", makeGetPromptVersion(getTenantId));
router.post("/prompts/:key/test", makeTestPrompt(getTenantId));
// --- Human Takeovers routes ---
router.get("/takeovers", makeListPendingTakeovers(getTenantId));
router.get("/takeovers/all", makeListAllTakeovers(getTenantId));
router.get("/takeovers/check/:chatId", makeCheckPendingTakeover(getTenantId));
router.get("/takeovers/:id", makeGetTakeover(getTenantId));
router.post("/takeovers/:id/respond", makeRespondToTakeover(getTenantId));
router.post("/takeovers/:id/cancel", makeCancelTakeover(getTenantId));
// --- Settings routes ---
router.get("/settings", makeGetSettings(getTenantId));
router.post("/settings", makeSaveSettings(getTenantId));
router.get("/users", makeListUsers(getTenantId));
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));

View File

@@ -232,22 +232,36 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
for (const act of actions) {
try {
if (act.type === "create_order") {
const basketToUse = reducedContext?.order_basket || plan?.basket_resolved || { items: [] };
// Construir address con phone de fallback desde wa_chat_id
const baseAddress = reducedContext?.delivery_address || reducedContext?.address || {};
const phoneFromWa = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || "";
const addressWithPhone = {
...baseAddress,
phone: baseAddress.phone || phoneFromWa,
};
const order = await createOrder({
tenantId,
wooCustomerId: externalCustomerId,
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
address: reducedContext?.delivery_address || reducedContext?.address || null,
basket: basketToUse,
address: addressWithPhone,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;
actionPatch.order_total = calcOrderTotal(order);
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
} else if (act.type === "update_order") {
const baseAddrUpd = reducedContext?.delivery_address || reducedContext?.address || {};
const phoneFromWaUpd = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || "";
const addressWithPhoneUpd = {
...baseAddrUpd,
phone: baseAddrUpd.phone || phoneFromWaUpd,
};
const order = await updateOrder({
tenantId,
wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null,
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
address: reducedContext?.delivery_address || reducedContext?.address || null,
address: addressWithPhoneUpd,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;

View File

@@ -11,6 +11,7 @@ export const ConversationState = Object.freeze({
SHIPPING: "SHIPPING",
PAYMENT: "PAYMENT",
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
});
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
@@ -33,23 +34,46 @@ export const INTENTS_BY_STATE = Object.freeze({
[ConversationState.WAITING_WEBHOOKS]: [
"add_to_cart", "view_cart", "other"
],
[ConversationState.AWAITING_HUMAN]: [
"other" // En este estado, el bot no procesa - espera respuesta humana
],
});
/**
* Verifica si el usuario quiere agregar productos (debe volver a CART).
*/
export function shouldReturnToCart(state, nlu) {
export function shouldReturnToCart(state, nlu, text = "") {
if (state === ConversationState.CART || state === ConversationState.IDLE) {
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
}
// En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT;
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
if (isCheckoutState && isJustNumber) {
return false; // No redirigir, es una selección de opción
}
const intent = nlu?.intent;
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
// Pero solo si hay una query de producto real (no vacía)
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
return true;
// Verificar que hay un producto real mencionado
const hasRealProduct = nlu?.entities?.product_query &&
String(nlu.entities.product_query).trim().length > 2;
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
nlu.entities.items.some(i => i?.product_query?.trim().length > 2);
if (hasRealProduct || hasRealItems) {
return true;
}
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
return false;
}
// Si hay menciones de producto en entities
if (nlu?.entities?.product_query) return true;
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) return true;
// Si hay menciones de producto en entities (con contenido real)
if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true;
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
return false;
}
@@ -167,26 +191,36 @@ const ALLOWED = Object.freeze({
[ConversationState.IDLE]: [
ConversationState.IDLE,
ConversationState.CART,
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
],
[ConversationState.CART]: [
ConversationState.CART,
ConversationState.SHIPPING,
ConversationState.IDLE, // Si vacía el carrito
ConversationState.AWAITING_HUMAN, // Producto no encontrado
],
[ConversationState.SHIPPING]: [
ConversationState.SHIPPING,
ConversationState.PAYMENT,
ConversationState.CART, // Volver a agregar productos
ConversationState.AWAITING_HUMAN,
],
[ConversationState.PAYMENT]: [
ConversationState.PAYMENT,
ConversationState.WAITING_WEBHOOKS,
ConversationState.CART, // Volver a agregar productos
ConversationState.AWAITING_HUMAN,
],
[ConversationState.WAITING_WEBHOOKS]: [
ConversationState.WAITING_WEBHOOKS,
ConversationState.IDLE, // Pago completado
ConversationState.CART, // Agregar más productos
ConversationState.AWAITING_HUMAN,
],
[ConversationState.AWAITING_HUMAN]: [
ConversationState.AWAITING_HUMAN, // Sigue esperando
ConversationState.CART, // Humano respondió, volver a procesar
ConversationState.IDLE, // Humano canceló
],
});

View 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
}

View 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"
}

View 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}, ...]
}

View 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
}

View 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]

View 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
}

View 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 },
},
};
}

View 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;

View 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;
}

View 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);
}

View 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,
};
}

View 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 },
};
}

View 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 },
};
}

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

View 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 },
};
}

View 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 },
};
}

View File

@@ -19,6 +19,7 @@ import {
formatOptionsForDisplay,
} from "./orderModel.js";
import { handleRecommend } from "./recommendations.js";
import { getProductQtyRules } from "../0-ui/db/repo.js";
// ─────────────────────────────────────────────────────────────
// Utilidades
@@ -356,6 +357,62 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
};
}
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
// Detectar "para X personas" en el texto original ANTES de preguntar cantidad
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
if (personasMatch && nextPending.selected_woo_id) {
const peopleCount = parseInt(personasMatch[1], 10);
if (peopleCount > 0 && peopleCount <= 100) {
// Buscar reglas de cantidad por persona para este producto
let qtyRules = [];
try {
qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id });
} catch (e) {
audit.qty_rules_error = e?.message;
}
// Calcular cantidad recomendada
let calculatedQty;
let calculatedUnit = nextPending.selected_unit || "kg";
const rule = qtyRules[0];
if (rule && rule.qty_per_person > 0) {
calculatedQty = rule.qty_per_person * peopleCount;
calculatedUnit = rule.unit || calculatedUnit;
audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount };
} else {
// Fallback: 0.3 kg por persona para carnes
calculatedQty = 0.3 * peopleCount;
audit.qty_fallback = { default_per_person: 0.3, people: peopleCount };
}
// Actualizar el pending item y mover al cart
const updatedOrder = updatePendingItem(currentOrder, nextPending.id, {
qty: calculatedQty,
unit: calculatedUnit,
status: PendingStatus.READY,
});
const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
return {
plan: {
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}. Ya lo anoté. ¿Algo más?`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
};
}
}
// Si no hay "para X personas", preguntar cantidad normalmente
const unitQuestion = unitAskFor(nextPending.selected_unit || "kg");
return {
plan: {
@@ -840,6 +897,73 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
};
}
// Detectar "para X personas" y calcular cantidad automáticamente
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
if (personasMatch && pendingItem.selected_woo_id) {
const peopleCount = parseInt(personasMatch[1], 10);
if (peopleCount > 0 && peopleCount <= 100) {
// Buscar reglas de cantidad por persona para este producto
let qtyRules = [];
try {
qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id });
} catch (e) {
audit.qty_rules_error = e?.message;
}
// Buscar regla para evento "asado" o genérica (null)
const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") ||
qtyRules.find(r => r.event_type === null && r.person_type === "adult") ||
qtyRules.find(r => r.person_type === "adult") ||
qtyRules[0];
let calculatedQty;
let calculatedUnit = pendingItem.selected_unit || "kg";
if (rule && rule.qty_per_person > 0) {
// Usar regla de BD
calculatedQty = rule.qty_per_person * peopleCount;
calculatedUnit = rule.unit || calculatedUnit;
audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit };
} else {
// Fallback: 300g por persona para productos por peso
const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3;
calculatedQty = fallbackPerPerson * peopleCount;
audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit };
}
// Redondear a 1 decimal para kg, entero para unidades
if (calculatedUnit === "unit") {
calculatedQty = Math.ceil(calculatedQty);
} else {
calculatedQty = Math.round(calculatedQty * 10) / 10;
}
const updatedOrder = updatePendingItem(order, pendingItem.id, {
qty: calculatedQty,
unit: calculatedUnit,
status: PendingStatus.READY,
});
const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
return {
plan: {
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}. Ya lo anoté. ¿Algo más?`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
};
}
}
// No entendió cantidad
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
return {

View File

@@ -3,9 +3,12 @@
*
* Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*
* Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular.
*/
import { llmNluV3 } from "./openai.js";
import { llmNluModular } from "./nlu/index.js";
import { ConversationState, shouldReturnToCart, safeNextState } from "./fsm.js";
import { migrateOldContext, createEmptyOrder } from "./orderModel.js";
import {
@@ -15,6 +18,10 @@ import {
handlePaymentState,
handleWaitingState,
} from "./stateHandlers.js";
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
// Feature flag para NLU modular
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
/**
* Genera un resumen corto del historial para el NLU
@@ -58,7 +65,7 @@ export async function runTurnV3({
const normalizedState = normalizeState(prev_state);
// ─────────────────────────────────────────────────────────────
// NLU
// NLU (con feature flag para sistema modular)
// ─────────────────────────────────────────────────────────────
const nluInput = {
@@ -73,8 +80,37 @@ export async function runTurnV3({
locale: "es-AR",
};
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
let nluResult;
if (USE_MODULAR_NLU) {
// Nuevo sistema NLU modular con prompts editables
// Cargar configuración del tenant desde la DB
const storeConfig = await getStoreConfig({ tenantId });
nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig });
audit.nlu = {
raw_text: nluResult.raw_text,
model: nluResult.model,
usage: nluResult.usage,
validation: nluResult.validation,
parsed: nluResult.nlu,
routing: nluResult.routing,
schema: "modular_v1",
};
} else {
// Sistema NLU clásico
nluResult = await llmNluV3({ input: nluInput });
audit.nlu = {
raw_text: nluResult.raw_text,
model: nluResult.model,
usage: nluResult.usage,
validation: nluResult.validation,
parsed: nluResult.nlu,
schema: "v3",
};
}
const nlu = nluResult.nlu;
// ─────────────────────────────────────────────────────────────
// Dispatcher por estado
@@ -90,7 +126,8 @@ export async function runTurnV3({
};
// Regla universal: si quiere agregar productos, volver a CART
if (shouldReturnToCart(normalizedState, nlu)) {
const returnToCart = shouldReturnToCart(normalizedState, nlu, text);
if (returnToCart) {
const result = await handleCartState({ ...handlerParams, fromIdle: false });
return formatResult(result, prev_context);
}

View File

@@ -125,7 +125,7 @@ async function buildLineItems({ tenantId, basket }) {
const lineItems = [];
for (const it of items) {
const productId = Number(it.product_id);
const unit = String(it.unit);
const unit = String(it.unit).toLowerCase();
const qty = Number(it.quantity);
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
const pricePerKg = await getWooProductPrice({ tenantId, productId });
@@ -144,8 +144,10 @@ async function buildLineItems({ tenantId, basket }) {
continue;
}
// Carne por gramos (enteros): quantity=1 y total calculado en base a ARS/kg
const grams = Math.round(qty);
// Carne por peso: convertir a gramos
// Si qty < 100, asumir que viene en kg (ej: 1.5 kg)
// Si qty >= 100, asumir que ya viene en gramos (ej: 1500 g)
const grams = qty < 100 ? Math.round(qty * 1000) : Math.round(qty);
const kilos = grams / 1000;
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
lineItems.push({
@@ -165,6 +167,13 @@ async function buildLineItems({ tenantId, basket }) {
function mapAddress(address) {
if (!address || typeof address !== "object") return null;
// Generar email fallback si no hay uno válido (usa formato wa_chat_id)
let email = address.email || "";
if (!email || !email.includes("@")) {
const phone = address.phone || "";
// Formato: {phone}@s.whatsapp.net (igual que wa_chat_id)
email = phone ? `${phone.replace(/[^0-9]/g, "")}@s.whatsapp.net` : `anon-${Date.now()}@s.whatsapp.net`;
}
return {
first_name: address.first_name || "",
last_name: address.last_name || "",
@@ -175,7 +184,7 @@ function mapAddress(address) {
postcode: address.postcode || "",
country: address.country || "AR",
phone: address.phone || "",
email: address.email || "",
email,
};
}