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)} />