Files
Alpha/src/services/project-analyzer.ts
T
ricardo 3035351e6f Sistema de códigos jerárquicos 2-niveles + asignación determinista post-IA
- 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
2026-05-29 18:13:17 -05:00

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 }
}