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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user