|
|
@@ -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');
|
|
|
|
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,
|
|
|
|
// 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.
|
|
|
|
// 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 typeNames = types.map((t) => t.name);
|
|
|
|
const result = await askStructured({
|
|
|
|
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.`,
|
|
|
|
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}"""`,
|
|
|
|
`Objetivo del proyecto descrito por el usuario: """${goal}"""`,
|
|
|
|
`Fecha de inicio: ${isoDate(start)}`,
|
|
|
|
`Fecha de inicio: ${isoDate(start)}`,
|
|
|
|
typeNames.length ? `Tipos de proyecto disponibles (elige uno exacto): ${typeNames.join('; ')}` : '',
|
|
|
|
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.`,
|
|
|
|
`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'),
|
|
|
|
].filter(Boolean).join('\n'),
|
|
|
|
toolName: 'propuesta_de_proyecto',
|
|
|
|
toolName: 'propuesta_de_proyecto',
|
|
|
@@ -188,7 +188,7 @@ export async function proposeNewProject({ objective, startDate, lang }, user) {
|
|
|
|
description: { type: 'string' },
|
|
|
|
description: { type: 'string' },
|
|
|
|
projectType: typeNames.length ? { type: 'string', enum: typeNames } : { type: 'string' },
|
|
|
|
projectType: typeNames.length ? { type: 'string', enum: typeNames } : { type: 'string' },
|
|
|
|
activities: ACTIVITIES_SCHEMA,
|
|
|
|
activities: ACTIVITIES_SCHEMA,
|
|
|
|
duplicateOf: { type: 'array', items: { type: 'integer' } },
|
|
|
|
duplicateOf: { type: 'array', items: { type: 'string' } },
|
|
|
|
},
|
|
|
|
},
|
|
|
|
required: ['isProject'],
|
|
|
|
required: ['isProject'],
|
|
|
|
},
|
|
|
|
},
|
|
|
@@ -202,8 +202,8 @@ export async function proposeNewProject({ objective, startDate, lang }, user) {
|
|
|
|
const name = clip(result.name, 80) || goal.slice(0, 80);
|
|
|
|
const name = clip(result.name, 80) || goal.slice(0, 80);
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
similarProjects: candidates
|
|
|
|
similarProjects: candidates
|
|
|
|
.filter((c) => Array.isArray(result.duplicateOf) && result.duplicateOf.includes(c.id))
|
|
|
|
.filter((c) => Array.isArray(result.duplicateOf) && result.duplicateOf.includes(c.key))
|
|
|
|
.map((c) => ({ id: c.id, code: c.code, name: c.name, statusName: c.status_name })),
|
|
|
|
.map((c) => ({ id: c.projectId, code: c.code, name: c.projectName, statusName: c.statusName, kind: c.kind, itemName: c.itemName })),
|
|
|
|
name,
|
|
|
|
name,
|
|
|
|
// El texto del usuario suele ser corto: la IA lo redacta como objetivo y deja el alcance en la descripcion.
|
|
|
|
// 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,
|
|
|
|
objective: clip(result.objective, 500) || goal,
|
|
|
@@ -251,22 +251,34 @@ export async function createNewProject(payload, user) {
|
|
|
|
return { id: project.id, code: project.code };
|
|
|
|
return { id: project.id, code: project.code };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Proyectos visibles para el usuario (no en papelera) que comparten al menos una palabra clave
|
|
|
|
// Proyectos, actividades y tareas visibles para el usuario (fuera de la papelera) que comparten al
|
|
|
|
// con el objetivo; los 20 mas cercanos. Solo SQL + comparacion local, sin tokens.
|
|
|
|
// menos una palabra clave con el objetivo; los 20 mas cercanos. Solo SQL + comparacion local.
|
|
|
|
const MAX_CANDIDATES = 20;
|
|
|
|
const MAX_CANDIDATES = 20;
|
|
|
|
async function findCandidateProjects(text, user) {
|
|
|
|
async function findCandidates(text, user) {
|
|
|
|
const target = keywords(text);
|
|
|
|
const target = keywords(text);
|
|
|
|
if (!target.size) return [];
|
|
|
|
if (!target.size) return [];
|
|
|
|
const fullAccess = FULL_ACCESS_ROLES.includes(user.roleCode);
|
|
|
|
const fullAccess = FULL_ACCESS_ROLES.includes(user.roleCode);
|
|
|
|
const [rows] = await pool.query(
|
|
|
|
const visible = fullAccess ? '' : 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)';
|
|
|
|
`SELECT p.id, p.code, p.name, p.objective, ps.name AS status_name
|
|
|
|
const params = fullAccess ? [] : [user.id];
|
|
|
|
FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
|
|
|
|
const base = `FROM projects p JOIN project_statuses ps ON ps.id = p.status_id`;
|
|
|
|
WHERE p.deleted_at IS NULL
|
|
|
|
const [projects] = await pool.query(
|
|
|
|
${fullAccess ? '' : 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)'}`,
|
|
|
|
`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
|
|
|
|
fullAccess ? [] : [user.id]
|
|
|
|
${base} WHERE p.deleted_at IS NULL ${visible}`, params);
|
|
|
|
);
|
|
|
|
const [activities] = await pool.query(
|
|
|
|
return rows
|
|
|
|
`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
|
|
|
|
.map((r) => ({ ...r, score: sharedCount(target, keywords(`${r.name} ${r.objective || ''}`)) }))
|
|
|
|
${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)
|
|
|
|
.filter((r) => r.score > 0)
|
|
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
.slice(0, MAX_CANDIDATES);
|
|
|
|
.slice(0, MAX_CANDIDATES);
|
|
|
|