modularizado de prompts
This commit is contained in:
258
src/modules/0-ui/handlers/prompts.js
Normal file
258
src/modules/0-ui/handlers/prompts.js
Normal 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,
|
||||
};
|
||||
}
|
||||
114
src/modules/0-ui/handlers/settings.js
Normal file
114
src/modules/0-ui/handlers/settings.js
Normal 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 });
|
||||
}
|
||||
246
src/modules/0-ui/handlers/takeovers.js
Normal file
246
src/modules/0-ui/handlers/takeovers.js
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user