feat: version 4.0.1 -- Nuevo con IA: aviso de proyectos parecidos y objetivo redactado

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 16:38:28 -05:00
parent 4b83a24e4c
commit 7e59f0181d
9 changed files with 75 additions and 6 deletions
+23 -2
View File
@@ -3,7 +3,8 @@ 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 } from '../projects/service.js';
import { getProject, canWriteProject, createProject, FULL_ACCESS_ROLES } from '../projects/service.js';
import { keywords, isSimilar } from '../../utils/similarity.js';
import { createActivity } from '../activities/service.js';
import { createTask } from '../tasks/service.js';
@@ -193,8 +194,10 @@ export async function proposeNewProject({ objective, startDate, lang }, user) {
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 {
name: clip(result.name, 80) || goal.slice(0, 80),
similarProjects: await findSimilarProjects(`${goal} ${name}`, user),
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),
@@ -240,3 +243,21 @@ export async function createNewProject(payload, user) {
await logAudit({ userId: user.id, action: 'ai_create_project', entityType: 'project', entityId: project.id });
return { id: project.id, code: project.code };
}
// Proyectos visibles para el usuario (no en papelera) con nombre/objetivo parecido. Solo SQL.
async function findSimilarProjects(text, user) {
const target = keywords(text);
if (target.size < 2) return [];
const fullAccess = FULL_ACCESS_ROLES.includes(user.roleCode);
const [rows] = await pool.query(
`SELECT p.id, p.code, p.name, p.objective, ps.name AS status_name
FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE p.deleted_at IS NULL
${fullAccess ? '' : 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)'}`,
fullAccess ? [] : [user.id]
);
return rows
.filter((r) => isSimilar(target, keywords(`${r.name} ${r.objective || ''}`)))
.slice(0, 5)
.map((r) => ({ id: r.id, code: r.code, name: r.name, statusName: r.status_name }));
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
const FULL_ACCESS_ROLES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
export const FULL_ACCESS_ROLES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
// Nadie ve ni toca el proyecto de otro dueño sin que se lo compartan -- salvo los roles
// globales de administracion, que siguen viendo todo para poder dar soporte (acordado con el
+28
View File
@@ -0,0 +1,28 @@
// Similitud por palabras clave (sin IA, sin tokens): normaliza tildes/mayusculas, quita palabras
// vacias, une singular/plural y aplica equivalencias comunes de TI.
const STOPWORDS = new Set(('de del la las el los un una unos unas y o a en con para por al lo su sus que se ' +
'implementar implementacion implantar implantacion proyecto sistema solucion nuevo nueva mejorar mejora ' +
'realizar crear desarrollar desarrollo the of and for to a an in').split(' '));
const SYNONYMS = {
helpdesk: 'mesa ayuda', servicedesk: 'mesa ayuda', soporte: 'ayuda',
pc: 'equipo', pcs: 'equipo', computador: 'equipo', computadora: 'equipo', laptop: 'equipo', portatil: 'equipo',
email: 'correo', mail: 'correo', wifi: 'red', lan: 'red', backup: 'respaldo', backups: 'respaldo',
erp: 'erp', crm: 'crm',
};
const stem = (w) => (w.length > 4 && w.endsWith('es') ? w.slice(0, -2) : w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w);
export function keywords(text) {
const words = String(text || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(Boolean)
.flatMap((w) => (SYNONYMS[w] ? SYNONYMS[w].split(' ') : [w]));
return new Set(words.filter((w) => w.length > 2 && !STOPWORDS.has(w)).map(stem));
}
// Parecido si comparten >= 2 palabras clave y al menos la mitad del conjunto mas chico.
export function isSimilar(a, b) {
let shared = 0;
for (const w of a) if (b.has(w)) shared += 1;
return shared >= 2 && shared / Math.max(1, Math.min(a.size, b.size)) >= 0.5;
}