project-analyzer: propone épicas + vincula HUs

- AnalysisEpic: name, description, linkedHuTitles, estimated dates
- saveAsDrafts: guarda épicas como drafts tipo 'E' con metadata JSON
- HuDraftRecord: +metadata field (JSON string)
- DashboardView: push épica busca HUs vinculadas con kappaId
- DashboardView: push HU guarda kappaId del response, no elimina draft
- UI: badge tipo (Epica/HU), HUs vinculadas visibles, estado Enviada con ID
This commit is contained in:
2026-05-28 14:38:39 -05:00
parent 63804a2cb6
commit 13683ec2c4
3 changed files with 127 additions and 77 deletions
+2 -1
View File
@@ -52,7 +52,8 @@ export interface HuDraftRecord {
description: string description: string
acceptanceCriteria: string acceptanceCriteria: string
priority: string priority: string
type: string type: string // 'U' = HU, 'E' = Epic, 'F' = Feature, 'T' = Task, 'B' = Bug
metadata: string // JSON opcional: { linkedHuTitles?: string[], estimatedStart?: string, estimatedEnd?: string }
sourceSessionId?: number sourceSessionId?: number
syncStatus: 'draft' | 'pushing' | 'pushed' syncStatus: 'draft' | 'pushing' | 'pushed'
kappaId?: number kappaId?: number
+53 -44
View File
@@ -10,29 +10,43 @@ export interface AnalysisHU {
priority: string priority: string
} }
export interface AnalysisEpic {
name: string
description: string
linkedHuTitles: string[]
estimatedStart?: string
estimatedEnd?: string
}
interface AnalysisResult { interface AnalysisResult {
hus: AnalysisHU[] hus: AnalysisHU[]
epics: AnalysisEpic[]
summary: string 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. const ANALYSIS_SYSTEM_PROMPT = `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.
Reglas: Reglas:
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 por las HUs existentes 2. Identificá requisitos, funcionalidades, mejoras o bugs que NO estén cubiertos
3. Cada HU debe tener: título claro, descripción detallada, criterios de aceptación verificables 3. Agrupá HUs relacionadas en Épicas. Cada épica agrupa funcionalidades de un mismo tema
4. No generes HUs duplicadas. Compará con la lista de HUs existentes 4. Cada HU debe tener: título claro, descripción, criterios de aceptación verificables
5. Priorizá según urgencia implícita (Alta/Media/Baja) 5. No generes duplicados. Compará con la lista existente
6. Si todo ya está cubierto, devolvé un arreglo vacío 6. Priorizá según urgencia implícita (Alta/Media/Baja)
7. Respondé SOLO con JSON válido 7. Si todo ya está cubierto, devolvé arreglos vacíos
8. 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: Formato de respuesta:
{ {
"epics": [
{
"name": "Nombre de la Épica",
"description": "Descripción de la épica",
"linkedHuTitles": ["Título HU 1", "Título HU 2"],
"estimatedStart": "YYYY-MM-DD",
"estimatedEnd": "YYYY-MM-DD"
}
],
"hus": [ "hus": [
{ {
"title": "Título de la HU", "title": "Título de la HU",
@@ -41,7 +55,7 @@ Formato de respuesta:
"priority": "Alta|Media|Baja" "priority": "Alta|Media|Baja"
} }
], ],
"summary": "Resumen del análisis: cuántas HUs se crearon y por qué" "summary": "Resumen del análisis"
}` }`
export async function analyzeProject( export async function analyzeProject(
@@ -50,11 +64,9 @@ export async function analyzeProject(
existingHUs: EnrichedUserStory[], existingHUs: EnrichedUserStory[],
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AnalysisResult> { ): Promise<AnalysisResult> {
// 1. Recolectar todo el contexto del proyecto
const sessions = await getSessionsByProject(projectId) const sessions = await getSessionsByProject(projectId)
const state = await getProjectState(projectId) const state = await getProjectState(projectId)
// 2. Construir context compacto en JSON
const sessionsWithSummaries = [] const sessionsWithSummaries = []
for (const s of sessions) { for (const s of sessions) {
const summary = await getSessionSummary(s.id!) const summary = await getSessionSummary(s.id!)
@@ -70,11 +82,7 @@ export async function analyzeProject(
const context = { const context = {
projectName, projectName,
existingHUs: existingHUs.map(h => ({ existingHUs: existingHUs.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority })),
t: h._cleanTitle || h.title,
s: h.status,
p: h.priority,
})),
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),
@@ -88,19 +96,14 @@ export async function analyzeProject(
console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`) console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`)
const content = await callAI( const content = await callAI(
[ [{ role: 'system', content: ANALYSIS_SYSTEM_PROMPT }, { role: 'user', content: userContent }],
{ role: 'system', content: ANALYSIS_SYSTEM_PROMPT }, 0.3, 8192, signal,
{ role: 'user', content: userContent },
],
0.3,
8192,
signal,
) )
try { try {
const jsonStr = extractJSON(content) const jsonStr = extractJSON(content)
const result: AnalysisResult = JSON.parse(jsonStr) const result: AnalysisResult = JSON.parse(jsonStr)
console.log(`[Alpha] Analysis result: ${result.hus.length} nuevas HUs propuestas`) console.log(`[Alpha] Analysis result: ${result.hus.length} HUs, ${result.epics?.length || 0} épicas`)
return result return result
} catch (e) { } catch (e) {
console.error('[Alpha] Failed to parse analysis. Raw:', content) console.error('[Alpha] Failed to parse analysis. Raw:', content)
@@ -108,10 +111,6 @@ export async function analyzeProject(
} }
} }
/**
* Guarda las HUs propuestas como borradores en la BD local.
* No crea nada en KAPPA — el usuario revisa y envía desde HuDrafts.
*/
export async function saveAsDrafts( export async function saveAsDrafts(
projectId: number, projectId: number,
analysis: AnalysisResult, analysis: AnalysisResult,
@@ -121,26 +120,36 @@ export async function saveAsDrafts(
let saved = 0 let saved = 0
let skipped = 0 let skipped = 0
for (const hu of analysis.hus) { // Guardar HUs
for (const hu of analysis.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()
return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et) return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et)
}) })
if (isDuplicate) { skipped++; continue } if (isDuplicate) { skipped++; continue }
await saveDraft({ await saveDraft({
id: createDraftId(), id: createDraftId(), projectId, title: hu.title,
projectId, description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
title: hu.title, priority: hu.priority, type: 'U', metadata: '{}',
description: hu.description, sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
acceptanceCriteria: hu.acceptance_criteria.join('\n'), })
priority: hu.priority, saved++
type: 'U', }
sourceSessionId,
syncStatus: 'draft', // Guardar épicas
createdAt: new Date().toISOString(), 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++
} }
+72 -32
View File
@@ -32,6 +32,12 @@ const emit = defineEmits<{
}>() }>()
// ─── Drafts ────────────────────────────────────────────── // ─── Drafts ──────────────────────────────────────────────
function getEpicLinkedHUs(d: HuDraftRecord): string[] {
try {
const meta = JSON.parse(d.metadata || '{}')
return meta.linkedHuTitles || []
} catch { return [] }
}
const drafts = ref<HuDraftRecord[]>([]) const drafts = ref<HuDraftRecord[]>([])
const pushingDraftId = ref<string | null>(null) const pushingDraftId = ref<string | null>(null)
@@ -46,36 +52,59 @@ async function pushDraft(d: HuDraftRecord) {
await dbSaveDraft(d) await dbSaveDraft(d)
try { try {
const token = localStorage.getItem('kappa_token') const token = localStorage.getItem('kappa_token')
const res = await fetch('/api/userstorys/create/', { const headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }, if (d.type === 'E') {
body: JSON.stringify({ // Push épica: buscar HUs vinculadas ya enviadas
initiative: String(d.projectId), const meta = JSON.parse(d.metadata || '{}')
title: d.title, const linkedHuIds: number[] = []
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '', for (const huTitle of meta.linkedHuTitles || []) {
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '', const pushedHu = drafts.value.find(x => x.type !== 'E' && (x.title.toLowerCase().trim() === huTitle.toLowerCase().trim()) && x.kappaId)
story_points: '', if (pushedHu?.kappaId) linkedHuIds.push(pushedHu.kappaId)
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2', }
sprint: '',
asignado_a: [], const res = await fetch('/api/epicdevelopment/create/', {
client_taker: null, method: 'POST', headers,
characterization_hu: '', body: JSON.stringify({
has_impairment: false, initiative: String(d.projectId), name: d.title,
epic_development: null, description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
feature: '', client_taker: null, hu: linkedHuIds,
initial_date: null, stimated_start_date: meta.estimatedStart || null,
end_date: null, stimated_end_date: meta.estimatedEnd || null,
}), }),
}) })
if (res.ok) { if (res.ok) {
await deleteDraft(d.id) d.syncStatus = 'pushed'; await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
} else { } else {
d.syncStatus = 'draft' // Push HU individual
await dbSaveDraft(d) const res = await fetch('/api/userstorys/create/', {
method: 'POST', headers,
body: JSON.stringify({
initiative: String(d.projectId), title: d.title,
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>` : '',
story_points: '',
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,
}),
})
if (res.ok) {
const created = await res.json()
d.kappaId = created.id || undefined
d.syncStatus = 'pushed'
await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
} }
} catch { } catch {
d.syncStatus = 'draft' d.syncStatus = 'draft'; await dbSaveDraft(d)
await dbSaveDraft(d)
} finally { } finally {
pushingDraftId.value = null pushingDraftId.value = null
await loadDrafts() await loadDrafts()
@@ -383,17 +412,28 @@ const statusLabel = (status: unknown) => {
<CardContent class="space-y-2"> <CardContent class="space-y-2">
<div v-for="d in drafts" :key="d.id" class="flex items-start gap-3 p-2 rounded-lg border text-sm"> <div v-for="d in drafts" :key="d.id" class="flex items-start gap-3 p-2 rounded-lg border text-sm">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium">{{ d.title }}</p> <div class="flex items-center gap-2">
<p v-if="d.description" class="text-xs text-muted-foreground truncate">{{ d.description }}</p> <Badge variant="outline" class="text-[10px]" :class="d.type === 'E' ? 'border-purple-300 text-purple-600' : ''">
{{ d.type === 'E' ? 'Épica' : 'HU' }}
</Badge>
<p class="font-medium">{{ d.title }}</p>
</div>
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
<span v-if="getEpicLinkedHUs(d).length > 0">
HUs vinculadas: {{ getEpicLinkedHUs(d).join(', ') }}
</span>
<span v-else class="italic">Sin HUs vinculadas</span>
</div>
<p v-if="d.description && d.type !== 'E'" class="text-xs text-muted-foreground truncate">{{ d.description }}</p>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<Badge variant="outline" class="text-[10px]">{{ d.priority }}</Badge> <Badge variant="outline" class="text-[10px]">{{ d.priority }}</Badge>
<Badge variant="outline" class="text-[10px] font-mono"> <Badge variant="outline" class="text-[10px] font-mono" :class="d.syncStatus === 'pushed' ? 'text-green-600 border-green-300' : ''">
{{ d.syncStatus === 'pushing' ? 'Enviando...' : d.syncStatus === 'pushed' ? 'Enviada' : 'Borrador' }} {{ d.syncStatus === 'pushing' ? 'Enviando...' : d.syncStatus === 'pushed' ? `Enviada (ID ${d.kappaId || '?'})` : 'Borrador' }}
</Badge> </Badge>
</div> </div>
</div> </div>
<div class="flex gap-1 shrink-0"> <div class="flex gap-1 shrink-0">
<Button size="sm" variant="outline" class="text-xs h-7" <Button v-if="d.syncStatus !== 'pushed'" size="sm" variant="outline" class="text-xs h-7"
:disabled="pushingDraftId === d.id" :disabled="pushingDraftId === d.id"
@click="pushDraft(d)" @click="pushDraft(d)"
> >