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
This commit is contained in:
2026-05-29 18:13:17 -05:00
parent 9cf12b482f
commit 3035351e6f
6 changed files with 218 additions and 26 deletions
+144
View File
@@ -0,0 +1,144 @@
import { parseHierarchy, buildFullTitle, buildHierarchyPath, type ItemType } from '@/services/hierarchy'
import type { AnalysisEpic, AnalysisHU } from '@/services/project-analyzer'
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
export interface MaxCounters {
epicMax: number
byEpic: Map<string, Record<string, number>>
}
const ITEM_TYPE_MAP: Record<string, ItemType> = {
feature: 'F',
task: 'T',
us: 'U',
bug: 'B',
spike: 'S',
}
/**
* Analiza épicas e ítems existentes para determinar el próximo número disponible
* para cada nivel jerárquico.
*/
export function analyzeExisting(
epics: EnrichedEpic[],
items: EnrichedUserStory[],
): MaxCounters {
let epicMax = 0
const byEpic = new Map<string, Record<string, number>>()
function updateCounter(epicCode: string, type: string, num: number) {
if (!byEpic.has(epicCode)) byEpic.set(epicCode, {})
const map = byEpic.get(epicCode)!
const current = map[type] || 0
if (num > current) map[type] = num
}
for (const epic of epics) {
const h = parseHierarchy(epic._cleanName || epic.name || epic.title || '')
if (!h) {
// Si no tiene código jerárquico, asumimos que usa el id numérico de KAPPA
const fakeH = parseHierarchy(buildFullTitle('', Math.max(epicMax, 1)))
if (fakeH) {
const code = fakeH.epicCode
if (code) {
const num = parseInt(code.slice(1), 10)
if (num > epicMax) epicMax = num
}
}
continue
}
const epicItem = h.items.find(i => i.type === 'E')
if (epicItem) {
const code = `E${String(epicItem.number).padStart(2, '0')}`
if (epicItem.number > epicMax) epicMax = epicItem.number
}
}
for (const item of items) {
const h = parseHierarchy(item._cleanTitle || item.title || '')
if (!h) continue
const epicItem = h.items.find(i => i.type === 'E')
if (!epicItem) continue
const epicCode = `E${String(epicItem.number).padStart(2, '0')}`
for (const seg of h.items) {
if (seg.type === 'E') continue
updateCounter(epicCode, seg.type, seg.number)
}
}
return { epicMax, byEpic }
}
/**
* Asigna códigos jerárquicos a épicas propuestas y devuelve el array
* enriquecido con epicCode en metadata.
*/
export function assignEpicCodes(
proposedEpics: AnalysisEpic[],
counters: MaxCounters,
): (AnalysisEpic & { epicCode: string })[] {
let nextEpicNum = counters.epicMax + 1
return proposedEpics.map(epic => {
const epicCode = `E${String(nextEpicNum).padStart(2, '0')}`
nextEpicNum++
return { ...epic, epicCode }
})
}
/**
* Asigna códigos jerárquicos a ítems propuestos dentro de épicas confirmadas.
* Requiere que cada HU tenga epicName (nombre de la épica) y type.
* Devuelve HUs con title y description enriquecidos con el código.
*/
export function assignItemCodes(
proposedHUs: AnalysisHU[],
confirmedEpics: { name: string; epicCode: string }[],
counters: MaxCounters,
): AnalysisHU[] {
const epicNameToCode = new Map<string, string>()
for (const e of confirmedEpics) {
epicNameToCode.set(e.name.toLowerCase().trim(), e.epicCode)
}
// Clonar counters para no mutar el original
const localCounters = new Map<string, Record<string, number>>()
for (const [key, val] of counters.byEpic) {
localCounters.set(key, { ...val })
}
return proposedHUs.map(hu => {
const epicName = (hu.epicName || '').toLowerCase().trim()
const epicCode = epicNameToCode.get(epicName) || ''
const epicNum = epicCode ? parseInt(epicCode.slice(1), 10) : 0
const itemType = ITEM_TYPE_MAP[(hu.type || 'feature').toLowerCase()] || 'F'
// Obtener próximo número para este tipo dentro de la épica
const epicMap = localCounters.get(epicCode) || {}
const nextNum = (epicMap[itemType] || 0) + 1
if (!localCounters.has(epicCode)) localCounters.set(epicCode, {})
localCounters.get(epicCode)![itemType] = nextNum
// Construir título y descripción con código
const fullTitle = buildFullTitle(hu.title, epicNum || undefined, itemType, nextNum)
const codeTag = buildHierarchyPath(epicNum || undefined, itemType, nextNum)
const typeLabel: Record<string, string> = {
feature: 'Feature', task: 'Tarea', us: 'HU', bug: 'Bug', spike: 'Spike',
}
const huType = (hu.type || 'feature').toLowerCase()
const descPrefix = `[Tipo: ${typeLabel[huType] || 'HU'} | Épica: ${epicCode} | Código: ${codeTag}]<br>`
const enrichedDesc = hu.description
? `${descPrefix}${hu.description}`
: descPrefix
return {
...hu,
title: fullTitle,
description: enrichedDesc,
}
})
}
+13 -15
View File
@@ -1,10 +1,9 @@
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B'
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B' | 'S'
export interface HierarchyInfo {
fullPath: string
items: { type: ItemType; number: number }[]
epicCode: string | null
featureCode: string | null
itemCode: string | null
}
@@ -14,6 +13,7 @@ const TYPE_LABELS: Record<ItemType, string> = {
U: 'HU',
T: 'Tarea',
B: 'Bug',
S: 'Spike',
}
const TYPE_COLORS: Record<ItemType, string> = {
@@ -22,6 +22,7 @@ const TYPE_COLORS: Record<ItemType, string> = {
U: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
T: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
B: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
S: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
}
const TYPE_ICONS: Record<ItemType, string> = {
@@ -30,6 +31,7 @@ const TYPE_ICONS: Record<ItemType, string> = {
U: '📋',
T: '⚙️',
B: '🐛',
S: '🔬',
}
export function getTypeLabel(type: ItemType): string {
@@ -44,7 +46,7 @@ export function getTypeIcon(type: ItemType): string {
return TYPE_ICONS[type] || '📄'
}
const HIERARCHY_REGEX = /\[(([EFUTB]\d+)(?:-([EFUTB]\d+))?(?:-([EFUTB]\d+))?)\]/i
const HIERARCHY_REGEX = /\[(([EFUTBS]\d+)(?:-([EFUTBS]\d+))?(?:-([EFUTBS]\d+))?)\]/i
export function parseHierarchy(title: string): HierarchyInfo | null {
const match = title.match(HIERARCHY_REGEX)
@@ -61,15 +63,12 @@ export function parseHierarchy(title: string): HierarchyInfo | null {
}))
const epic = items.find(i => i.type === 'E')
const feature = items.find(i => i.type === 'F')
const item = items[items.length - 1]
return {
fullPath,
items,
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
featureCode: feature ? `F${String(feature.number).padStart(2, '0')}` : null,
itemCode: item ? `${item.type}${String(item.number).padStart(2, '0')}` : null,
itemCode: items.length > 1 ? `${items[1].type}${String(items[1].number).padStart(2, '0')}` : null,
}
}
@@ -85,25 +84,24 @@ export function getItemType(title: string): ItemType {
export function buildHierarchyPath(
epicNum?: number,
featureNum?: number,
itemType?: ItemType,
itemNum?: number,
): string {
const parts: string[] = []
if (epicNum !== undefined) parts.push(`E${String(epicNum).padStart(2, '0')}`)
if (featureNum !== undefined) parts.push(`F${String(featureNum).padStart(2, '0')}`)
if (itemType && itemNum !== undefined) parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
return parts.length ? `[${parts.join('-')}]` : ''
if (epicNum === undefined) return ''
const parts: string[] = [`E${String(epicNum).padStart(2, '0')}`]
if (itemType && itemNum !== undefined) {
parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
}
return `[${parts.join('-')}]`
}
export function buildFullTitle(
name: string,
epicNum?: number,
featureNum?: number,
itemType?: ItemType,
itemNum?: number,
): string {
const path = buildHierarchyPath(epicNum, featureNum, itemType, itemNum)
const path = buildHierarchyPath(epicNum, itemType, itemNum)
return path ? `${path} ${name}` : name
}
+29 -6
View File
@@ -2,7 +2,8 @@ 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 type { EnrichedUserStory } from '@/stores/workitems'
import { analyzeExisting, assignEpicCodes, assignItemCodes } from '@/services/hierarchy-generator'
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
export interface AnalysisHU {
title: string
@@ -70,8 +71,9 @@ export async function analyzeProjectEpics(
projectId: number,
projectName: string,
existingHUs: EnrichedUserStory[],
existingEpics: string[], // épicas que ya están en KAPPA
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)
@@ -101,8 +103,14 @@ IMPORTANTE: Respondé SOLO con épicas en esta fase. NO generes HUs aún.`
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: parsed.epics || parsed.epic || [],
epics: epicsWithCodes,
summary: parsed.summary || '',
rationale: parsed.rationale || parsed.summary || '',
}
@@ -153,11 +161,21 @@ IMPORTANTE: Respondé SOLO con HUs en esta fase. NO generes épicas nuevas.`
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: parsed.hus || [],
hus: husWithCodes,
summary: parsed.summary || '',
}
console.log(`[Alpha] Phase 2 complete: ${result.hus.length} HUs generadas`)
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)
@@ -176,18 +194,23 @@ export async function saveEpicDrafts(
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: epic.name,
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(),
})
+5 -3
View File
@@ -77,7 +77,9 @@ Formato de respuesta FASE 1:
--- FASE 2: Generar HUs dentro de Épicas ---
Instrucciones específicas:
- Las épicas ya están definidas. Generá las HUs que pertenecen a cada una.
- Cada HU debe tener un campo "epicName" indicando a qué épica pertenece
- NO generes códigos jerárquicos en los títulos. El sistema los asigna después.
- Proponé solo el nombre del ítem sin prefijos como [E01-F04].
- Cada HU debe tener un campo "epicName" con el NOMBRE de la épica a la que pertenece.
- Tipos de HU: feature, task, US (historia de usuario), bug, spike
- Incluí: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado
- No generes HUs duplicadas con las existentes
@@ -87,7 +89,7 @@ Formato de respuesta FASE 2:
{
"hus": [
{
"title": "Título de la HU",
"title": "Nombre del ítem (sin código jerárquico)",
"description": "Descripción detallada",
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
"priority": "Alta|Media|Baja",
@@ -95,7 +97,7 @@ Formato de respuesta FASE 2:
"type": "feature|task|bug|spike",
"feature": "Nombre de la feature",
"sprint": 12,
"epicName": "Nombre de la épica a la que pertenece"
"epicName": "Nombre exacto de la épica a la que pertenece"
}
],
"summary": "Resumen de las HUs generadas"