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

@@ -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";
}