diff --git a/src/services/hierarchy-generator.ts b/src/services/hierarchy-generator.ts new file mode 100644 index 0000000..1de9e9a --- /dev/null +++ b/src/services/hierarchy-generator.ts @@ -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> +} + +const ITEM_TYPE_MAP: Record = { + 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>() + + 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() + for (const e of confirmedEpics) { + epicNameToCode.set(e.name.toLowerCase().trim(), e.epicCode) + } + + // Clonar counters para no mutar el original + const localCounters = new Map>() + 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 = { + 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}]
` + const enrichedDesc = hu.description + ? `${descPrefix}${hu.description}` + : descPrefix + + return { + ...hu, + title: fullTitle, + description: enrichedDesc, + } + }) +} diff --git a/src/services/hierarchy.ts b/src/services/hierarchy.ts index d0addb1..f7be516 100644 --- a/src/services/hierarchy.ts +++ b/src/services/hierarchy.ts @@ -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 = { U: 'HU', T: 'Tarea', B: 'Bug', + S: 'Spike', } const TYPE_COLORS: Record = { @@ -22,6 +22,7 @@ const TYPE_COLORS: Record = { 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 = { @@ -30,6 +31,7 @@ const TYPE_ICONS: Record = { 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 } diff --git a/src/services/project-analyzer.ts b/src/services/project-analyzer.ts index a8eb31c..af6f0b9 100644 --- a/src/services/project-analyzer.ts +++ b/src/services/project-analyzer.ts @@ -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 { 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(), }) diff --git a/src/services/prompts-db.ts b/src/services/prompts-db.ts index 5ca688c..01ab052 100644 --- a/src/services/prompts-db.ts +++ b/src/services/prompts-db.ts @@ -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" diff --git a/src/stores/workitems.ts b/src/stores/workitems.ts index 6478517..5d076f7 100644 --- a/src/stores/workitems.ts +++ b/src/stores/workitems.ts @@ -19,12 +19,15 @@ export interface EnrichedUserStory extends KappaUserStory { _assignedName: string _statusName: string _assignedEmployeeId: number | null + _epicCode: string | null + _itemCode: string | null } export interface EnrichedEpic extends KappaEpicDevelopment { _itemType: ItemType _hierarchyPath: string | null _cleanName: string + _epicCode: string | null } export const useWorkItemsStore = defineStore('workitems', () => { @@ -61,6 +64,7 @@ export const useWorkItemsStore = defineStore('workitems', () => { _itemType: h?.items[h.items.length - 1]?.type || 'E', _hierarchyPath: h?.fullPath ?? null, _cleanName: h ? stripHierarchy(title) : title, + _epicCode: h?.epicCode ?? null, } } @@ -113,6 +117,8 @@ export const useWorkItemsStore = defineStore('workitems', () => { _assignedName: assignedName, _statusName: hu.status_name || resolveStatusName(hu.status), _assignedEmployeeId: employeeId, + _epicCode: h?.epicCode ?? null, + _itemCode: h?.itemCode ?? null, } } diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index f9c8806..ccb540b 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -270,6 +270,22 @@ function getEpicLinkedHUs(d: HuDraftRecord): string[] { return meta.linkedHuTitles || [] } catch { return [] } } + +function getEpicCode(d: HuDraftRecord): string { + try { + const meta = JSON.parse(d.metadata || '{}') + return meta.epicCode || '' + } catch { return '' } +} + +function getItemCode(d: HuDraftRecord): string { + const match = d.title.match(/\[([^\]]+)\]/) + return match ? match[1] : '' +} + +function stripEpicTitle(title: string): string { + return title.replace(/^\[[^\]]+\]\s*/, '') +} const drafts = ref([]) const pushingDraftId = ref(null) @@ -427,6 +443,7 @@ async function runPhaseEpics() { project.value.id, project.value.name || '', workItems.userStories, existingEpics, analysisAbort.value?.signal, + workItems.epics, ) analysisMessage.value = result.rationale @@ -699,7 +716,9 @@ const statusLabel = (status: unknown) => { {{ d.type === 'E' ? 'Épica' : 'HU' }} -

{{ d.title }}

+ [{{ getEpicCode(d) }}] + {{ getItemCode(d) }} +

{{ d.type === 'E' ? stripEpicTitle(d.title) : d.title }}

@@ -758,7 +777,7 @@ const statusLabel = (status: unknown) => { - {{ epic.code || `EP-${epic.id}` }} + {{ epic._epicCode || epic.code || `EP-${epic.id}` }} {{ getTypeLabel(epic._itemType) }}