refactor: version 4.0.2 -- Duplicados de Nuevo con IA decididos por la IA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user