3035351e6f
- hierarchy.ts: Spike (S) agregado, buildHierarchyPath genera [E01-F04] (2 niveles)
Legacy [E05-F04-U01] preservado (regex opcional 3er segmento)
- hierarchy-generator.ts (nuevo): analyzeExisting() computa contadores por épica+tipo
assignEpicCodes() asigna E{max+1} secuencial
assignItemCodes() asigna {epic}-{tipo}{n+1} a cada HU dentro de su épica
- project-analyzer.ts: post-procesa épicas y HUs con generador de códigos
saveEpicDrafts usa epicCode en metadata y título con [E01]
- prompts-db.ts: prompt FASE 2 instruye a la IA a no generar códigos
- workitems.ts: EnrichedEpic._epicCode, EnrichedUserStory._epicCode/_itemCode
- DashboardView: muestra códigos en drafts y tabla de épicas
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
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 { analyzeExisting, assignEpicCodes, assignItemCodes } from '@/services/hierarchy-generator'
|
|
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
|
|
|
|
export interface AnalysisHU {
|
|
title: string
|
|
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 {
|
|
name: string
|
|
description: string
|
|
linkedHuTitles: string[]
|
|
estimatedStart?: string
|
|
estimatedEnd?: string
|
|
}
|
|
|
|
interface AnalysisEpicsResult {
|
|
epics: AnalysisEpic[]
|
|
summary: string
|
|
rationale: string // explicación de por qué estas épicas
|
|
}
|
|
|
|
interface AnalysisHUsResult {
|
|
hus: AnalysisHU[]
|
|
summary: string
|
|
}
|
|
|
|
async function buildProjectContext(projectId: number, projectName: string, existingHUs: EnrichedUserStory[]) {
|
|
const sessions = await getSessionsByProject(projectId)
|
|
const state = await getProjectState(projectId)
|
|
|
|
const sessionsWithSummaries = []
|
|
for (const s of sessions) {
|
|
const summary = await getSessionSummary(s.id!)
|
|
sessionsWithSummaries.push({
|
|
date: s.date,
|
|
title: s.title,
|
|
file: s.fileName,
|
|
summary: summary?.summary?.slice(0, 500) || '',
|
|
tasks: (safeParse<{ description: string; priority: string }[]>(summary?.tasks, [])).map(t => ({ d: t.description, p: t.priority })),
|
|
decisions: safeParse<string[]>(summary?.decisions, []),
|
|
})
|
|
}
|
|
|
|
return {
|
|
projectName,
|
|
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),
|
|
objectives: safeParse<string[]>(state.objectives, []),
|
|
tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30),
|
|
} : null,
|
|
}
|
|
}
|
|
|
|
// ─── FASE 1: Generar solo Épicas ──────────────────────
|
|
|
|
export async function analyzeProjectEpics(
|
|
projectId: number,
|
|
projectName: string,
|
|
existingHUs: EnrichedUserStory[],
|
|
existingEpics: string[], // nombres de épicas que ya están en KAPPA
|
|
signal?: AbortSignal,
|
|
existingEpicItems?: EnrichedEpic[], // épicas enriquecidas para contar códigos
|
|
): 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')
|
|
// 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: phasePrompt }, { role: 'user', content: userContent }],
|
|
0.3, 8192, signal,
|
|
)
|
|
|
|
try {
|
|
const jsonStr = extractJSON(content)
|
|
const parsed = JSON.parse(jsonStr)
|
|
const rawEpics: AnalysisEpic[] = parsed.epics || parsed.epic || []
|
|
|
|
// Asignar códigos jerárquicos (E06, E07...) a las épicas propuestas
|
|
const counters = analyzeExisting(existingEpicItems || [], existingHUs)
|
|
const epicsWithCodes = assignEpicCodes(rawEpics, counters)
|
|
|
|
const result: AnalysisEpicsResult = {
|
|
epics: epicsWithCodes,
|
|
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 epics analysis. Raw:', content)
|
|
throw new Error('No se pudieron generar las épicas')
|
|
}
|
|
}
|
|
|
|
// ─── FASE 2: Generar HUs dentro de las épicas confirmadas ──
|
|
|
|
export async function analyzeProjectHUs(
|
|
projectId: number,
|
|
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 rawHUs: AnalysisHU[] = parsed.hus || []
|
|
|
|
// Asignar códigos jerárquicos (E01-F04, E01-T01...) a las HUs propuestas
|
|
const counters = analyzeExisting([], existingHUs)
|
|
const epicsWithCodes = confirmedEpics.map(e => ({
|
|
name: e.name,
|
|
epicCode: (e as any).epicCode || '',
|
|
}))
|
|
const husWithCodes = assignItemCodes(rawHUs, epicsWithCodes, counters)
|
|
|
|
const result: AnalysisHUsResult = {
|
|
hus: husWithCodes,
|
|
summary: parsed.summary || '',
|
|
}
|
|
console.log(`[Alpha] Phase 2 complete: ${result.hus.length} HUs generadas con códigos`)
|
|
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[],
|
|
): Promise<{ saved: number; skipped: number }> {
|
|
let saved = 0
|
|
let skipped = 0
|
|
|
|
for (const epic of epics) {
|
|
const epicWithCode = epic as any
|
|
const epicCode = epicWithCode.epicCode || ''
|
|
const fullTitle = epicCode ? `[${epicCode}] ${epic.name}` : epic.name
|
|
|
|
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: fullTitle,
|
|
description: epic.description, acceptanceCriteria: '',
|
|
priority: 'Media', type: 'E',
|
|
metadata: JSON.stringify({
|
|
linkedHuTitles: epic.linkedHuTitles,
|
|
estimatedStart: epic.estimatedStart,
|
|
estimatedEnd: epic.estimatedEnd,
|
|
epicCode,
|
|
}),
|
|
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()
|
|
return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et)
|
|
})
|
|
if (isDuplicate) { skipped++; continue }
|
|
|
|
const draftId = createDraftId()
|
|
await saveDraft({
|
|
id: draftId, projectId, title: hu.title,
|
|
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
|
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++
|
|
}
|
|
|
|
return { saved, skipped }
|
|
}
|
|
|
|
function extractJSON(text: string): string {
|
|
try { JSON.parse(text); return text } catch {}
|
|
const block = text.match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
if (block) { try { JSON.parse(block[1].trim()); return block[1].trim() } catch {} }
|
|
const first = text.indexOf('{')
|
|
const last = text.lastIndexOf('}')
|
|
if (first !== -1 && last > first) { const c = text.slice(first, last + 1); try { JSON.parse(c); return c } catch {} }
|
|
return text
|
|
}
|
|
|
|
function safeParse<T>(json: string | undefined | null, fallback: T): T {
|
|
if (!json) return fallback
|
|
try { return JSON.parse(json) as T } catch { return fallback }
|
|
}
|