qa-analyzer: plan QA por HU generado por IA + indicadores
- services/qa-analyzer.ts: genera plan QA (tests, automatizable/manual, pasos) - db.ts v5: tabla qa_plans - DashboardView: push HU/épica auto-genera QA plan - DashboardView: stats card QA con conteo de planes - DashboardView: boton QA por draft + visualización de planes - project-analyzer: saveAsDrafts actualizado con metadata para épicas
This commit is contained in:
+12
-1
@@ -60,6 +60,15 @@ export interface HuDraftRecord {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QAPlanRecord {
|
||||||
|
id: string
|
||||||
|
projectId: number
|
||||||
|
huId: string
|
||||||
|
huTitle: string
|
||||||
|
plan: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
const db = new Dexie('alpha-core') as Dexie & {
|
const db = new Dexie('alpha-core') as Dexie & {
|
||||||
settings: Dexie.Table<SettingEntry, string>
|
settings: Dexie.Table<SettingEntry, string>
|
||||||
project_docs: Dexie.Table<ProjectDocRecord, number>
|
project_docs: Dexie.Table<ProjectDocRecord, number>
|
||||||
@@ -67,15 +76,17 @@ const db = new Dexie('alpha-core') as Dexie & {
|
|||||||
session_summaries: Dexie.Table<SessionSummaryRecord, number>
|
session_summaries: Dexie.Table<SessionSummaryRecord, number>
|
||||||
project_state: Dexie.Table<ProjectStateRecord, number>
|
project_state: Dexie.Table<ProjectStateRecord, number>
|
||||||
hu_drafts: Dexie.Table<HuDraftRecord, string>
|
hu_drafts: Dexie.Table<HuDraftRecord, string>
|
||||||
|
qa_plans: Dexie.Table<QAPlanRecord, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(4).stores({
|
db.version(5).stores({
|
||||||
settings: '&key',
|
settings: '&key',
|
||||||
project_docs: '&projectId, projectName, updatedAt',
|
project_docs: '&projectId, projectName, updatedAt',
|
||||||
sessions: '++id, projectId, date',
|
sessions: '++id, projectId, date',
|
||||||
session_summaries: '&sessionId',
|
session_summaries: '&sessionId',
|
||||||
project_state: '&projectId',
|
project_state: '&projectId',
|
||||||
hu_drafts: '&id, projectId, syncStatus',
|
hu_drafts: '&id, projectId, syncStatus',
|
||||||
|
qa_plans: '&id, projectId',
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { callAI } from '@/services/ai'
|
||||||
|
import db from '@/services/db'
|
||||||
|
|
||||||
|
export interface QATestCase {
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
automatable: 'SÍ' | 'PARCIAL' | 'MANUAL'
|
||||||
|
tool: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HUQAPlan {
|
||||||
|
huTitle: string
|
||||||
|
huId: string
|
||||||
|
acceptanceCriteria: string[]
|
||||||
|
testCases: QATestCase[]
|
||||||
|
manualSteps: string[]
|
||||||
|
criticalTests: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const QA_PROMPT = `Eres un ingeniero de QA experto. Generá un plan de pruebas detallado para una Historia de Usuario.
|
||||||
|
|
||||||
|
Formato de respuesta JSON:
|
||||||
|
{
|
||||||
|
"acceptanceCriteria": ["Criterio 1", "Criterio 2"],
|
||||||
|
"testCases": [
|
||||||
|
{
|
||||||
|
"type": "Tipo de prueba",
|
||||||
|
"description": "Descripción de lo que verifica",
|
||||||
|
"automatizable": "SÍ|PARCIAL|MANUAL",
|
||||||
|
"tool": "Playwright|API Testing|Manual"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"manualSteps": ["Paso manual 1"],
|
||||||
|
"criticalTests": ["Prueba crítica manual"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- SÍ = completamente automatizable
|
||||||
|
- PARCIAL = requiere validación manual complementaria
|
||||||
|
- MANUAL = requiere intervención humana
|
||||||
|
- Incluí al menos 3-5 casos de prueba
|
||||||
|
- Marcá como crítica pruebas con datos reales, ERPs externos, o cálculos financieros`
|
||||||
|
|
||||||
|
export async function generateQAPlan(huTitle: string, huDescription: string, acceptanceCriteria: string): Promise<HUQAPlan> {
|
||||||
|
const userContent = `HU: ${huTitle}\nDescripción: ${huDescription}\nCriterios: ${acceptanceCriteria}`
|
||||||
|
const content = await callAI(
|
||||||
|
[{ role: 'system', content: QA_PROMPT }, { role: 'user', content: userContent }],
|
||||||
|
0.3, 4096,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const jsonStr = extractJSON(content)
|
||||||
|
const result: Omit<HUQAPlan, 'huTitle' | 'huId'> = JSON.parse(jsonStr)
|
||||||
|
return { huTitle, huId: '', ...result }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Alpha] Failed to parse QA plan. Raw:', content)
|
||||||
|
throw new Error(`Error generando plan QA para: ${huTitle}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveQAPlan(projectId: number, huId: string, huTitle: string, plan: HUQAPlan) {
|
||||||
|
await db.table('qa_plans').put({
|
||||||
|
id: `${projectId}-${huId}`, projectId, huId, huTitle,
|
||||||
|
plan: JSON.stringify(plan),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQAPlans(projectId: number) {
|
||||||
|
return db.table('qa_plans').where('projectId').equals(projectId).toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAndSavePlan(projectId: number, huId: string, huTitle: string, huDescription: string, acceptanceCriteria: string): Promise<HUQAPlan> {
|
||||||
|
const plan = await generateQAPlan(huTitle, huDescription, acceptanceCriteria)
|
||||||
|
plan.huId = huId
|
||||||
|
await saveQAPlan(projectId, huId, huTitle, plan)
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import AiProjectChat from '@/components/AiProjectChat.vue'
|
|||||||
import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
|
import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
|
||||||
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -31,6 +32,37 @@ const emit = defineEmits<{
|
|||||||
'navigate-settings': []
|
'navigate-settings': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// ─── QA Plans ───────────────────────────────────────────
|
||||||
|
const qaPlans = ref<any[]>([])
|
||||||
|
const generatingQA = ref<string | null>(null)
|
||||||
|
const expandedQA = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadQAPlans(projectId: number) {
|
||||||
|
qaPlans.value = await getQAPlans(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateQA(d: HuDraftRecord) {
|
||||||
|
generatingQA.value = d.id
|
||||||
|
try {
|
||||||
|
await generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', d.acceptanceCriteria)
|
||||||
|
await loadQAPlans(d.projectId)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Alpha] QA generation error:', e)
|
||||||
|
} finally {
|
||||||
|
generatingQA.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQAPlan(record: any): HUQAPlan | null {
|
||||||
|
try { return JSON.parse(record.plan) as HUQAPlan } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function qaBadgeColor(a: string) {
|
||||||
|
if (a === 'SÍ') return 'text-green-600 border-green-300'
|
||||||
|
if (a === 'MANUAL') return 'text-red-600 border-red-300'
|
||||||
|
return 'text-amber-600 border-amber-300'
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Drafts ──────────────────────────────────────────────
|
// ─── Drafts ──────────────────────────────────────────────
|
||||||
function getEpicLinkedHUs(d: HuDraftRecord): string[] {
|
function getEpicLinkedHUs(d: HuDraftRecord): string[] {
|
||||||
try {
|
try {
|
||||||
@@ -75,6 +107,10 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
d.syncStatus = 'pushed'; await dbSaveDraft(d)
|
d.syncStatus = 'pushed'; await dbSaveDraft(d)
|
||||||
|
// Auto-generar QA plan tras push exitoso de épica
|
||||||
|
generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', '').catch(e =>
|
||||||
|
console.error(`[Alpha] QA plan auto-gen failed for epic ${d.title}:`, e)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
||||||
}
|
}
|
||||||
@@ -99,6 +135,10 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
d.kappaId = created.id || undefined
|
d.kappaId = created.id || undefined
|
||||||
d.syncStatus = 'pushed'
|
d.syncStatus = 'pushed'
|
||||||
await dbSaveDraft(d)
|
await dbSaveDraft(d)
|
||||||
|
// Auto-generar QA plan tras push exitoso
|
||||||
|
generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', d.acceptanceCriteria).catch(e =>
|
||||||
|
console.error(`[Alpha] QA plan auto-gen failed for ${d.title}:`, e)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
||||||
}
|
}
|
||||||
@@ -108,6 +148,7 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
} finally {
|
} finally {
|
||||||
pushingDraftId.value = null
|
pushingDraftId.value = null
|
||||||
await loadDrafts()
|
await loadDrafts()
|
||||||
|
await loadQAPlans(project.value!.id)
|
||||||
await workItems.fetchWorkItems(project.value!.id)
|
await workItems.fetchWorkItems(project.value!.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +158,7 @@ async function dbSaveDraft(d: HuDraftRecord) {
|
|||||||
await saveDraft(d)
|
await saveDraft(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => project.value?.id, () => { if (project.value) loadDrafts() }, { immediate: false })
|
watch(() => project.value?.id, () => { if (project.value) { loadDrafts(); loadQAPlans(project.value.id) } }, { immediate: false })
|
||||||
|
|
||||||
async function discardDraft(id: string) {
|
async function discardDraft(id: string) {
|
||||||
await deleteDraft(id)
|
await deleteDraft(id)
|
||||||
@@ -199,7 +240,7 @@ const statusLabel = (status: unknown) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div id="dashboard-stats" class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-4">
|
<div id="dashboard-stats" class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-5">
|
||||||
<Card id="dashboard-stats-epics" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
<Card id="dashboard-stats-epics" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.epics') }}</CardTitle>
|
<CardTitle class="text-sm font-medium">{{ t('dashboard.epics') }}</CardTitle>
|
||||||
@@ -233,6 +274,17 @@ const statusLabel = (status: unknown) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card id="dashboard-stats-qa" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">QA</CardTitle>
|
||||||
|
<CheckCircle2 class="size-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-2xl font-bold">{{ qaPlans.length }}</div>
|
||||||
|
<p class="text-xs text-muted-foreground">Planes QA generados</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card id="dashboard-stats-sessions" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
<Card id="dashboard-stats-sessions" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.sessions') }}</CardTitle>
|
<CardTitle class="text-sm font-medium">{{ t('dashboard.sessions') }}</CardTitle>
|
||||||
@@ -439,7 +491,14 @@ const statusLabel = (status: unknown) => {
|
|||||||
>
|
>
|
||||||
<Loader2 v-if="pushingDraftId === d.id" class="size-3 mr-1 animate-spin" />
|
<Loader2 v-if="pushingDraftId === d.id" class="size-3 mr-1 animate-spin" />
|
||||||
<Send v-else class="size-3 mr-1" />
|
<Send v-else class="size-3 mr-1" />
|
||||||
Enviar
|
{{ d.type === 'E' ? 'Epica' : 'HU' }}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" class="text-xs h-7"
|
||||||
|
:disabled="generatingQA === d.id"
|
||||||
|
@click="generateQA(d)"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="generatingQA === d.id" class="size-3 mr-1 animate-spin" />
|
||||||
|
QA
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" class="text-xs h-7"
|
<Button size="sm" variant="ghost" class="text-xs h-7"
|
||||||
@click="discardDraft(d.id)"
|
@click="discardDraft(d.id)"
|
||||||
|
|||||||
Reference in New Issue
Block a user