diff --git a/CHANGELOG.md b/CHANGELOG.md index dc4eaac..5c0d089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog - project.linkedcorp.net +## [4.0.2] - 2026-10-23 +### Changed +- Detección de proyectos duplicados en "Nuevo con IA" ahora la decide la IA, dentro de la misma + llamada de la propuesta: un filtro amplio por palabras clave (sin tokens) elige hasta 20 + candidatos visibles para el usuario y la IA marca solo los que persiguen el mismo objetivo, + aunque usen sinónimos u otra redacción. Costo extra fijo (~300-600 tokens de entrada). + ## [4.0.1] - 2026-10-23 ### Added - "Nuevo con IA" avisa si ya existen proyectos parecidos (visibles para el usuario, fuera de la diff --git a/VERSION b/VERSION index 1454f6e..4d54dad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.1 +4.0.2 diff --git a/backend/package.json b/backend/package.json index 2e9716f..f67cbe7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.0.1", + "version": "4.0.2", "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 f341fc7..4960aca 100644 --- a/backend/src/modules/ai/service.js +++ b/backend/src/modules/ai/service.js @@ -4,7 +4,7 @@ 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, isSimilar } from '../../utils/similarity.js'; +import { keywords, sharedCount } from '../../utils/similarity.js'; import { createActivity } from '../activities/service.js'; import { createTask } from '../tasks/service.js'; @@ -165,6 +165,9 @@ export async function proposeNewProject({ objective, startDate, lang }, 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 findCandidateProjects(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.`, @@ -172,6 +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.` : '', `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', @@ -184,6 +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' } }, }, required: ['isProject'], }, @@ -196,7 +201,9 @@ export async function proposeNewProject({ objective, startDate, lang }, user) { const type = types.find((t) => t.name === result.projectType); const name = clip(result.name, 80) || goal.slice(0, 80); return { - similarProjects: await findSimilarProjects(`${goal} ${name}`, user), + 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 })), 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, @@ -244,10 +251,12 @@ export async function createNewProject(payload, user) { 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) { +// 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. +const MAX_CANDIDATES = 20; +async function findCandidateProjects(text, user) { const target = keywords(text); - if (target.size < 2) return []; + 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 @@ -257,7 +266,8 @@ async function findSimilarProjects(text, user) { 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 })); + .map((r) => ({ ...r, score: sharedCount(target, keywords(`${r.name} ${r.objective || ''}`)) })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_CANDIDATES); } diff --git a/backend/src/utils/similarity.js b/backend/src/utils/similarity.js index 5995c70..a0e1c86 100644 --- a/backend/src/utils/similarity.js +++ b/backend/src/utils/similarity.js @@ -20,9 +20,9 @@ export function keywords(text) { 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) { +// Cantidad de palabras clave compartidas (filtro amplio previo a la IA). +export function sharedCount(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; + return shared; } diff --git a/frontend/package.json b/frontend/package.json index 74dc2a4..e07a69b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.0.1", + "version": "4.0.2", "type": "module", "scripts": { "dev": "vite",