diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e3ed1..dc4eaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog - project.linkedcorp.net +## [4.0.1] - 2026-10-23 +### Added +- "Nuevo con IA" avisa si ya existen proyectos parecidos (visibles para el usuario, fuera de la + papelera), con enlace para abrirlos. Comparación por palabras clave sin IA ni tokens: ignora + tildes/mayúsculas y palabras vacías, une singular/plural y aplica equivalencias de TI + (helpdesk = mesa de ayuda, PC = equipo, etc.). El usuario decide si crea igual. +### Changed +- La IA redacta por separado el objetivo (una oración: qué y para qué) y la descripción (alcance); + ambos se ven en la revisión. Antes el texto del usuario quedaba como objetivo. + ## [4.0.0] - 2026-10-23 ### Added - Asistente "Nuevo con IA" en Proyectos (roles que crean proyectos, con la IA configurada): diff --git a/VERSION b/VERSION index fcdb2e1..1454f6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.0 +4.0.1 diff --git a/backend/package.json b/backend/package.json index 207dd1a..2e9716f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.0.0", + "version": "4.0.1", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/ai/service.js b/backend/src/modules/ai/service.js index 28d9205..f341fc7 100644 --- a/backend/src/modules/ai/service.js +++ b/backend/src/modules/ai/service.js @@ -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 })); +} diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index 867653a..9d10f2d 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -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 diff --git a/backend/src/utils/similarity.js b/backend/src/utils/similarity.js new file mode 100644 index 0000000..5995c70 --- /dev/null +++ b/backend/src/utils/similarity.js @@ -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; +} diff --git a/frontend/package.json b/frontend/package.json index 26c7d3e..74dc2a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.0.0", + "version": "4.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 92701ec..bad0c39 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -39,6 +39,7 @@ export const messages = { ai_new_project_start: 'Fecha de inicio', ai_new_project_fixed: 'El proyecto se crea en estado Planificado, contigo como responsable y único miembro. La IA propone el nombre, las actividades y sus tareas; podrás revisarlos antes de crear.', ai_new_project_type: 'Tipo', + ai_new_project_similar: 'Ya existen proyectos parecidos; revisa que no sea el mismo (se abren en otra pestaña):', ai_new_project_goal: 'Objetivo', ai_new_project_desc: 'Descripción', ai_new_project_end: 'Fin estimado', @@ -600,6 +601,7 @@ export const messages = { ai_new_project_start: 'Start date', ai_new_project_fixed: 'The project is created as Planned, with you as owner and only member. AI proposes the name, activities and tasks; you will review them before creating.', ai_new_project_type: 'Type', + ai_new_project_similar: 'Similar projects already exist; check it is not the same one (opens in a new tab):', ai_new_project_goal: 'Goal', ai_new_project_desc: 'Description', ai_new_project_end: 'Estimated end', diff --git a/frontend/src/pages/Proyectos/NewProjectAiDialog.jsx b/frontend/src/pages/Proyectos/NewProjectAiDialog.jsx index b837486..faac9ee 100644 --- a/frontend/src/pages/Proyectos/NewProjectAiDialog.jsx +++ b/frontend/src/pages/Proyectos/NewProjectAiDialog.jsx @@ -92,6 +92,14 @@ export function NewProjectAiDialog({ open, deptOptions, minLength = 20, onClose, ) : ( <> + {proposal.similarProjects?.length > 0 && ( + + {t('ai_new_project_similar')}{' '} + {proposal.similarProjects.map((p, i) => ( + {i > 0 && ', '}{p.code} {p.name} ({p.statusName}) + ))} + + )} setName(e.target.value)} />