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
acceptanceCriteria: 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
syncStatus: 'draft' | 'pushing' | 'pushed'
kappaId?: number
+53 -44
View File
@@ -10,29 +10,43 @@ export interface AnalysisHU {
priority: string
}
export interface AnalysisEpic {
name: string
description: string
linkedHuTitles: string[]
estimatedStart?: string
estimatedEnd?: string
}
interface AnalysisResult {
hus: AnalysisHU[]
epics: AnalysisEpic[]
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:
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
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
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": [
{
"title": "Título de la HU",
@@ -41,7 +55,7 @@ Formato de respuesta:
"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(
@@ -50,11 +64,9 @@ export async function analyzeProject(
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!)
@@ -70,11 +82,7 @@ export async function analyzeProject(
const context = {
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 })),
sessions: sessionsWithSummaries.slice(-10).reverse(),
projectState: state ? {
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`)
const content = await callAI(
[
{ role: 'system', content: ANALYSIS_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
0.3,
8192,
signal,
[{ 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`)
console.log(`[Alpha] Analysis result: ${result.hus.length} HUs, ${result.epics?.length || 0} épicas`)
return result
} catch (e) {
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(
projectId: number,
analysis: AnalysisResult,
@@ -121,26 +120,36 @@ export async function saveAsDrafts(
let saved = 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 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 }
await saveDraft({
id: createDraftId(),
projectId,
title: hu.title,
description: hu.description,
acceptanceCriteria: hu.acceptance_criteria.join('\n'),
priority: hu.priority,
type: 'U',
sourceSessionId,
syncStatus: 'draft',
createdAt: new Date().toISOString(),
id: createDraftId(), 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(),
})
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(),
})
saved++
}
+72 -32
View File
@@ -32,6 +32,12 @@ const emit = defineEmits<{
}>()
// ─── Drafts ──────────────────────────────────────────────
function getEpicLinkedHUs(d: HuDraftRecord): string[] {
try {
const meta = JSON.parse(d.metadata || '{}')
return meta.linkedHuTitles || []
} catch { return [] }
}
const drafts = ref<HuDraftRecord[]>([])
const pushingDraftId = ref<string | null>(null)
@@ -46,36 +52,59 @@ async function pushDraft(d: HuDraftRecord) {
await dbSaveDraft(d)
try {
const token = localStorage.getItem('kappa_token')
const res = await fetch('/api/userstorys/create/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) },
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) {
await deleteDraft(d.id)
const headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
if (d.type === 'E') {
// Push épica: buscar HUs vinculadas ya enviadas
const meta = JSON.parse(d.metadata || '{}')
const linkedHuIds: number[] = []
for (const huTitle of meta.linkedHuTitles || []) {
const pushedHu = drafts.value.find(x => x.type !== 'E' && (x.title.toLowerCase().trim() === huTitle.toLowerCase().trim()) && x.kappaId)
if (pushedHu?.kappaId) linkedHuIds.push(pushedHu.kappaId)
}
const res = await fetch('/api/epicdevelopment/create/', {
method: 'POST', headers,
body: JSON.stringify({
initiative: String(d.projectId), name: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
client_taker: null, hu: linkedHuIds,
stimated_start_date: meta.estimatedStart || null,
stimated_end_date: meta.estimatedEnd || null,
}),
})
if (res.ok) {
d.syncStatus = 'pushed'; await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
} else {
d.syncStatus = 'draft'
await dbSaveDraft(d)
// Push HU individual
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 {
d.syncStatus = 'draft'
await dbSaveDraft(d)
d.syncStatus = 'draft'; await dbSaveDraft(d)
} finally {
pushingDraftId.value = null
await loadDrafts()
@@ -383,17 +412,28 @@ const statusLabel = (status: unknown) => {
<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 class="flex-1 min-w-0">
<p class="font-medium">{{ d.title }}</p>
<p v-if="d.description" class="text-xs text-muted-foreground truncate">{{ d.description }}</p>
<div class="flex items-center gap-2">
<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">
<Badge variant="outline" class="text-[10px]">{{ d.priority }}</Badge>
<Badge variant="outline" class="text-[10px] font-mono">
{{ d.syncStatus === 'pushing' ? 'Enviando...' : d.syncStatus === 'pushed' ? 'Enviada' : 'Borrador' }}
<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 (ID ${d.kappaId || '?'})` : 'Borrador' }}
</Badge>
</div>
</div>
<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"
@click="pushDraft(d)"
>