feat: version 3.2.0 -- Integracion IA: configuracion y plan de actividades

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 15:55:54 -05:00
parent c64a16064f
commit 89dec6da7e
25 changed files with 538 additions and 10 deletions
+2
View File
@@ -26,6 +26,7 @@ import notesRoutes from './modules/notes/routes.js';
import vaultRoutes from './modules/vault/routes.js';
import devicesRoutes from './modules/devices/routes.js';
import inventoryIngestRoutes from './modules/devices/ingest.js';
import aiRoutes from './modules/ai/routes.js';
export const app = express();
@@ -66,6 +67,7 @@ app.use('/api/error-logs', errorLogsRoutes);
app.use('/api/notes', notesRoutes);
app.use('/api/vault', vaultRoutes);
app.use('/api/devices', devicesRoutes);
app.use('/api/ai', aiRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
+12
View File
@@ -0,0 +1,12 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getStatus = asyncHandler(async (req, res) => ok(res, await service.getStatus()));
export const postProjectPlan = asyncHandler(async (req, res) => {
ok(res, await service.generateProjectPlan(Number(req.params.projectId), req.body?.description, req.body?.lang, req.currentUser));
});
export const postApplyProjectPlan = asyncHandler(async (req, res) => {
ok(res, await service.applyProjectPlan(Number(req.params.projectId), req.body?.activities, req.currentUser), 201);
});
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { getStatus, postProjectPlan, postApplyProjectPlan } from './controller.js';
const router = Router();
// Cada llamada a la IA cuesta: tope por usuario/IP para no agotar el saldo por error o abuso.
const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
router.use(requireAuth);
router.get('/status', getStatus);
router.post('/projects/:projectId/plan', requireModule('projects'), aiLimiter, postProjectPlan);
router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan);
export default router;
+108
View File
@@ -0,0 +1,108 @@
import { pool } from '../../config/db.js';
import { HttpError } from '../../utils/http.js';
import { askStructured } from '../../utils/ai.js';
import { logAudit } from '../../utils/audit.js';
import { getAiConfig } from '../settings/service.js';
import { getProject, canWriteProject } from '../projects/service.js';
import { createActivity } from '../activities/service.js';
import { createTask } from '../tasks/service.js';
const MAX_ACTIVITIES = 15;
const MAX_TASKS = 10;
const PLAN_SCHEMA = {
type: 'object',
properties: {
activities: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
durationDays: { type: 'integer', minimum: 1 },
tasks: { type: 'array', items: { type: 'string' } },
},
required: ['name', 'durationDays', 'tasks'],
},
},
},
required: ['activities'],
};
export async function getStatus() {
const { configured } = await getAiConfig();
return { enabled: configured };
}
async function getWritableProject(projectId, user) {
const project = await getProject(projectId, user);
if (!(await canWriteProject(user, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
return project;
}
// Normaliza lo que devuelve la IA: recorta, limita cantidades y descarta entradas vacias.
function cleanPlan(activities) {
return (Array.isArray(activities) ? activities : []).slice(0, MAX_ACTIVITIES).map((a) => ({
name: String(a?.name || '').trim().slice(0, 200),
description: String(a?.description || '').trim().slice(0, 1000),
durationDays: Math.min(365, Math.max(1, Number.parseInt(a?.durationDays, 10) || 1)),
tasks: (Array.isArray(a?.tasks) ? a.tasks : []).map((t) => String(t || '').trim().slice(0, 200)).filter(Boolean).slice(0, MAX_TASKS),
})).filter((a) => a.name);
}
export async function generateProjectPlan(projectId, description, lang, user) {
const project = await getWritableProject(projectId, user);
const extra = String(description || '').trim().slice(0, 2000);
const [existing] = await pool.query('SELECT name FROM activities WHERE project_id = ? AND deleted_at IS NULL', [projectId]);
const language = String(lang || '').startsWith('en') ? 'English' : 'español neutro';
const result = await askStructured({
system: `Eres un jefe de proyectos de TI. Propones planes de trabajo realistas y concretos para un area de TI corporativa. Responde en ${language}.`,
prompt: [
`Proyecto: ${project.name}`,
project.description ? `Descripcion: ${project.description}` : '',
project.objective ? `Objetivo: ${project.objective}` : '',
extra ? `Indicaciones del usuario: ${extra}` : '',
existing.length ? `Actividades que YA existen (no las repitas): ${existing.map((r) => r.name).join('; ')}` : '',
`Propon entre 3 y ${MAX_ACTIVITIES} actividades en orden de ejecucion, cada una con una duracion estimada en dias habiles y de 2 a ${MAX_TASKS} tareas concretas.`,
].filter(Boolean).join('\n'),
toolName: 'plan_de_proyecto',
schema: PLAN_SCHEMA,
maxTokens: 4000,
});
await logAudit({ userId: user.id, action: 'ai_plan', entityType: 'project', entityId: projectId });
return { activities: cleanPlan(result.activities) };
}
function addDays(date, days) {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
const isoDate = (d) => d.toISOString().slice(0, 10);
// Crea las actividades/tareas que el usuario acepto, encadenadas desde hoy, con la prioridad
// media y el primer estado abierto del catalogo. Reusa los servicios normales (permisos,
// auditoria, notificaciones y recalculo de avance incluidos).
export async function applyProjectPlan(projectId, activities, user) {
await getWritableProject(projectId, user);
const plan = cleanPlan(activities);
if (!plan.length) throw new HttpError(400, 'No hay actividades para crear');
const [priorities] = await pool.query('SELECT id FROM priorities ORDER BY sort_order, id');
const [statuses] = await pool.query('SELECT id FROM activity_statuses WHERE is_closed = 0 ORDER BY sort_order, id LIMIT 1');
if (!priorities.length || !statuses.length) throw new HttpError(400, 'Faltan prioridades o estados de actividad en Configuracion');
const priorityId = priorities[Math.floor((priorities.length - 1) / 2)].id;
let start = new Date();
let created = 0;
for (const a of plan) {
const due = addDays(start, a.durationDays - 1);
const activity = await createActivity(projectId, {
name: a.name, description: a.description, priorityId, statusId: statuses[0].id,
startDate: isoDate(start), dueDate: isoDate(due),
}, user);
for (const name of a.tasks) await createTask(activity.id, { name }, user);
start = addDays(due, 1);
created += 1;
}
return { created };
}
@@ -10,3 +10,13 @@ export const putSmtp = asyncHandler(async (req, res) => {
export const postTestSmtp = asyncHandler(async (req, res) => {
ok(res, await service.testSmtpConfig(req.body, req.currentUser.id));
});
export const getAi = asyncHandler(async (req, res) => ok(res, await service.getAiConfig()));
export const putAi = asyncHandler(async (req, res) => {
ok(res, await service.updateAiConfig(req.body, req.currentUser.id));
});
export const postTestAi = asyncHandler(async (req, res) => {
ok(res, await service.testAiConfig(req.body, req.currentUser.id));
});
+4 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth, requireRole, CAN_MANAGE_SMTP } from '../../middleware/auth.js';
import { getSmtp, putSmtp, postTestSmtp } from './controller.js';
import { getSmtp, putSmtp, postTestSmtp, getAi, putAi, postTestAi } from './controller.js';
const router = Router();
@@ -12,5 +12,8 @@ router.use(requireAuth, requireRole(...CAN_MANAGE_SMTP));
router.get('/smtp', getSmtp);
router.put('/smtp', putSmtp);
router.post('/smtp/test', testLimiter, postTestSmtp);
router.get('/ai', getAi);
router.put('/ai', putAi);
router.post('/ai/test', testLimiter, postTestAi);
export default router;
+56
View File
@@ -114,3 +114,59 @@ export async function testSmtpConfig(payload, actorId) {
await logAudit({ userId: actorId, action: 'test', entityType: 'settings:smtp', entityId: null, detail: { to } });
return { sent: true };
}
// --- Integracion IA (Anthropic). La clave nunca sale del backend: solo se informa si existe. ---
export const AI_DEFAULT_MODEL = 'claude-haiku-5-5';
const AI_MODELS = ['claude-haiku-5-5', 'claude-sonnet-5-5'];
export async function getAiConfig() {
const values = await repo.getAll(['ai_api_key_enc', 'ai_model', 'ai_edit_version']);
return {
edit_version: Number(values.ai_edit_version),
model: values.ai_model || AI_DEFAULT_MODEL,
models: AI_MODELS,
configured: !!values.ai_api_key_enc,
};
}
// Uso interno (utils/ai.js). null si no hay clave o no se puede descifrar.
export async function getAiConfigInternal() {
const values = await repo.getAll(['ai_api_key_enc', 'ai_model']);
if (!values.ai_api_key_enc) return null;
try {
return { apiKey: decrypt(values.ai_api_key_enc), model: values.ai_model || AI_DEFAULT_MODEL };
} catch {
console.error('[ai] no se pudo descifrar ai_api_key_enc -- revisa ENCRYPTION_KEY o vuelve a guardar la clave desde Configuracion');
return null;
}
}
export async function updateAiConfig(payload, actorId) {
requireEditVersion(payload.editVersion);
const model = AI_MODELS.includes(payload.model) ? payload.model : AI_DEFAULT_MODEL;
const entries = { ai_model: model };
const apiKey = stripSpaces(payload.apiKey);
if (apiKey) entries.ai_api_key_enc = encrypt(apiKey);
requireUpdated(await repo.setManyIfVersion(entries, 'ai_edit_version', payload.editVersion));
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:ai', entityId: null, detail: { model, keyChanged: !!apiKey } });
return getAiConfig();
}
// Prueba la clave escrita (o la guardada) con una consulta minima. Propaga el error real.
export async function testAiConfig(payload, actorId) {
const { askStructured } = await import('../../utils/ai.js');
const typed = stripSpaces(payload?.apiKey);
const stored = typed ? null : await getAiConfigInternal();
if (!typed && !stored) throw new HttpError(400, 'No hay una clave guardada ni escrita para probar');
const model = AI_MODELS.includes(payload?.model) ? payload.model : (stored?.model || AI_DEFAULT_MODEL);
const result = await askStructured({
system: 'Responde solo con la herramienta.',
prompt: 'Responde ok=true.',
toolName: 'prueba',
schema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
maxTokens: 50,
configOverride: { apiKey: typed || stored.apiKey, model },
});
await logAudit({ userId: actorId, action: 'test', entityType: 'settings:ai', entityId: null });
return { ok: !!result.ok, model };
}
+47
View File
@@ -0,0 +1,47 @@
import { getAiConfigInternal } from '../modules/settings/service.js';
import { HttpError } from './http.js';
const API_URL = 'https://api.anthropic.com/v1/messages';
const TIMEOUT_MS = 60_000;
// Llama a la API de Mensajes forzando una herramienta, para recibir JSON con el esquema pedido
// (la IA solo sugiere: quien llama valida y el usuario revisa antes de guardar).
export async function askStructured({ system, prompt, toolName, schema, maxTokens = 2000, configOverride }) {
const config = configOverride || await getAiConfigInternal();
if (!config) throw new HttpError(400, 'La integracion con IA no esta configurada');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res;
try {
res = await fetch(API_URL, {
method: 'POST',
signal: controller.signal,
headers: { 'content-type': 'application/json', 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: config.model,
max_tokens: maxTokens,
system: `${system}\nResponde SIEMPRE llamando a la herramienta ${toolName}.`,
messages: [{ role: 'user', content: prompt }],
tools: [{ name: toolName, description: 'Devuelve el resultado estructurado.', input_schema: schema }],
// Forzar la herramienta (tool_choice 'tool') da 400 en Sonnet 5.5: se usa 'auto' + instruccion.
tool_choice: { type: 'auto' },
}),
});
} catch (err) {
throw new HttpError(502, err.name === 'AbortError' ? 'La IA tardo demasiado en responder' : 'No se pudo conectar con el servicio de IA');
} finally {
clearTimeout(timer);
}
const body = await res.json().catch(() => null);
if (!res.ok) {
// Mensaje del proveedor sin exponer la clave; 401 = clave invalida, 429 = limite, 400 = saldo/peticion.
const detail = body?.error?.message || `HTTP ${res.status}`;
console.error('[ai] error de la API:', res.status, detail);
throw new HttpError(502, `Servicio de IA: ${detail}`);
}
// Consumo real de cada llamada (para estimar costos: ver precios del modelo en la consola).
console.log(`[ai] ${config.model} tokens entrada=${body?.usage?.input_tokens} salida=${body?.usage?.output_tokens}`);
const tool = body?.content?.find((c) => c.type === 'tool_use');
if (!tool?.input) throw new HttpError(502, 'La IA no devolvio un resultado valido');
return tool.input;
}