feat: version 4.0.0 -- Asistente Nuevo proyecto con IA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,3 +10,11 @@ export const postProjectPlan = asyncHandler(async (req, res) => {
|
||||
export const postApplyProjectPlan = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.applyProjectPlan(Number(req.params.projectId), req.body?.activities, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const postProposeProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.proposeNewProject(req.body || {}, req.currentUser));
|
||||
});
|
||||
|
||||
export const postCreateProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createNewProject(req.body || {}, req.currentUser), 201);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { getStatus, postProjectPlan, postApplyProjectPlan, postProposeProject, postCreateProject } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -12,6 +12,8 @@ const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeade
|
||||
router.use(requireAuth);
|
||||
router.get('/status', getStatus);
|
||||
router.post('/projects/:projectId/plan', requireModule('projects'), aiLimiter, postProjectPlan);
|
||||
router.post('/new-project/propose', requireModule('projects'), aiLimiter, postProposeProject);
|
||||
router.post('/new-project', requireModule('projects'), postCreateProject);
|
||||
router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,36 +3,43 @@ 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 { getProject, canWriteProject, createProject } from '../projects/service.js';
|
||||
import { createActivity } from '../activities/service.js';
|
||||
import { createTask } from '../tasks/service.js';
|
||||
|
||||
const MAX_ACTIVITIES = 15;
|
||||
const MAX_TASKS = 10;
|
||||
export const MIN_OBJECTIVE_LENGTH = 20;
|
||||
const MAX_OBJECTIVE_LENGTH = 2000;
|
||||
|
||||
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' } },
|
||||
const ACTIVITIES_SCHEMA = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
durationDays: { type: 'integer', minimum: 1 },
|
||||
tasks: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' }, durationDays: { type: 'integer', minimum: 1 } },
|
||||
required: ['name', 'durationDays'],
|
||||
},
|
||||
required: ['name', 'durationDays', 'tasks'],
|
||||
},
|
||||
},
|
||||
required: ['name', 'durationDays', 'tasks'],
|
||||
},
|
||||
required: ['activities'],
|
||||
};
|
||||
|
||||
const PLANNER_SYSTEM = (language) => `Eres un jefe de proyectos de TI de una empresa. Solo planificas proyectos: propones actividades realistas en orden de ejecucion, cada una con duracion en dias habiles y de 2 a ${MAX_TASKS} tareas concretas y accionables (las tareas son lo mas importante: ninguna actividad sin tareas). Responde en ${language}.`;
|
||||
const languageFor = (lang) => (String(lang || '').startsWith('en') ? 'English' : 'español neutro');
|
||||
|
||||
export async function getStatus() {
|
||||
const { configured } = await getAiConfig();
|
||||
return { enabled: configured };
|
||||
return { enabled: configured, minObjectiveLength: MIN_OBJECTIVE_LENGTH };
|
||||
}
|
||||
|
||||
async function getWritableProject(projectId, user) {
|
||||
@@ -41,68 +48,195 @@ async function getWritableProject(projectId, user) {
|
||||
return project;
|
||||
}
|
||||
|
||||
// Normaliza lo que devuelve la IA: recorta, limita cantidades y descarta entradas vacias.
|
||||
const clip = (v, max) => String(v || '').trim().slice(0, max);
|
||||
const days = (v) => Math.min(365, Math.max(1, Number.parseInt(v, 10) || 1));
|
||||
|
||||
// Normaliza lo que devuelve la IA (o lo que reenvia el cliente al aplicar): recorta, limita
|
||||
// cantidades y descarta actividades sin tareas. Acepta tareas como texto o { name, durationDays }.
|
||||
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);
|
||||
return (Array.isArray(activities) ? activities : []).slice(0, MAX_ACTIVITIES).map((a) => {
|
||||
const tasks = (Array.isArray(a?.tasks) ? a.tasks : [])
|
||||
.map((t) => (typeof t === 'string' ? { name: t, durationDays: 1 } : t))
|
||||
.map((t) => ({ name: clip(t?.name, 200), durationDays: days(t?.durationDays) }))
|
||||
.filter((t) => t.name)
|
||||
.slice(0, MAX_TASKS);
|
||||
return { name: clip(a?.name, 200), description: clip(a?.description, 1000), durationDays: days(a?.durationDays), tasks };
|
||||
}).filter((a) => a.name && a.tasks.length);
|
||||
}
|
||||
|
||||
function addDays(date, n) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
const isoDate = (d) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Fecha de fin estimada = inicio + suma de duraciones (actividades encadenadas).
|
||||
const planEndDate = (plan, start) => addDays(start, Math.max(1, plan.reduce((s, a) => s + a.durationDays, 0)) - 1);
|
||||
|
||||
// Crea actividades encadenadas desde `start` y sus tareas repartidas dentro de cada actividad,
|
||||
// con la prioridad media y el primer estado abierto. Reusa los servicios normales (permisos,
|
||||
// auditoria, notificaciones y recalculo de avance incluidos).
|
||||
async function createPlanActivities(projectId, plan, start, user, responsibleUserId = null) {
|
||||
const [priorities] = await pool.query('SELECT id FROM priorities ORDER BY level, 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 cursor = new Date(start);
|
||||
for (const a of plan) {
|
||||
const due = addDays(cursor, a.durationDays - 1);
|
||||
const activity = await createActivity(projectId, {
|
||||
name: a.name, description: a.description, priorityId, statusId: statuses[0].id,
|
||||
responsibleUserId, startDate: isoDate(cursor), dueDate: isoDate(due),
|
||||
}, user);
|
||||
let taskCursor = new Date(cursor);
|
||||
for (const task of a.tasks) {
|
||||
let taskDue = addDays(taskCursor, task.durationDays - 1);
|
||||
if (taskDue > due) taskDue = due;
|
||||
await createTask(activity.id, { name: task.name, responsibleUserId, dueDate: isoDate(taskDue) }, user);
|
||||
taskCursor = taskDue < due ? addDays(taskDue, 1) : due;
|
||||
}
|
||||
cursor = addDays(due, 1);
|
||||
}
|
||||
return plan.length;
|
||||
}
|
||||
|
||||
// --- Plan para un proyecto existente (pestaña Actividades) ---
|
||||
|
||||
export async function generateProjectPlan(projectId, description, lang, user) {
|
||||
const project = await getWritableProject(projectId, user);
|
||||
const extra = String(description || '').trim().slice(0, 2000);
|
||||
const extra = clip(description, MAX_OBJECTIVE_LENGTH);
|
||||
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}.`,
|
||||
system: PLANNER_SYSTEM(languageFor(lang)),
|
||||
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.`,
|
||||
`Propon entre 3 y ${MAX_ACTIVITIES} actividades nuevas.`,
|
||||
].filter(Boolean).join('\n'),
|
||||
toolName: 'plan_de_proyecto',
|
||||
schema: PLAN_SCHEMA,
|
||||
schema: { type: 'object', properties: { activities: ACTIVITIES_SCHEMA }, required: ['activities'] },
|
||||
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 project = 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 };
|
||||
// Desde hoy, o desde el inicio del proyecto si todavia no empezo.
|
||||
const today = new Date();
|
||||
const projectStart = project.start_date ? new Date(project.start_date) : null;
|
||||
const start = projectStart && projectStart > today ? projectStart : today;
|
||||
return { created: await createPlanActivities(projectId, plan, start, user) };
|
||||
}
|
||||
|
||||
// --- Asistente "Nuevo proyecto con IA" ---
|
||||
// Solo el objetivo va a la IA (una llamada). Estado, responsable, miembros y fecha los fija la
|
||||
// app; el departamento se elige de la lista real. Nada se crea hasta que el usuario confirma.
|
||||
|
||||
const PROJECT_CREATOR_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
|
||||
|
||||
function requireCreator(user) {
|
||||
if (!PROJECT_CREATOR_ROLES.includes(user.roleCode)) throw new HttpError(403, 'No tienes permisos para crear proyectos');
|
||||
}
|
||||
|
||||
function parseStartDate(value) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) throw new HttpError(400, 'Fecha de inicio invalida');
|
||||
const d = new Date(`${value}T12:00:00`);
|
||||
if (Number.isNaN(d.getTime())) throw new HttpError(400, 'Fecha de inicio invalida');
|
||||
return d;
|
||||
}
|
||||
|
||||
function parseObjective(value) {
|
||||
const objective = clip(value, MAX_OBJECTIVE_LENGTH);
|
||||
if (objective.length < MIN_OBJECTIVE_LENGTH) throw new HttpError(400, `Describe el objetivo con al menos ${MIN_OBJECTIVE_LENGTH} caracteres`);
|
||||
return objective;
|
||||
}
|
||||
|
||||
export async function proposeNewProject({ objective, startDate, lang }, user) {
|
||||
requireCreator(user);
|
||||
const goal = parseObjective(objective);
|
||||
const start = parseStartDate(startDate);
|
||||
const [types] = await pool.query('SELECT id, name FROM project_types ORDER BY name');
|
||||
const typeNames = types.map((t) => t.name);
|
||||
const result = await askStructured({
|
||||
system: `${PLANNER_SYSTEM(languageFor(lang))} Si el texto del usuario NO describe un proyecto o iniciativa de trabajo (saludos, preguntas generales, chistes, temas ajenos), devuelve isProject=false y no inventes nada.`,
|
||||
prompt: [
|
||||
`Objetivo del proyecto descrito por el usuario: """${goal}"""`,
|
||||
`Fecha de inicio: ${isoDate(start)}`,
|
||||
typeNames.length ? `Tipos de proyecto disponibles (elige uno exacto): ${typeNames.join('; ')}` : '',
|
||||
`Si es un proyecto: propone un nombre corto (max 80 caracteres); un objetivo (una sola oracion que empiece con verbo en infinitivo y diga QUE se quiere lograr y para que, max 250 caracteres); una descripcion (alcance y contexto: que incluye y como se hara, 2-3 oraciones); y entre 3 y ${MAX_ACTIVITIES} actividades.`,
|
||||
].filter(Boolean).join('\n'),
|
||||
toolName: 'propuesta_de_proyecto',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
isProject: { type: 'boolean' },
|
||||
name: { type: 'string' },
|
||||
objective: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
projectType: typeNames.length ? { type: 'string', enum: typeNames } : { type: 'string' },
|
||||
activities: ACTIVITIES_SCHEMA,
|
||||
},
|
||||
required: ['isProject'],
|
||||
},
|
||||
maxTokens: 4000,
|
||||
});
|
||||
await logAudit({ userId: user.id, action: 'ai_new_project', entityType: 'project', entityId: null });
|
||||
if (!result.isProject) throw new HttpError(422, 'Solo puedo ayudarte a crear proyectos. Describe el objetivo de un proyecto de trabajo.');
|
||||
const plan = cleanPlan(result.activities);
|
||||
if (!plan.length) throw new HttpError(422, 'No se pudo generar un plan con tareas para ese objetivo. Intenta describirlo con mas detalle.');
|
||||
const type = types.find((t) => t.name === result.projectType);
|
||||
return {
|
||||
name: clip(result.name, 80) || goal.slice(0, 80),
|
||||
// El texto del usuario suele ser corto: la IA lo redacta como objetivo y deja el alcance en la descripcion.
|
||||
objective: clip(result.objective, 500) || goal,
|
||||
description: clip(result.description, 1000),
|
||||
projectTypeId: type?.id || null,
|
||||
projectTypeName: type?.name || null,
|
||||
estimatedEndDate: isoDate(planEndDate(plan, start)),
|
||||
activities: plan,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNewProject(payload, user) {
|
||||
requireCreator(user);
|
||||
const objective = parseObjective(payload.objective);
|
||||
const start = parseStartDate(payload.startDate);
|
||||
const name = clip(payload.name, 200);
|
||||
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const plan = cleanPlan(payload.activities);
|
||||
if (!plan.length) throw new HttpError(400, 'Selecciona al menos una actividad con tareas');
|
||||
const departmentId = Number(payload.requestingDepartmentId);
|
||||
const [[department]] = await pool.query('SELECT id FROM departments WHERE id = ?', [departmentId]);
|
||||
if (!department) throw new HttpError(400, 'Departamento solicitante invalido');
|
||||
const [[type]] = payload.projectTypeId ? await pool.query('SELECT id FROM project_types WHERE id = ?', [Number(payload.projectTypeId)]) : [[null]];
|
||||
const [priorities] = await pool.query('SELECT id FROM priorities ORDER BY level, id');
|
||||
// Estado "Planificado" si existe; si no, el primer estado abierto.
|
||||
const [statuses] = await pool.query(
|
||||
"SELECT id FROM project_statuses WHERE is_closed = 0 ORDER BY (name LIKE 'Planific%' OR name LIKE 'Plann%') DESC, sort_order, id LIMIT 1"
|
||||
);
|
||||
if (!priorities.length || !statuses.length) throw new HttpError(400, 'Faltan prioridades o estados de proyecto en Configuracion');
|
||||
|
||||
const project = await createProject({
|
||||
name,
|
||||
description: clip(payload.description, 1000),
|
||||
objective,
|
||||
projectTypeId: type?.id || null,
|
||||
priorityId: priorities[Math.floor((priorities.length - 1) / 2)].id,
|
||||
statusId: statuses[0].id,
|
||||
ownerUserId: user.id,
|
||||
requestingDepartmentId: department.id,
|
||||
startDate: isoDate(start),
|
||||
estimatedEndDate: isoDate(planEndDate(plan, start)),
|
||||
}, user);
|
||||
await createPlanActivities(project.id, plan, start, user, user.id);
|
||||
await logAudit({ userId: user.id, action: 'ai_create_project', entityType: 'project', entityId: project.id });
|
||||
return { id: project.id, code: project.code };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user