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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user