diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3ed06..05cd39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog - project.linkedcorp.net +## [4.0.4] - 2026-10-23 +### Changed +- "Nuevo con IA" también detecta cuando lo pedido ya existe como actividad o tarea dentro de otro + proyecto (no solo proyectos): el filtro por palabras clave reúne proyectos, actividades y + tareas visibles (máx. 20 candidatos) y la IA marca lo que ya cubre el mismo objetivo. El aviso + indica el tipo y el proyecto ("tarea «...» en PRY-0005 ..."). Sigue en la misma llamada. + ## [4.0.3] - 2026-10-23 ### Fixed - Las consultas a la IA ("Nuevo con IA" y "Plan con IA") muestran un indicador de proceso con diff --git a/VERSION b/VERSION index c4e41f9..c5106e6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.3 +4.0.4 diff --git a/backend/package.json b/backend/package.json index 3d45691..bd681c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.0.3", + "version": "4.0.4", "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 4960aca..6e4034e 100644 --- a/backend/src/modules/ai/service.js +++ b/backend/src/modules/ai/service.js @@ -167,7 +167,7 @@ export async function proposeNewProject({ objective, startDate, lang }, user) { 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 findCandidateProjects(goal, user); + 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.`, @@ -175,7 +175,7 @@ export async function proposeNewProject({ objective, startDate, lang }, user) { `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 ? `Proyectos que YA existen (id | nombre | objetivo):\n${candidates.map((c) => `${c.id} | ${c.name} | ${clip(c.objective, 200)}`).join('\n')}\nEn duplicateOf pon los id de los que persigan el MISMO objetivo que el usuario (aunque usen otras palabras o sinonimos); no incluyas los que solo comparten tema.` : '', + 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', @@ -188,7 +188,7 @@ export async function proposeNewProject({ objective, startDate, lang }, user) { description: { type: 'string' }, projectType: typeNames.length ? { type: 'string', enum: typeNames } : { type: 'string' }, activities: ACTIVITIES_SCHEMA, - duplicateOf: { type: 'array', items: { type: 'integer' } }, + duplicateOf: { type: 'array', items: { type: 'string' } }, }, required: ['isProject'], }, @@ -202,8 +202,8 @@ export async function proposeNewProject({ objective, startDate, lang }, user) { const name = clip(result.name, 80) || goal.slice(0, 80); return { similarProjects: candidates - .filter((c) => Array.isArray(result.duplicateOf) && result.duplicateOf.includes(c.id)) - .map((c) => ({ id: c.id, code: c.code, name: c.name, statusName: c.status_name })), + .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, @@ -251,22 +251,34 @@ export async function createNewProject(payload, user) { return { id: project.id, code: project.code }; } -// Proyectos visibles para el usuario (no en papelera) que comparten al menos una palabra clave -// con el objetivo; los 20 mas cercanos. Solo SQL + comparacion local, sin tokens. +// 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 findCandidateProjects(text, user) { +async function findCandidates(text, user) { const target = keywords(text); if (!target.size) 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 - .map((r) => ({ ...r, score: sharedCount(target, keywords(`${r.name} ${r.objective || ''}`)) })) + 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); diff --git a/frontend/package.json b/frontend/package.json index c1bf081..6592190 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.0.3", + "version": "4.0.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index ac81196..85a5149 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -42,7 +42,10 @@ 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_similar_actividad: 'actividad', + ai_similar_tarea: 'tarea', + ai_similar_in: 'en', + ai_new_project_similar: 'Esto ya existe o se parece a trabajo existente; revisa antes de crear (se abre en otra pestaña):', ai_new_project_goal: 'Objetivo', ai_new_project_desc: 'Descripción', ai_new_project_end: 'Fin estimado', @@ -607,7 +610,10 @@ 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_similar_actividad: 'activity', + ai_similar_tarea: 'task', + ai_similar_in: 'in', + ai_new_project_similar: 'This already exists or resembles existing work; check before creating (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 246bb12..96e7de4 100644 --- a/frontend/src/pages/Proyectos/NewProjectAiDialog.jsx +++ b/frontend/src/pages/Proyectos/NewProjectAiDialog.jsx @@ -98,7 +98,10 @@ export function NewProjectAiDialog({ open, deptOptions, minLength = 20, onClose, {t('ai_new_project_similar')}{' '} {proposal.similarProjects.map((p, i) => ( - {i > 0 && ', '}{p.code} {p.name} ({p.statusName}) + {i > 0 && '; '} + {p.kind !== 'proyecto' && <>{t(`ai_similar_${p.kind}`)} «{p.itemName}» {t('ai_similar_in')} } + {p.code} {p.name} ({p.statusName}) + ))} )}