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:
@@ -0,0 +1,3 @@
|
||||
-- Integracion IA (API de Anthropic): clave cifrada AES-256-GCM y modelo en system_settings, igual
|
||||
-- que el SMTP. Solo se siembra la version de edicion optimista. Idempotente.
|
||||
INSERT INTO system_settings (`key`, `value`) VALUES ('ai_edit_version', '1') ON DUPLICATE KEY UPDATE `value` = `value`;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "3.1.2",
|
||||
"version": "3.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user