project-analyzer: analisis completo con contexto global + dedup

- services/project-analyzer.ts: analiza sesiones + resumenes + HUs existentes
- generateMissingHUs: proposicion inteligente sin duplicados
- createMissingHUs: crea en KAPPA solo las que no existen (comparacion por titulo)
- AiProjectChat: sin limite de HUs en contexto JSON compacto
- DashboardView: card 'Generar HUs faltantes' con resultado
- ai.ts: stripThinkTags() para eliminar bloques de razonamiento
This commit is contained in:
2026-05-28 14:19:13 -05:00
parent e950eb1285
commit eb4fae78b3
3 changed files with 279 additions and 54 deletions
+172
View File
@@ -0,0 +1,172 @@
import { callAI } from '@/services/ai'
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
import { kappa } from '@/services/kappa-api'
import type { EnrichedUserStory } from '@/stores/workitems'
interface AnalysisHU {
title: string
description: string
acceptance_criteria: string[]
priority: string
}
interface AnalysisResult {
hus: AnalysisHU[]
summary: string
}
const ANALYSIS_SYSTEM_PROMPT = `Eres un analista funcional experto. Tu tarea es analizar TODO el contexto de un proyecto y generar las Historias de Usuario (HUs) que faltan.
Reglas:
1. Analizá TODA la información disponible: sesiones, resúmenes, estado del proyecto, HUs existentes
2. Identificá requisitos, funcionalidades, mejoras o bugs que NO estén cubiertos por las HUs existentes
3. Cada HU debe tener: título claro, descripción detallada, criterios de aceptación verificables
4. No generes HUs duplicadas. Compará con la lista de HUs existentes
5. Priorizá según urgencia implícita (Alta/Media/Baja)
6. Si todo ya está cubierto, devolvé un arreglo vacío
7. Respondé SOLO con JSON válido
Contexto recibido en JSON:
- existingHUs: HUs que ya existen en el proyecto (NO repetir)
- sessions: sesiones registradas con resúmenes
- projectState: estado consolidado del proyecto
Formato de respuesta:
{
"hus": [
{
"title": "Título de la HU",
"description": "Descripción detallada",
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
"priority": "Alta|Media|Baja"
}
],
"summary": "Resumen del análisis: cuántas HUs se crearon y por qué"
}`
export async function analyzeProject(
projectId: number,
projectName: string,
existingHUs: EnrichedUserStory[],
signal?: AbortSignal,
): Promise<AnalysisResult> {
// 1. Recolectar todo el contexto del proyecto
const sessions = await getSessionsByProject(projectId)
const state = await getProjectState(projectId)
// 2. Construir context compacto en JSON
const sessionsWithSummaries = []
for (const s of sessions) {
const summary = await getSessionSummary(s.id!)
sessionsWithSummaries.push({
date: s.date,
title: s.title,
file: s.fileName,
summary: summary?.summary?.slice(0, 500) || '',
tasks: (safeParse<{ description: string; priority: string }[]>(summary?.tasks, [])).map(t => ({ d: t.description, p: t.priority })),
decisions: safeParse<string[]>(summary?.decisions, []),
})
}
const context = {
projectName,
existingHUs: existingHUs.map(h => ({
t: h._cleanTitle || h.title,
s: h.status,
p: h.priority,
})),
sessions: sessionsWithSummaries.slice(-10).reverse(),
projectState: state ? {
summary: state.summary?.slice(0, 500),
objectives: safeParse<string[]>(state.objectives, []),
tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30),
} : null,
}
const userContent = `Contexto completo del proyecto en JSON:\n${JSON.stringify(context, null, 0)}`
console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`)
const content = await callAI(
[
{ role: 'system', content: ANALYSIS_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
0.3,
8192,
signal,
)
try {
const jsonStr = extractJSON(content)
const result: AnalysisResult = JSON.parse(jsonStr)
console.log(`[Alpha] Analysis result: ${result.hus.length} nuevas HUs propuestas`)
return result
} catch (e) {
console.error('[Alpha] Failed to parse analysis. Raw:', content)
throw new Error('No se pudo procesar el análisis del proyecto')
}
}
/**
* Crea las HUs propuestas en KAPPA (solo las que no existen).
* Devuelve cuántas se crearon.
*/
export async function createMissingHUs(
projectId: number,
analysis: AnalysisResult,
existingHUs: EnrichedUserStory[],
): Promise<{ created: number; skipped: number; errors: string[] }> {
let created = 0
let skipped = 0
const errors: string[] = []
const existingTitles = new Set(existingHUs.map(h => (h._cleanTitle || h.title).toLowerCase().trim()))
for (const hu of analysis.hus) {
const normalizedTitle = hu.title.toLowerCase().trim()
// Verificar duplicado por título similar
const isDuplicate = existingHUs.some(ex => {
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et)
})
if (isDuplicate) {
skipped++
continue
}
try {
await kappa.createUserStory({
title: hu.title,
description: hu.description + (hu.acceptance_criteria.length > 0
? `\n\n**Criterios de aceptación:**\n${hu.acceptance_criteria.map(c => `- ${c}`).join('\n')}`
: ''),
initiative: projectId,
priority: hu.priority || 'Media',
status: 'todo',
})
created++
} catch (e: any) {
errors.push(`${hu.title}: ${e.message}`)
}
}
return { created, skipped, errors }
}
function extractJSON(text: string): string {
try { JSON.parse(text); return text } catch {}
const block = text.match(/```(?:json)?\s*([\s\S]*?)```/)
if (block) { try { JSON.parse(block[1].trim()); return block[1].trim() } catch {} }
const first = text.indexOf('{')
const last = text.lastIndexOf('}')
if (first !== -1 && last > first) { const c = text.slice(first, last + 1); try { JSON.parse(c); return c } catch {} }
return text
}
function safeParse<T>(json: string | undefined | null, fallback: T): T {
if (!json) return fallback
try { return JSON.parse(json) as T } catch { return fallback }
}