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