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, createProject, FULL_ACCESS_ROLES } from '../projects/service.js'; import { keywords, sharedCount } from '../../utils/similarity.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 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'], }, }; 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, minObjectiveLength: MIN_OBJECTIVE_LENGTH }; } 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; } 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) => { 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 = clip(description, MAX_OBJECTIVE_LENGTH); const [existing] = await pool.query('SELECT name FROM activities WHERE project_id = ? AND deleted_at IS NULL', [projectId]); const result = await askStructured({ 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 nuevas.`, ].filter(Boolean).join('\n'), toolName: 'plan_de_proyecto', 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) }; } export async function applyProjectPlan(projectId, activities, user) { const project = await getWritableProject(projectId, user); const plan = cleanPlan(activities); if (!plan.length) throw new HttpError(400, 'No hay actividades para crear'); // 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'); // Duplicados: filtro amplio por palabras (sin tokens) y solo los mas cercanos van a la IA, // en la MISMA llamada, para que decida cuales son realmente el mismo proyecto. const candidates = await findCandidates(goal, user); 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('; ')}` : '', candidates.length ? `Trabajo que YA existe (clave | tipo | nombre | proyecto):\n${candidates.map((c) => `${c.key} | ${c.kind} | ${clip(c.itemName, 150)} | ${c.code} ${clip(c.projectName, 100)}${c.kind === 'proyecto' && c.objective ? ` - ${clip(c.objective, 150)}` : ''}`).join('\n')}\nEn duplicateOf pon las claves de lo que YA cubra el MISMO objetivo que pide el usuario (aunque use otras palabras o sinonimos): un proyecto igual, o una actividad/tarea que ya hace eso. No incluyas lo que solo comparte tema.` : '', `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, duplicateOf: { type: 'array', items: { type: 'string' } }, }, 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); const name = clip(result.name, 80) || goal.slice(0, 80); return { similarProjects: candidates .filter((c) => Array.isArray(result.duplicateOf) && result.duplicateOf.includes(c.key)) .map((c) => ({ id: c.projectId, code: c.code, name: c.projectName, statusName: c.statusName, kind: c.kind, itemName: c.itemName })), name, // 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 }; } // Proyectos, actividades y tareas visibles para el usuario (fuera de la papelera) que comparten al // menos una palabra clave con el objetivo; los 20 mas cercanos. Solo SQL + comparacion local. const MAX_CANDIDATES = 20; async function findCandidates(text, user) { const target = keywords(text); if (!target.size) return []; const fullAccess = FULL_ACCESS_ROLES.includes(user.roleCode); const visible = fullAccess ? '' : 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)'; const params = fullAccess ? [] : [user.id]; const base = `FROM projects p JOIN project_statuses ps ON ps.id = p.status_id`; const [projects] = await pool.query( `SELECT 'proyecto' AS kind, p.id AS item_id, p.name AS item_name, p.objective, p.id AS project_id, p.code, p.name AS project_name, ps.name AS status_name ${base} WHERE p.deleted_at IS NULL ${visible}`, params); const [activities] = await pool.query( `SELECT 'actividad' AS kind, a.id AS item_id, a.name AS item_name, NULL AS objective, p.id AS project_id, p.code, p.name AS project_name, ps.name AS status_name ${base} JOIN activities a ON a.project_id = p.id WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL ${visible}`, params); const [tasks] = await pool.query( `SELECT 'tarea' AS kind, t.id AS item_id, t.name AS item_name, NULL AS objective, p.id AS project_id, p.code, p.name AS project_name, ps.name AS status_name ${base} JOIN activities a ON a.project_id = p.id JOIN tasks t ON t.activity_id = a.id WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND t.deleted_at IS NULL ${visible}`, params); const prefix = { proyecto: 'P', actividad: 'A', tarea: 'T' }; return [...projects, ...activities, ...tasks] .map((r) => ({ key: `${prefix[r.kind]}${r.item_id}`, kind: r.kind, itemName: r.item_name, objective: r.objective, projectId: r.project_id, code: r.code, projectName: r.project_name, statusName: r.status_name, score: sharedCount(target, keywords(`${r.item_name} ${r.objective || ''}`)), })) .filter((r) => r.score > 0) .sort((a, b) => b.score - a.score) .slice(0, MAX_CANDIDATES); }