|
|
|
@@ -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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|