Análisis IA en dos fases: épicas primero, luego HUs vinculadas con epic_development
- project-analyzer.ts: dividido en analyzeProjectEpics() y analyzeProjectHUs() Fase 1: genera solo épicas con linkedHuTitles Fase 2: genera HUs dentro de épicas con epicName - saveEpicDrafts / saveHUDrafts separados para cada tipo - DashboardView: dos botones '1. Generar Épicas' y '2. Generar HUs' - pushDraft épica: al crear en KAPPA, actualiza metadata de HUs vinculadas con epicDevelopment - pushDraft HU: envía epic_development + payload completo (feature, sprint, asignado_a, etc.) - project_gap prompt: instrucciones separadas para FASE 1 (épicas) y FASE 2 (HUs)
This commit is contained in:
@@ -2,7 +2,6 @@ import { callAI } from '@/services/ai'
|
|||||||
import { getPrompt } from '@/services/prompts-db'
|
import { getPrompt } from '@/services/prompts-db'
|
||||||
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
||||||
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
||||||
import { generateAndSavePlan } from '@/services/qa-analyzer'
|
|
||||||
import type { EnrichedUserStory } from '@/stores/workitems'
|
import type { EnrichedUserStory } from '@/stores/workitems'
|
||||||
|
|
||||||
export interface AnalysisHU {
|
export interface AnalysisHU {
|
||||||
@@ -10,6 +9,11 @@ export interface AnalysisHU {
|
|||||||
description: string
|
description: string
|
||||||
acceptance_criteria: string[]
|
acceptance_criteria: string[]
|
||||||
priority: 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 {
|
export interface AnalysisEpic {
|
||||||
@@ -20,18 +24,18 @@ export interface AnalysisEpic {
|
|||||||
estimatedEnd?: string
|
estimatedEnd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnalysisResult {
|
interface AnalysisEpicsResult {
|
||||||
hus: AnalysisHU[]
|
|
||||||
epics: AnalysisEpic[]
|
epics: AnalysisEpic[]
|
||||||
summary: string
|
summary: string
|
||||||
|
rationale: string // explicación de por qué estas épicas
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisHUsResult {
|
||||||
|
hus: AnalysisHU[]
|
||||||
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeProject(
|
async function buildProjectContext(projectId: number, projectName: string, existingHUs: EnrichedUserStory[]) {
|
||||||
projectId: number,
|
|
||||||
projectName: string,
|
|
||||||
existingHUs: EnrichedUserStory[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<AnalysisResult> {
|
|
||||||
const sessions = await getSessionsByProject(projectId)
|
const sessions = await getSessionsByProject(projectId)
|
||||||
const state = await getProjectState(projectId)
|
const state = await getProjectState(projectId)
|
||||||
|
|
||||||
@@ -48,9 +52,9 @@ export async function analyzeProject(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = {
|
return {
|
||||||
projectName,
|
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(),
|
sessions: sessionsWithSummaries.slice(-10).reverse(),
|
||||||
projectState: state ? {
|
projectState: state ? {
|
||||||
summary: state.summary?.slice(0, 500),
|
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),
|
tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30),
|
||||||
} : null,
|
} : 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<AnalysisEpicsResult> {
|
||||||
|
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')
|
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(
|
const content = await callAI(
|
||||||
[{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }],
|
[{ role: 'system', content: phasePrompt }, { role: 'user', content: userContent }],
|
||||||
0.3, 8192, signal,
|
0.3, 8192, signal,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jsonStr = extractJSON(content)
|
const jsonStr = extractJSON(content)
|
||||||
const result: AnalysisResult = JSON.parse(jsonStr)
|
const parsed = JSON.parse(jsonStr)
|
||||||
console.log(`[Alpha] Analysis result: ${result.hus.length} HUs, ${result.epics?.length || 0} épicas`)
|
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
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Alpha] Failed to parse analysis. Raw:', content)
|
console.error('[Alpha] Failed to parse epics analysis. Raw:', content)
|
||||||
throw new Error('No se pudo procesar el análisis del proyecto')
|
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,
|
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<AnalysisHUsResult> {
|
||||||
|
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[],
|
existingHUs: EnrichedUserStory[],
|
||||||
sourceSessionId?: number,
|
|
||||||
): Promise<{ saved: number; skipped: number }> {
|
): Promise<{ saved: number; skipped: number }> {
|
||||||
let saved = 0
|
let saved = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
|
|
||||||
// Guardar HUs
|
for (const epic of epics) {
|
||||||
for (const hu of analysis.hus || []) {
|
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 normalizedTitle = hu.title.toLowerCase().trim()
|
||||||
const isDuplicate = existingHUs.some(ex => {
|
const isDuplicate = existingHUs.some(ex => {
|
||||||
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
||||||
@@ -102,27 +217,14 @@ export async function saveAsDrafts(
|
|||||||
await saveDraft({
|
await saveDraft({
|
||||||
id: draftId, projectId, title: hu.title,
|
id: draftId, projectId, title: hu.title,
|
||||||
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
||||||
priority: hu.priority, type: 'U', metadata: '{}',
|
priority: hu.priority, type: hu.type === 'task' ? 'T' : hu.type === 'bug' ? 'B' : hu.type === 'feature' ? 'F' : 'U',
|
||||||
sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
|
metadata: JSON.stringify({
|
||||||
})
|
epicName: hu.epicName,
|
||||||
// QA plan: fire-and-forget para no bloquear el guardado
|
feature: hu.feature,
|
||||||
generateAndSavePlan(projectId, draftId, hu.title, hu.description, hu.acceptance_criteria.join('\n'))
|
sprint: hu.sprint,
|
||||||
.catch(e => console.error(`[Alpha] QA auto-gen failed for ${hu.title}:`, e))
|
storyPoints: hu.story_points,
|
||||||
saved++
|
}),
|
||||||
}
|
syncStatus: 'draft', createdAt: new Date().toISOString(),
|
||||||
|
|
||||||
// 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(),
|
|
||||||
})
|
})
|
||||||
saved++
|
saved++
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-12
@@ -40,38 +40,65 @@ Responde SOLO con JSON válido en este formato:
|
|||||||
},
|
},
|
||||||
project_gap: {
|
project_gap: {
|
||||||
label: 'Análisis de brechas del proyecto',
|
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
|
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
|
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
|
3. No generes duplicados. Compará con TODO lo que ya existe
|
||||||
4. Cada HU debe tener: título claro, descripción, criterios de aceptación verificables
|
4. Respondé SOLO con JSON válido
|
||||||
5. No generes duplicados. Compará con la lista existente
|
5. Si todo ya está cubierto, devolvé arreglos vacíos
|
||||||
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
|
|
||||||
|
|
||||||
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": [
|
"epics": [
|
||||||
{
|
{
|
||||||
"name": "Nombre de la Épica",
|
"name": "Nombre de la Épica (ej: Módulo de Pagos)",
|
||||||
"description": "Descripción de la épica",
|
"description": "Descripción de la épica",
|
||||||
"linkedHuTitles": ["Título HU 1", "Título HU 2"],
|
"linkedHuTitles": ["Título HU 1", "Título HU 2"],
|
||||||
"estimatedStart": "YYYY-MM-DD",
|
"estimatedStart": "YYYY-MM-DD",
|
||||||
"estimatedEnd": "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": [
|
"hus": [
|
||||||
{
|
{
|
||||||
"title": "Título de la HU",
|
"title": "Título de la HU",
|
||||||
"description": "Descripción detallada",
|
"description": "Descripción detallada",
|
||||||
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
"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: {
|
session: {
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
|
|
||||||
// 4. Actualizar UI con datos frescos de KAPPA
|
// 4. Actualizar UI con datos frescos de KAPPA
|
||||||
userStories.value = stories.map(hu => enrichHU(hu, id))
|
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 => ({
|
epics.value = epicData.map(epic => ({
|
||||||
...epic,
|
...epic,
|
||||||
|
|||||||
+160
-45
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.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 { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
||||||
@@ -289,7 +289,7 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
let endpoint: string, body: string
|
let endpoint: string, body: string
|
||||||
|
|
||||||
if (d.type === 'E') {
|
if (d.type === 'E') {
|
||||||
// Push épica
|
// Push épica primero — KAPPA asigna el ID
|
||||||
const meta = JSON.parse(d.metadata || '{}')
|
const meta = JSON.parse(d.metadata || '{}')
|
||||||
const linkedHuIds: number[] = []
|
const linkedHuIds: number[] = []
|
||||||
for (const huTitle of meta.linkedHuTitles || []) {
|
for (const huTitle of meta.linkedHuTitles || []) {
|
||||||
@@ -308,24 +308,51 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
status: false,
|
status: false,
|
||||||
})
|
})
|
||||||
} else {
|
} 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/'
|
endpoint = '/api/userstorys/create/'
|
||||||
body = JSON.stringify({
|
body = JSON.stringify({
|
||||||
initiative: String(d.projectId), title: d.title,
|
initiative: String(d.projectId),
|
||||||
|
title: d.title,
|
||||||
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
|
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
|
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
story_points: String(d.story_points ?? ''),
|
story_points: d.story_points ?? null,
|
||||||
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
|
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
|
||||||
sprint: '', asignado_a: [], client_taker: null,
|
sprint: meta.sprint ?? '',
|
||||||
characterization_hu: '', has_impairment: false,
|
asignado_a: meta.asignado_a ?? [],
|
||||||
epic_development: null, feature: '',
|
client_taker: meta.clientTaker ?? null,
|
||||||
initial_date: null, end_date: 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 })
|
const res = await fetch(endpoint, { method: 'POST', headers, body })
|
||||||
if (res.ok) {
|
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()
|
const created = await res.json()
|
||||||
d.kappaId = created.id || undefined
|
d.kappaId = created.id || undefined
|
||||||
}
|
}
|
||||||
@@ -365,44 +392,118 @@ async function discardDraft(id: string) {
|
|||||||
await loadDrafts()
|
await loadDrafts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Project analysis ────────────────────────────────────
|
// ─── Project analysis — Two-phase ─────────────────────────
|
||||||
|
const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle')
|
||||||
const analyzing = ref(false)
|
const analyzing = ref(false)
|
||||||
const analysisAbort = ref<AbortController | null>(null)
|
const analysisAbort = ref<AbortController | null>(null)
|
||||||
|
const analysisMessage = ref('')
|
||||||
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
|
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
|
||||||
const analysisSummary = ref('')
|
|
||||||
|
|
||||||
function cancelAnalysis() {
|
function cancelAnalysis() {
|
||||||
analysisAbort.value?.abort()
|
analysisAbort.value?.abort()
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
analysisAbort.value = null
|
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
|
if (!project.value) return
|
||||||
|
phase.value = 'epics'
|
||||||
analyzing.value = true
|
analyzing.value = true
|
||||||
analysisAbort.value = new AbortController()
|
analysisAbort.value = new AbortController()
|
||||||
analysisResult.value = null
|
analysisResult.value = null
|
||||||
analysisSummary.value = ''
|
analysisMessage.value = 'Analizando contexto y generando épicas...'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories, analysisAbort.value?.signal)
|
const existingEpics = getExistingEpicNames()
|
||||||
analysisSummary.value = result.summary
|
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) {
|
if (result.epics.length > 0) {
|
||||||
const outcome = await saveAsDrafts(project.value.id, result, workItems.userStories)
|
const outcome = await saveEpicDrafts(project.value.id, result.epics, workItems.userStories)
|
||||||
analysisResult.value = outcome
|
analysisResult.value = outcome
|
||||||
|
await loadDrafts()
|
||||||
} else {
|
} else {
|
||||||
analysisResult.value = { saved: 0, skipped: 0 }
|
analysisResult.value = { saved: 0, skipped: 0 }
|
||||||
|
analysisMessage.value = 'No se identificaron nuevas épicas necesarias.'
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
||||||
analysisSummary.value = 'Análisis cancelado'
|
analysisMessage.value = 'Análisis cancelado'
|
||||||
} else {
|
} else {
|
||||||
console.error('[Alpha] Analysis error:', e)
|
console.error('[Alpha] Phase 1 error:', e)
|
||||||
analysisSummary.value = `Error: ${e.message}`
|
analysisMessage.value = `Error: ${e.message}`
|
||||||
}
|
}
|
||||||
analysisResult.value = { saved: 0, skipped: 0 }
|
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 {
|
} finally {
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
}
|
}
|
||||||
@@ -529,45 +630,59 @@ const statusLabel = (status: unknown) => {
|
|||||||
@navigate-settings="emit('navigate-settings')"
|
@navigate-settings="emit('navigate-settings')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Project Analysis -->
|
<!-- Project Analysis — Two-phase -->
|
||||||
<Card id="dashboard-analysis" class="border-dashed">
|
<Card id="dashboard-analysis" class="border-dashed">
|
||||||
<CardHeader class="pb-3 flex flex-row items-center justify-between">
|
<CardHeader class="pb-3 flex flex-row items-center justify-between">
|
||||||
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
||||||
<Sparkles class="size-4" />
|
<Sparkles class="size-4" />
|
||||||
Análisis completo del proyecto
|
Análisis del proyecto
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div class="flex gap-2">
|
<div v-if="!analyzing" class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-if="analyzing"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="cancelAnalysis()"
|
:disabled="analyzing || phase === 'done'"
|
||||||
|
@click="runPhaseEpics()"
|
||||||
>
|
>
|
||||||
Cancelar
|
<Sparkles class="size-3 mr-1" />
|
||||||
|
1. Generar Épicas
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="analyzing"
|
:disabled="analyzing || phase === 'idle'"
|
||||||
@click="runAnalysis()"
|
@click="runPhaseHUs()"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="analyzing" class="size-4 mr-1 animate-spin" />
|
<Sparkles class="size-3 mr-1" />
|
||||||
<Sparkles v-else class="size-4 mr-1" />
|
2. Generar HUs
|
||||||
{{ analyzing ? 'Analizando...' : 'Generar HUs faltantes' }}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="analyzing"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="cancelAnalysis()"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
|
<CardContent class="space-y-3">
|
||||||
<p class="text-muted-foreground">{{ analysisSummary }}</p>
|
<div v-if="analyzing" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div class="flex items-center gap-3 text-xs">
|
<Loader2 class="size-4 animate-spin" />
|
||||||
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
{{ analysisMessage }}
|
||||||
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
|
</div>
|
||||||
</span>
|
<div v-if="analysisResult" class="space-y-2 text-sm">
|
||||||
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
<p class="text-muted-foreground">{{ analysisMessage }}</p>
|
||||||
{{ analysisResult.skipped }} duplicadas saltadas
|
<div class="flex items-center gap-3 text-xs">
|
||||||
</span>
|
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
|
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
|
||||||
Todo ya está cubierto. No se requieren nuevas HUs.
|
</span>
|
||||||
</span>
|
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
{{ analysisResult.skipped }} duplicadas saltadas
|
||||||
|
</span>
|
||||||
|
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
|
||||||
|
{{ phase === 'epics' ? 'No se requieren nuevas épicas.' : phase === 'hus' ? 'No se requieren nuevas HUs.' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user