diff --git a/src/services/project-analyzer.ts b/src/services/project-analyzer.ts index 216a857..a8eb31c 100644 --- a/src/services/project-analyzer.ts +++ b/src/services/project-analyzer.ts @@ -2,7 +2,6 @@ import { callAI } from '@/services/ai' import { getPrompt } from '@/services/prompts-db' import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db' import { saveDraft, createDraftId } from '@/services/hu-drafts-db' -import { generateAndSavePlan } from '@/services/qa-analyzer' import type { EnrichedUserStory } from '@/stores/workitems' export interface AnalysisHU { @@ -10,6 +9,11 @@ export interface AnalysisHU { description: string acceptance_criteria: string[] priority: string + story_points?: number + type?: string + feature?: string + sprint?: number + epicName?: string // nombre de la épica a la que pertenece } export interface AnalysisEpic { @@ -20,18 +24,18 @@ export interface AnalysisEpic { estimatedEnd?: string } -interface AnalysisResult { - hus: AnalysisHU[] +interface AnalysisEpicsResult { epics: AnalysisEpic[] summary: string + rationale: string // explicación de por qué estas épicas +} + +interface AnalysisHUsResult { + hus: AnalysisHU[] + summary: string } -export async function analyzeProject( - projectId: number, - projectName: string, - existingHUs: EnrichedUserStory[], - signal?: AbortSignal, -): Promise { +async function buildProjectContext(projectId: number, projectName: string, existingHUs: EnrichedUserStory[]) { const sessions = await getSessionsByProject(projectId) const state = await getProjectState(projectId) @@ -48,9 +52,9 @@ export async function analyzeProject( }) } - const context = { + return { projectName, - existingHUs: existingHUs.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority })), + existingHUs: existingHUs.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority, e: h._assignedName || '' })), sessions: sessionsWithSummaries.slice(-10).reverse(), projectState: state ? { summary: state.summary?.slice(0, 500), @@ -58,39 +62,150 @@ export async function analyzeProject( 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)}` +// ─── FASE 1: Generar solo Épicas ────────────────────── - console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`) +export async function analyzeProjectEpics( + projectId: number, + projectName: string, + existingHUs: EnrichedUserStory[], + existingEpics: string[], // épicas que ya están en KAPPA + signal?: AbortSignal, +): Promise { + const context = await buildProjectContext(projectId, projectName, existingHUs) + + const userContent = `Contexto del proyecto:\n${JSON.stringify(context, null, 0)}\n\nÉpicas ya existentes en KAPPA: ${existingEpics.join(', ') || 'Ninguna'}` + + console.log(`[Alpha] Phase 1 — Analyzing epics for ${projectName}, ${existingEpics.length} existentes`) const systemPrompt = await getPrompt('project_gap') + // Pasamos una instrucción adicional para que solo genere épicas en esta fase + const phasePrompt = `[FASE 1: SOLO ÉPICAS] +Analizá TODO el contexto del proyecto. +Identificá las épicas necesarias. Una épica agrupa funcionalidades de un mismo tema (ej: "Módulo de Pagos", "Scrapers", "Dashboard"). +Revisá si las épicas ya existentes en KAPPA cubren las necesidades. +Si una épica ya existe en KAPPA, NO la generes de nuevo. +Si el acta de inicio, sesiones o documentación mencionan funcionalidades que no están cubiertas por ninguna épica existente, proponé nuevas épicas. +Para cada épica, listá los títulos tentativos de las HUs que pertenecerían a ella (linkedHuTitles). + +${systemPrompt} + +IMPORTANTE: Respondé SOLO con épicas en esta fase. NO generes HUs aún.` + const content = await callAI( - [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], + [{ role: 'system', content: phasePrompt }, { 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} HUs, ${result.epics?.length || 0} épicas`) + const parsed = JSON.parse(jsonStr) + const result: AnalysisEpicsResult = { + epics: parsed.epics || parsed.epic || [], + summary: parsed.summary || '', + rationale: parsed.rationale || parsed.summary || '', + } + console.log(`[Alpha] Phase 1 complete: ${result.epics.length} épicas 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') + console.error('[Alpha] Failed to parse epics analysis. Raw:', content) + throw new Error('No se pudieron generar las épicas') } } -export async function saveAsDrafts( +// ─── FASE 2: Generar HUs dentro de las épicas confirmadas ── + +export async function analyzeProjectHUs( projectId: number, - analysis: AnalysisResult, + projectName: string, + existingHUs: EnrichedUserStory[], + confirmedEpics: AnalysisEpic[], // épicas que el usuario aceptó y ya están o serán enviadas a KAPPA + signal?: AbortSignal, +): Promise { + const context = await buildProjectContext(projectId, projectName, existingHUs) + + const epicsDetail = confirmedEpics.map(e => + `- ${e.name}: ${e.description?.slice(0, 200)} (HUs sugeridas: ${e.linkedHuTitles.join(', ')})` + ).join('\n') + + const userContent = `Contexto del proyecto:\n${JSON.stringify(context, null, 0)}\n\nÉpicas confirmadas:\n${epicsDetail}` + + console.log(`[Alpha] Phase 2 — Generating HUs for ${confirmedEpics.length} epics in ${projectName}`) + + const systemPrompt = await getPrompt('project_gap') + const phasePrompt = `[FASE 2: GENERAR HUs] +Las siguientes épicas ya están definidas. Tu tarea es generar las HUs (feature, task, US, bug, spike, etc.) que pertenecen a cada épica. +Cada HU debe estar vinculada a UNA épica existente. +Usá el campo "epicName" para indicar a qué épica pertenece cada HU. +No generes HUs duplicadas con las que ya existen en KAPPA. +Incluí para cada HU: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado. + +${systemPrompt} + +IMPORTANTE: Respondé SOLO con HUs en esta fase. NO generes épicas nuevas.` + + const content = await callAI( + [{ role: 'system', content: phasePrompt }, { role: 'user', content: userContent }], + 0.3, 8192, signal, + ) + + try { + const jsonStr = extractJSON(content) + const parsed = JSON.parse(jsonStr) + const result: AnalysisHUsResult = { + hus: parsed.hus || [], + summary: parsed.summary || '', + } + console.log(`[Alpha] Phase 2 complete: ${result.hus.length} HUs generadas`) + return result + } catch (e) { + console.error('[Alpha] Failed to parse HUs analysis. Raw:', content) + throw new Error('No se pudieron generar las HUs') + } +} + +// ─── Guardar drafts ─────────────────────────────────── + +export async function saveEpicDrafts( + projectId: number, + epics: AnalysisEpic[], existingHUs: EnrichedUserStory[], - sourceSessionId?: number, ): Promise<{ saved: number; skipped: number }> { let saved = 0 let skipped = 0 - // Guardar HUs - for (const hu of analysis.hus || []) { + for (const epic of epics) { + const normalizedName = epic.name.toLowerCase().trim() + const isDuplicate = existingHUs.some(ex => (ex._cleanTitle || ex.title).toLowerCase().trim() === normalizedName) + if (isDuplicate) { skipped++; continue } + + await saveDraft({ + id: createDraftId(), projectId, title: epic.name, + description: epic.description, acceptanceCriteria: '', + priority: 'Media', type: 'E', + metadata: JSON.stringify({ + linkedHuTitles: epic.linkedHuTitles, + estimatedStart: epic.estimatedStart, + estimatedEnd: epic.estimatedEnd, + }), + syncStatus: 'draft', createdAt: new Date().toISOString(), + }) + saved++ + } + + return { saved, skipped } +} + +export async function saveHUDrafts( + projectId: number, + hus: AnalysisHU[], + existingHUs: EnrichedUserStory[], +): Promise<{ saved: number; skipped: number }> { + let saved = 0 + let skipped = 0 + + for (const hu of hus) { const normalizedTitle = hu.title.toLowerCase().trim() const isDuplicate = existingHUs.some(ex => { const et = (ex._cleanTitle || ex.title).toLowerCase().trim() @@ -102,27 +217,14 @@ export async function saveAsDrafts( await saveDraft({ id: draftId, projectId, title: hu.title, description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'), - priority: hu.priority, type: 'U', metadata: '{}', - sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(), - }) - // QA plan: fire-and-forget para no bloquear el guardado - generateAndSavePlan(projectId, draftId, hu.title, hu.description, hu.acceptance_criteria.join('\n')) - .catch(e => console.error(`[Alpha] QA auto-gen failed for ${hu.title}:`, e)) - saved++ - } - - // Guardar épicas - for (const epic of analysis.epics || []) { - const normalizedName = epic.name.toLowerCase().trim() - const isDuplicate = existingHUs.some(ex => (ex._cleanTitle || ex.title).toLowerCase().trim() === normalizedName) - if (isDuplicate) { skipped++; continue } - - await saveDraft({ - id: createDraftId(), projectId, title: epic.name, - description: epic.description, acceptanceCriteria: '', - priority: 'Media', type: 'E', - metadata: JSON.stringify({ linkedHuTitles: epic.linkedHuTitles, estimatedStart: epic.estimatedStart, estimatedEnd: epic.estimatedEnd }), - sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(), + priority: hu.priority, type: hu.type === 'task' ? 'T' : hu.type === 'bug' ? 'B' : hu.type === 'feature' ? 'F' : 'U', + metadata: JSON.stringify({ + epicName: hu.epicName, + feature: hu.feature, + sprint: hu.sprint, + storyPoints: hu.story_points, + }), + syncStatus: 'draft', createdAt: new Date().toISOString(), }) saved++ } diff --git a/src/services/prompts-db.ts b/src/services/prompts-db.ts index 492552e..5ca688c 100644 --- a/src/services/prompts-db.ts +++ b/src/services/prompts-db.ts @@ -40,38 +40,65 @@ Responde SOLO con JSON válido en este formato: }, project_gap: { label: 'Análisis de brechas del proyecto', - content: `Eres un analista funcional experto. Tu tarea es analizar TODO el contexto de un proyecto y generar las Épicas e Historias de Usuario (HUs) que faltan. + content: `Eres un analista funcional experto en metodologías ágiles. +Trabajás en DOS FASES separadas. Cada fase tiene sus propias instrucciones. -Reglas: +Reglas generales: 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 -3. Agrupá HUs relacionadas en Épicas. Cada épica agrupa funcionalidades de un mismo tema -4. Cada HU debe tener: título claro, descripción, criterios de aceptación verificables -5. No generes duplicados. Compará con la lista existente -6. Priorizá según urgencia implícita (Alta/Media/Baja) -7. Si todo ya está cubierto, devolvé arreglos vacíos -8. Respondé SOLO con JSON válido +3. No generes duplicados. Compará con TODO lo que ya existe +4. Respondé SOLO con JSON válido +5. Si todo ya está cubierto, devolvé arreglos vacíos -Formato de respuesta: +--- FASE 1: Generar Épicas --- +Instrucciones específicas: +- Identificá las épicas necesarias agrupando funcionalidades por tema +- Revisá si las épicas ya existentes cubren las necesidades +- Si una épica ya existe en KAPPA, NO la generes de nuevo +- Para cada épica, listá los títulos tentativos de las HUs que pertenecerían a ella (linkedHuTitles) +- Incluí un campo "rationale" explicando por qué proponés cada épica +- NO generes HUs en esta fase + +Formato de respuesta FASE 1: { "epics": [ { - "name": "Nombre de la Épica", + "name": "Nombre de la Épica (ej: Módulo de Pagos)", "description": "Descripción de la épica", "linkedHuTitles": ["Título HU 1", "Título HU 2"], "estimatedStart": "YYYY-MM-DD", "estimatedEnd": "YYYY-MM-DD" } ], + "summary": "Resumen del análisis de épicas", + "rationale": "Explicación de por qué estas épicas y no otras" +} + +--- FASE 2: Generar HUs dentro de Épicas --- +Instrucciones específicas: +- Las épicas ya están definidas. Generá las HUs que pertenecen a cada una. +- Cada HU debe tener un campo "epicName" indicando a qué épica pertenece +- Tipos de HU: feature, task, US (historia de usuario), bug, spike +- Incluí: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado +- No generes HUs duplicadas con las existentes +- NO generes épicas en esta fase + +Formato de respuesta FASE 2: +{ "hus": [ { "title": "Título de la HU", "description": "Descripción detallada", "acceptance_criteria": ["Criterio 1", "Criterio 2"], - "priority": "Alta|Media|Baja" + "priority": "Alta|Media|Baja", + "story_points": 3, + "type": "feature|task|bug|spike", + "feature": "Nombre de la feature", + "sprint": 12, + "epicName": "Nombre de la épica a la que pertenece" } ], - "summary": "Resumen del análisis" + "summary": "Resumen de las HUs generadas" }`, }, session: { diff --git a/src/stores/workitems.ts b/src/stores/workitems.ts index bc5b173..6478517 100644 --- a/src/stores/workitems.ts +++ b/src/stores/workitems.ts @@ -177,6 +177,14 @@ export const useWorkItemsStore = defineStore('workitems', () => { // 4. Actualizar UI con datos frescos de KAPPA userStories.value = stories.map(hu => enrichHU(hu, id)) + if (userStories.value.length > 0) { + console.log('[Alpha DEBUG] 1ra HU desc:', { + id: userStories.value[0].id, + title: userStories.value[0].title?.slice(0, 40), + hasDescription: !!userStories.value[0].description, + descLength: userStories.value[0].description?.length, + }) + } epics.value = epicData.map(epic => ({ ...epic, diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index 110fbd9..f9c8806 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import HuDrafts from '@/components/HuDrafts.vue' import AiProjectChat from '@/components/AiProjectChat.vue' -import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer' +import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer' import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db' import { kappa } from '@/services/kappa-api' import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer' @@ -289,7 +289,7 @@ async function pushDraft(d: HuDraftRecord) { let endpoint: string, body: string if (d.type === 'E') { - // Push épica + // Push épica primero — KAPPA asigna el ID const meta = JSON.parse(d.metadata || '{}') const linkedHuIds: number[] = [] for (const huTitle of meta.linkedHuTitles || []) { @@ -308,24 +308,51 @@ async function pushDraft(d: HuDraftRecord) { status: false, }) } else { - // Push HU + // Push HU con epic_development si está vinculada a una épica + const meta = JSON.parse(d.metadata || '{}') + const epicDevId = meta.epicDevelopment || null endpoint = '/api/userstorys/create/' body = JSON.stringify({ - initiative: String(d.projectId), title: d.title, + initiative: String(d.projectId), + title: d.title, description: d.description ? `

${d.description.replace(/\n/g, '

')}

` : '', criterios_aceptacion: d.acceptanceCriteria ? `

${d.acceptanceCriteria.replace(/\n/g, '

')}

` : '', - story_points: String(d.story_points ?? ''), + story_points: d.story_points ?? null, priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2', - sprint: '', asignado_a: [], client_taker: null, - characterization_hu: '', has_impairment: false, - epic_development: null, feature: '', - initial_date: null, end_date: null, + sprint: meta.sprint ?? '', + asignado_a: meta.asignado_a ?? [], + client_taker: meta.clientTaker ?? null, + characterization_hu: meta.characterizationHu ?? '', + has_impairment: false, + epic_development: epicDevId, + feature: meta.feature ?? '', + initial_date: null, + end_date: meta.endDate ?? null, + status: meta.status ?? 1, + is_planned: false, }) } const res = await fetch(endpoint, { method: 'POST', headers, body }) if (res.ok) { - if (d.type !== 'E') { + if (d.type === 'E') { + // Épica creada → capturar ID y vincular HUs pendientes + const created = await res.json() + const epicKappaId = created.id + const meta = JSON.parse(d.metadata || '{}') + for (const huTitle of meta.linkedHuTitles || []) { + const linkedDraft = drafts.value.find(x => + x.type !== 'E' && x.syncStatus === 'draft' && + x.title.toLowerCase().trim() === huTitle.toLowerCase().trim() + ) + if (linkedDraft) { + const linkedMeta = JSON.parse(linkedDraft.metadata || '{}') + linkedMeta.epicDevelopment = String(epicKappaId) + linkedDraft.metadata = JSON.stringify(linkedMeta) + await dbSaveDraft(linkedDraft) + } + } + } else { const created = await res.json() d.kappaId = created.id || undefined } @@ -365,44 +392,118 @@ async function discardDraft(id: string) { await loadDrafts() } -// ─── Project analysis ──────────────────────────────────── +// ─── Project analysis — Two-phase ───────────────────────── +const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle') const analyzing = ref(false) const analysisAbort = ref(null) +const analysisMessage = ref('') const analysisResult = ref<{ saved: number; skipped: number } | null>(null) -const analysisSummary = ref('') function cancelAnalysis() { analysisAbort.value?.abort() analyzing.value = false analysisAbort.value = null - analysisSummary.value = 'Análisis cancelado' + analysisMessage.value = '' + phase.value = 'idle' } -async function runAnalysis() { +// Obtener épicas que ya existen en KAPPA +function getExistingEpicNames(): string[] { + return workItems.epics.map(e => (e._cleanName || e.name || e.title || '').toLowerCase().trim()).filter(Boolean) +} + +// FASE 1: Generar épicas +async function runPhaseEpics() { if (!project.value) return + phase.value = 'epics' analyzing.value = true analysisAbort.value = new AbortController() analysisResult.value = null - analysisSummary.value = '' + analysisMessage.value = 'Analizando contexto y generando épicas...' try { - const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories, analysisAbort.value?.signal) - analysisSummary.value = result.summary + const existingEpics = getExistingEpicNames() + const result = await analyzeProjectEpics( + project.value.id, project.value.name || '', + workItems.userStories, existingEpics, + analysisAbort.value?.signal, + ) + analysisMessage.value = result.rationale - if (result.hus.length > 0) { - const outcome = await saveAsDrafts(project.value.id, result, workItems.userStories) + if (result.epics.length > 0) { + const outcome = await saveEpicDrafts(project.value.id, result.epics, workItems.userStories) analysisResult.value = outcome + await loadDrafts() } else { analysisResult.value = { saved: 0, skipped: 0 } + analysisMessage.value = 'No se identificaron nuevas épicas necesarias.' } } catch (e: any) { if (e.name === 'AbortError' || e.message?.includes('aborted')) { - analysisSummary.value = 'Análisis cancelado' + analysisMessage.value = 'Análisis cancelado' } else { - console.error('[Alpha] Analysis error:', e) - analysisSummary.value = `Error: ${e.message}` + console.error('[Alpha] Phase 1 error:', e) + analysisMessage.value = `Error: ${e.message}` } analysisResult.value = { saved: 0, skipped: 0 } + phase.value = 'idle' + } finally { + analyzing.value = false + } +} + +// FASE 2: Generar HUs dentro de las épicas +async function runPhaseHUs() { + if (!project.value) return + phase.value = 'hus' + analyzing.value = true + analysisAbort.value = new AbortController() + analysisResult.value = null + analysisMessage.value = 'Generando HUs dentro de las épicas...' + + try { + // Obtener las épicas confirmadas desde los drafts de tipo épica + const epicDrafts = drafts.value.filter(d => d.type === 'E') + const confirmedEpics = epicDrafts.map(d => ({ + name: d.title, + description: d.description, + linkedHuTitles: (() => { try { return JSON.parse(d.metadata || '{}').linkedHuTitles || [] } catch { return [] } })(), + })) + + if (confirmedEpics.length === 0) { + analysisMessage.value = 'No hay épicas en borrador. Primero generá y enviá las épicas a KAPPA.' + analysisResult.value = { saved: 0, skipped: 0 } + phase.value = 'idle' + analyzing.value = false + return + } + + const result = await analyzeProjectHUs( + project.value.id, project.value.name || '', + workItems.userStories, confirmedEpics, + analysisAbort.value?.signal, + ) + analysisMessage.value = result.summary + + if (result.hus.length > 0) { + const outcome = await saveHUDrafts(project.value.id, result.hus, workItems.userStories) + analysisResult.value = outcome + await loadDrafts() + phase.value = 'done' + } else { + analysisResult.value = { saved: 0, skipped: 0 } + analysisMessage.value = 'No se identificaron HUs nuevas. Todo parece cubierto.' + phase.value = 'done' + } + } catch (e: any) { + if (e.name === 'AbortError' || e.message?.includes('aborted')) { + analysisMessage.value = 'Análisis cancelado' + } else { + console.error('[Alpha] Phase 2 error:', e) + analysisMessage.value = `Error: ${e.message}` + } + analysisResult.value = { saved: 0, skipped: 0 } + phase.value = 'idle' } finally { analyzing.value = false } @@ -529,45 +630,59 @@ const statusLabel = (status: unknown) => { @navigate-settings="emit('navigate-settings')" /> - + - Análisis completo del proyecto + Análisis del proyecto -
+
+ - -

{{ analysisSummary }}

-
- - {{ analysisResult.saved }} borradores guardados - - - {{ analysisResult.skipped }} duplicadas saltadas - - - Todo ya está cubierto. No se requieren nuevas HUs. - + +
+ + {{ analysisMessage }} +
+
+

{{ analysisMessage }}

+
+ + {{ analysisResult.saved }} borradores guardados + + + {{ analysisResult.skipped }} duplicadas saltadas + + + {{ phase === 'epics' ? 'No se requieren nuevas épicas.' : phase === 'hus' ? 'No se requieren nuevas HUs.' : '' }} + +