feat: version 4.2.0 -- Informe de estado del proyecto con IA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 18:19:24 -05:00
parent 120e54283e
commit 9d50bc8564
13 changed files with 255 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.1.0",
"version": "4.2.0",
"private": true,
"type": "module",
"main": "src/server.js",
+4
View File
@@ -18,3 +18,7 @@ export const postProposeProject = asyncHandler(async (req, res) => {
export const postCreateProject = asyncHandler(async (req, res) => {
ok(res, await service.createNewProject(req.body || {}, req.currentUser), 201);
});
export const postProjectReport = asyncHandler(async (req, res) => {
ok(res, await service.generateProjectReport(Number(req.params.projectId), req.body?.lang, req.currentUser));
});
+2 -1
View File
@@ -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, postProposeProject, postCreateProject } from './controller.js';
import { getStatus, postProjectPlan, postApplyProjectPlan, postProposeProject, postCreateProject, postProjectReport } from './controller.js';
const router = Router();
@@ -14,6 +14,7 @@ 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/report', requireModule('projects'), aiLimiter, postProjectReport);
router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan);
export default router;
+75
View File
@@ -285,3 +285,78 @@ async function findCandidates(text, user) {
.sort((a, b) => b.score - a.score)
.slice(0, MAX_CANDIDATES);
}
// --- Informe de estado del proyecto ---
// Lee el estado real (actividades, tareas, fechas, avance, comentarios recientes) y la IA redacta
// un informe para jefatura. Solo lectura: no modifica nada. Basta permiso de lectura del proyecto.
const REPORT_SCHEMA = {
type: 'object',
properties: {
summary: { type: 'string' },
health: { type: 'string', enum: ['en_curso', 'en_riesgo', 'atrasado', 'completado', 'sin_iniciar'] },
achievements: { type: 'array', items: { type: 'string' } },
delays: { type: 'array', items: { type: 'object', properties: { item: { type: 'string' }, reason: { type: 'string' } }, required: ['item', 'reason'] } },
risks: { type: 'array', items: { type: 'string' } },
nextSteps: { type: 'array', items: { type: 'string' } },
},
required: ['summary', 'health', 'achievements', 'delays', 'risks', 'nextSteps'],
};
export async function generateProjectReport(projectId, lang, user) {
const project = await getProject(projectId, user);
const today = isoDate(new Date());
const [activities] = await pool.query(
`SELECT a.id, a.name, a.start_date, a.due_date, a.actual_end_date, a.progress_pct, s.name AS status_name, s.is_closed,
u.name AS responsible,
(SELECT COUNT(*) FROM tasks t WHERE t.activity_id = a.id AND t.deleted_at IS NULL) AS tasks_total,
(SELECT COUNT(*) FROM tasks t WHERE t.activity_id = a.id AND t.deleted_at IS NULL AND t.is_done = 1) AS tasks_done,
(SELECT GROUP_CONCAT(t.name SEPARATOR '; ') FROM tasks t WHERE t.activity_id = a.id AND t.deleted_at IS NULL
AND t.is_done = 0 AND t.due_date < CURDATE()) AS overdue_tasks
FROM activities a JOIN activity_statuses s ON s.id = a.status_id LEFT JOIN users u ON u.id = a.responsible_user_id
WHERE a.project_id = ? AND a.deleted_at IS NULL ORDER BY a.sort_order, a.id`,
[projectId]
);
const [comments] = await pool.query(
`SELECT c.message, c.created_at, u.name AS author FROM comments c LEFT JOIN users u ON u.id = c.user_id
WHERE (c.entity_type = 'project' AND c.entity_id = ?)
OR (c.entity_type = 'activity' AND c.entity_id IN (SELECT id FROM activities WHERE project_id = ? AND deleted_at IS NULL))
ORDER BY c.created_at DESC LIMIT 15`,
[projectId, projectId]
);
const fmt = (d) => (d ? String(d).slice(0, 10) : '-');
const lines = activities.map((a) => [
`- ${clip(a.name, 150)} | estado: ${a.status_name} | avance: ${a.progress_pct || 0}% | ${fmt(a.start_date)} -> ${fmt(a.due_date)}`,
a.actual_end_date ? ` | terminada: ${fmt(a.actual_end_date)}` : '',
` | responsable: ${a.responsible || 'sin asignar'} | tareas: ${a.tasks_done}/${a.tasks_total}`,
a.overdue_tasks ? ` | tareas vencidas: ${clip(a.overdue_tasks, 300)}` : '',
].join(''));
const result = await askStructured({
system: `Eres un jefe de proyectos de TI que redacta informes de estado claros y honestos para la jefatura. Usa solo los datos dados, no inventes hechos ni cifras. Se concreto: menciona actividades y fechas. Responde en ${languageFor(lang)}.`,
prompt: [
`Fecha de hoy: ${today}`,
`Proyecto: ${project.code} ${project.name} | estado: ${project.status_name || '-'} | avance: ${project.progress_pct ?? 0}%`,
`Fechas: inicio ${fmt(project.start_date)}, fin estimado ${fmt(project.estimated_end_date)}`,
project.objective ? `Objetivo: ${clip(project.objective, 500)}` : '',
activities.length ? `Actividades:\n${lines.join('\n')}` : 'El proyecto aun no tiene actividades.',
comments.length ? `Comentarios recientes:\n${comments.map((c) => `- ${fmt(c.created_at)} ${c.author || ''}: ${clip(c.message, 300)}`).join('\n')}` : '',
'Redacta: summary (3-5 oraciones con la situacion general), health, achievements (lo completado o avanzado), delays (lo atrasado o vencido con su motivo probable segun los datos), risks y nextSteps (acciones concretas para la proxima semana). Listas vacias si no aplica.',
].filter(Boolean).join('\n'),
toolName: 'informe_de_estado',
schema: REPORT_SCHEMA,
maxTokens: 8000,
usage: { userId: user.id, action: 'project_report' },
});
await logAudit({ userId: user.id, action: 'ai_report', entityType: 'project', entityId: projectId });
const list = (v) => (Array.isArray(v) ? v.map((x) => clip(x, 500)).filter(Boolean).slice(0, 15) : []);
return {
project: { code: project.code, name: project.name },
generatedAt: new Date().toISOString(),
summary: clip(result.summary, 2000),
health: result.health,
achievements: list(result.achievements),
delays: (Array.isArray(result.delays) ? result.delays : []).slice(0, 15).map((d) => ({ item: clip(d?.item, 300), reason: clip(d?.reason, 500) })).filter((d) => d.item),
risks: list(result.risks),
nextSteps: list(result.nextSteps),
};
}