diff --git a/admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts
new file mode 100644
index 00000000..bc05124e
--- /dev/null
+++ b/admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts
@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
+
+function tenantHeader(request: NextRequest): string {
+ return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ derived_id: string }> }
+) {
+ const { derived_id } = await params
+ try {
+ const resp = await fetch(
+ `${BACKEND_URL}/api/v1/quaidal/controls/${encodeURIComponent(derived_id)}`,
+ { headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
+ )
+ const body = await resp.text()
+ return new NextResponse(body, {
+ status: resp.status,
+ headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
+ })
+ } catch (err) {
+ return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
+ }
+}
diff --git a/admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts
new file mode 100644
index 00000000..1ec0557a
--- /dev/null
+++ b/admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts
@@ -0,0 +1,25 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
+
+function tenantHeader(request: NextRequest): string {
+ return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
+}
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url)
+ const qs = searchParams.toString()
+ try {
+ const resp = await fetch(
+ `${BACKEND_URL}/api/v1/quaidal/controls${qs ? `?${qs}` : ''}`,
+ { headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
+ )
+ const body = await resp.text()
+ return new NextResponse(body, {
+ status: resp.status,
+ headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
+ })
+ } catch (err) {
+ return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
+ }
+}
diff --git a/admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts
new file mode 100644
index 00000000..7b6bfd9e
--- /dev/null
+++ b/admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts
@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
+
+function tenantHeader(request: NextRequest): string {
+ return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ section_id: string }> }
+) {
+ const { section_id } = await params
+ try {
+ const resp = await fetch(
+ `${BACKEND_URL}/api/v1/quaidal/criteria/${encodeURIComponent(section_id)}`,
+ { headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
+ )
+ const body = await resp.text()
+ return new NextResponse(body, {
+ status: resp.status,
+ headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
+ })
+ } catch (err) {
+ return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
+ }
+}
diff --git a/admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts
new file mode 100644
index 00000000..015b1206
--- /dev/null
+++ b/admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
+
+function tenantHeader(request: NextRequest): string {
+ return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/criteria`, {
+ headers: { 'X-Tenant-ID': tenantHeader(request) },
+ cache: 'no-store',
+ })
+ const body = await resp.text()
+ return new NextResponse(body, {
+ status: resp.status,
+ headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
+ })
+ } catch (err) {
+ return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
+ }
+}
diff --git a/admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts
new file mode 100644
index 00000000..f79c2d28
--- /dev/null
+++ b/admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
+
+function tenantHeader(request: NextRequest): string {
+ return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/stats`, {
+ headers: { 'X-Tenant-ID': tenantHeader(request) },
+ cache: 'no-store',
+ })
+ const body = await resp.text()
+ return new NextResponse(body, {
+ status: resp.status,
+ headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
+ })
+ } catch (err) {
+ return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
+ }
+}
diff --git a/admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx b/admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx
new file mode 100644
index 00000000..d02610b1
--- /dev/null
+++ b/admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import Link from 'next/link'
+
+interface Props {
+ /** Risk classification of the AI system. Tile is only rendered for high_risk / unacceptable. */
+ riskLevel: string
+}
+
+/**
+ * Renders a tile pointing to the BSI QUAIDAL-based data-quality control tab.
+ * AI Act Article 10 obligations (training-data quality) apply only to high-risk
+ * systems, so the tile is skipped for limited / minimal / not-applicable classes.
+ */
+export function Art10Tile({ riskLevel }: Props) {
+ if (riskLevel !== 'high_risk' && riskLevel !== 'unacceptable') return null
+
+ return (
+
+
+
+
+
+ Art. 10 Datenqualität (Hochrisiko-KI)
+
+
+ BSI QUAIDAL Controls: 10 Kriterien, 15 Bausteine, 30 Maßnahmen, 140 Metriken.
+ Klicken zum Öffnen des Trainingsdaten-Qualität-Moduls.
+
+
+
+
+
+
+
+ )
+}
diff --git a/admin-compliance/app/sdk/ai-act/page.tsx b/admin-compliance/app/sdk/ai-act/page.tsx
index 96522f97..5d65075e 100644
--- a/admin-compliance/app/sdk/ai-act/page.tsx
+++ b/admin-compliance/app/sdk/ai-act/page.tsx
@@ -9,6 +9,7 @@ import { RiskPyramid } from './_components/RiskPyramid'
import { AddSystemForm } from './_components/AddSystemForm'
import { AISystemCard } from './_components/AISystemCard'
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
+import { Art10Tile } from './_components/Art10Tile'
type TabId = 'overview' | 'decision-tree' | 'results'
@@ -136,6 +137,7 @@ function SavedResultsTab() {
Löschen
+
))}
diff --git a/admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx b/admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx
new file mode 100644
index 00000000..3cf6d34e
--- /dev/null
+++ b/admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx
@@ -0,0 +1,152 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { fetchCriterionTree, type QuaidalControl, type QuaidalCriterionTree } from '../_hooks/useQuaidalData'
+
+interface Props {
+ sectionId: string
+ onClose: () => void
+}
+
+function ControlBlock({ ctrl, badgeColor }: { ctrl: QuaidalControl; badgeColor: string }) {
+ return (
+
+ )
+}
+
+export function QuaidalCriterionDetail({ sectionId, onClose }: Props) {
+ const [tree, setTree] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let active = true
+ setLoading(true)
+ fetchCriterionTree(sectionId).then(t => {
+ if (active) {
+ setTree(t)
+ setLoading(false)
+ }
+ })
+ return () => { active = false }
+ }, [sectionId])
+
+ return (
+
+
+
+
+
QUAIDAL Kriterium
+
+ {tree?.criterion.canonical_name || sectionId}
+
+
+
×
+
+
+
+ {loading &&
Lade...
}
+
+ {tree && (
+ <>
+
+
+ Anforderung (eigene Formulierung)
+
+
+
{tree.criterion.description}
+
+
+
Regulierung: {tree.criterion.regulation_anchor || '—'}
+
Quelle: {tree.criterion.source.framework} {tree.criterion.source.section}
+ {tree.criterion.source.url && (
+
+ Originalquelle
+
+ )}
+
+
+
+ {tree.criterion.external_refs.length > 0 && (
+
+
+ Externe Referenzen (nicht ingestiert, nur Verweis)
+
+
+ {tree.criterion.external_refs.map((ref, i) => (
+
+ {ref.framework}{ref.citation ? ` — ${ref.citation}` : ''}
+
+ ))}
+
+
+ )}
+
+ {tree.building_blocks.length > 0 && (
+
+
+ Bausteine ({tree.building_blocks.length})
+
+
+ {tree.building_blocks.map(qb => (
+
+ ))}
+
+
+ )}
+
+ {tree.measures.length > 0 && (
+
+
+ Maßnahmen ({tree.measures.length})
+
+
+ {tree.measures.map(m => (
+
+ ))}
+
+
+ )}
+
+ {tree.metrics.length > 0 && (
+
+
+ Metriken & Methoden ({tree.metrics.length})
+
+
+ {tree.metrics.map(qm => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+
+
+ Eigene Clean-Room-Ableitung von BSI QUAIDAL. Quellverweis und Lizenz-Note pro Eintrag.
+
+
+
+ )
+}
diff --git a/admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx b/admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx
new file mode 100644
index 00000000..e2431a7a
--- /dev/null
+++ b/admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { useState } from 'react'
+import { useQuaidalData, type QuaidalControl } from '../_hooks/useQuaidalData'
+import { QuaidalCriterionDetail } from './QuaidalCriterionDetail'
+
+function CriterionCard({ ctrl, onOpen }: { ctrl: QuaidalControl; onOpen: () => void }) {
+ return (
+
+
+
{ctrl.canonical_name}
+
+ {ctrl.source.section}
+
+
+ {ctrl.description}
+
+ Bausteine: {ctrl.related_quaidal_ids.length}
+ {ctrl.external_refs.slice(0, 2).map((r, i) => (
+
+ {r.framework}
+
+ ))}
+
+
+ )
+}
+
+export function TrainingDataQualityTab() {
+ const { criteria, stats, loading, error } = useQuaidalData()
+ const [openSection, setOpenSection] = useState(null)
+
+ if (loading) {
+ return Lade QUAIDAL-Katalog...
+ }
+ if (error) {
+ return (
+
+ QUAIDAL-Daten konnten nicht geladen werden: {error}
+
+ )
+ }
+
+ return (
+
+
+
Trainingsdaten-Qualität nach BSI QUAIDAL
+
+ Operative Umsetzung von EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI) auf Basis des
+ BSI-Katalogs QUAIDAL. Alle Controls sind eigenständig formuliert (Clean-Room) und verweisen
+ auf die jeweilige QUAIDAL-Sektion.
+
+ {stats && (
+
+
+
Qualitätskriterien
+
{stats.counts_by_kind.criterion ?? 0}
+
+
+
Bausteine
+
{stats.counts_by_kind.building_block ?? 0}
+
+
+
Maßnahmen
+
{stats.counts_by_kind.measure ?? 0}
+
+
+
Metriken & Methoden
+
{stats.counts_by_kind.metric ?? 0}
+
+
+ )}
+
+
+
+
10 Qualitätskriterien
+ {criteria.length === 0 ? (
+
+ Keine Kriterien gefunden. Bitte Backend-Ingest prüfen.
+
+ ) : (
+
+ {criteria.map(c => (
+ setOpenSection(c.source.section)}
+ />
+ ))}
+
+ )}
+
+
+ {stats?.license_note && (
+
{stats.license_note}
+ )}
+
+ {openSection && (
+
setOpenSection(null)}
+ />
+ )}
+
+ )
+}
diff --git a/admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts b/admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts
new file mode 100644
index 00000000..47f8c6d4
--- /dev/null
+++ b/admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts
@@ -0,0 +1,86 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+
+export interface QuaidalExternalRef {
+ framework: string
+ citation: string | null
+}
+
+export interface QuaidalSource {
+ framework: string
+ section: string
+ url: string | null
+ commit_sha: string | null
+ title_original: string | null
+ license_note: string | null
+}
+
+export interface QuaidalControl {
+ derived_id: string
+ kind: 'criterion' | 'building_block' | 'measure' | 'metric'
+ canonical_name: string
+ description: string
+ regulation_anchor: string | null
+ related_quaidal_ids: string[]
+ external_refs: QuaidalExternalRef[]
+ source: QuaidalSource
+ plagiarism_score: number | null
+}
+
+export interface QuaidalStats {
+ counts_by_kind: Record
+ source_framework: string
+ source_commit_sha: string | null
+ license_note: string | null
+}
+
+export interface QuaidalCriterionTree {
+ criterion: QuaidalControl
+ building_blocks: QuaidalControl[]
+ measures: QuaidalControl[]
+ metrics: QuaidalControl[]
+}
+
+const API_BASE = '/api/sdk/v1/quaidal'
+
+export function useQuaidalData() {
+ const [criteria, setCriteria] = useState([])
+ const [stats, setStats] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const loadAll = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [criteriaRes, statsRes] = await Promise.all([
+ fetch(`${API_BASE}/criteria`, { cache: 'no-store' }),
+ fetch(`${API_BASE}/stats`, { cache: 'no-store' }),
+ ])
+ if (criteriaRes.ok) {
+ const data = (await criteriaRes.json()) as QuaidalControl[]
+ setCriteria(Array.isArray(data) ? data : [])
+ } else {
+ setError(`Criteria endpoint returned ${criteriaRes.status}`)
+ }
+ if (statsRes.ok) {
+ setStats(await statsRes.json())
+ }
+ } catch (err) {
+ setError(String(err))
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { loadAll() }, [loadAll])
+
+ return { criteria, stats, loading, error, reload: loadAll }
+}
+
+export async function fetchCriterionTree(sectionId: string): Promise {
+ const res = await fetch(`${API_BASE}/criteria/${encodeURIComponent(sectionId)}`, { cache: 'no-store' })
+ if (!res.ok) return null
+ return (await res.json()) as QuaidalCriterionTree
+}
diff --git a/admin-compliance/app/sdk/quality/page.tsx b/admin-compliance/app/sdk/quality/page.tsx
index e6776b66..abda4c59 100644
--- a/admin-compliance/app/sdk/quality/page.tsx
+++ b/admin-compliance/app/sdk/quality/page.tsx
@@ -1,15 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
+import { useSearchParams } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { useQualityData } from './_hooks/useQualityData'
import { MetricCard, type QualityMetric } from './_components/MetricCard'
import { TestRow } from './_components/TestRow'
import { MetricModal } from './_components/MetricModal'
import { TestModal } from './_components/TestModal'
+import { TrainingDataQualityTab } from './_components/TrainingDataQualityTab'
+
+type TabId = 'model_quality' | 'data_quality'
export default function QualityPage() {
const { state } = useSDK()
+ const searchParams = useSearchParams()
+ const initialTab: TabId = searchParams?.get('category') === 'data_quality' ? 'data_quality' : 'model_quality'
+ const [tab, setTab] = useState(initialTab)
+
const {
metrics,
tests,
@@ -41,24 +49,54 @@ export default function QualityPage() {
AI Quality Dashboard
Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme
-
-
setShowTestModal(true)}
- className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
- >
-
- Test hinzufuegen
-
-
{ setEditMetric(undefined); setShowMetricModal(true) }}
- className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
- >
-
- Messung hinzufuegen
-
-
+ {tab === 'model_quality' && (
+
+
setShowTestModal(true)}
+ className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
+ >
+
+ Test hinzufuegen
+
+
{ setEditMetric(undefined); setShowMetricModal(true) }}
+ className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
+ >
+
+ Messung hinzufuegen
+
+
+ )}
+
+
+ setTab('model_quality')}
+ className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
+ tab === 'model_quality'
+ ? 'border-purple-500 text-purple-600'
+ : 'border-transparent text-gray-500 hover:text-gray-700'
+ }`}
+ >
+ Modell-Qualität
+
+ setTab('data_quality')}
+ className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
+ tab === 'data_quality'
+ ? 'border-purple-500 text-purple-600'
+ : 'border-transparent text-gray-500 hover:text-gray-700'
+ }`}
+ >
+ Trainingsdaten-Qualität (BSI QUAIDAL)
+
+
+
+
+ {tab === 'data_quality' && }
+ {tab === 'model_quality' && (
+ <>
Durchschnittlicher Score
@@ -141,6 +179,8 @@ export default function QualityPage() {
+ >
+ )}
{showMetricModal && (
DerivedControl:
+ return DerivedControl(
+ derived_id=row.derived_id,
+ kind=row.kind,
+ canonical_name=row.canonical_name,
+ description=row.description,
+ regulation_anchor=row.regulation_anchor,
+ related_quaidal_ids=row.related_quaidal_ids or [],
+ external_refs=[ExternalRef(**r) for r in (row.external_refs or [])],
+ source=SourceProvenance(
+ framework=row.source_framework,
+ section=row.source_section,
+ url=row.source_url,
+ commit_sha=row.source_commit_sha,
+ title_original=row.source_title_original,
+ license_note=row.source_license_note,
+ ),
+ plagiarism_score=float(row.plagiarism_score_at_generation) if row.plagiarism_score_at_generation is not None else None,
+ )
+
+
+_SELECT_COLUMNS = """
+ derived_id, kind, canonical_name, description, regulation_anchor,
+ related_quaidal_ids, external_refs,
+ source_framework, source_section, source_url, source_commit_sha,
+ source_title_original, source_license_note,
+ plagiarism_score_at_generation
+"""
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.get("/stats", response_model=StatsResponse)
+def get_stats() -> StatsResponse:
+ """Counts by kind + the QUAIDAL source provenance (single source today)."""
+ with SessionLocal() as db:
+ counts = db.execute(text(
+ "SELECT kind, COUNT(*) AS n FROM compliance.derived_controls "
+ "WHERE source_framework = :fw GROUP BY kind"
+ ), {"fw": "BSI QUAIDAL"}).all()
+ meta = db.execute(text(
+ "SELECT source_commit_sha, source_license_note FROM compliance.derived_controls "
+ "WHERE source_framework = :fw LIMIT 1"
+ ), {"fw": "BSI QUAIDAL"}).first()
+ return StatsResponse(
+ counts_by_kind={r.kind: r.n for r in counts},
+ source_framework="BSI QUAIDAL",
+ source_commit_sha=meta.source_commit_sha if meta else None,
+ license_note=meta.source_license_note if meta else None,
+ )
+
+
+@router.get("/controls", response_model=ControlsListResponse)
+def list_controls(
+ kind: Optional[str] = Query(None, description="criterion | building_block | measure | metric"),
+ limit: int = Query(500, ge=1, le=2000),
+ offset: int = Query(0, ge=0),
+) -> ControlsListResponse:
+ """List QUAIDAL-derived controls, optionally filtered by kind."""
+ where = ["source_framework = :fw"]
+ params: dict = {"fw": "BSI QUAIDAL", "limit": limit, "offset": offset}
+ if kind:
+ where.append("kind = :kind")
+ params["kind"] = kind
+
+ sql = (
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls "
+ f"WHERE {' AND '.join(where)} "
+ "ORDER BY source_section LIMIT :limit OFFSET :offset"
+ )
+ count_sql = f"SELECT COUNT(*) FROM compliance.derived_controls WHERE {' AND '.join(where)}"
+
+ with SessionLocal() as db:
+ rows = db.execute(text(sql), params).all()
+ total = db.execute(text(count_sql), {k: v for k, v in params.items() if k not in ("limit", "offset")}).scalar() or 0
+ return ControlsListResponse(total=int(total), controls=[_row_to_control(r) for r in rows])
+
+
+@router.get("/controls/{derived_id}", response_model=DerivedControl)
+def get_control(derived_id: str) -> DerivedControl:
+ with SessionLocal() as db:
+ row = db.execute(text(
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls WHERE derived_id = :id"
+ ), {"id": derived_id}).first()
+ if not row:
+ raise HTTPException(status_code=404, detail=f"Control {derived_id} not found")
+ return _row_to_control(row)
+
+
+@router.get("/criteria", response_model=list[DerivedControl])
+def list_criteria() -> list[DerivedControl]:
+ """Returns the 10 QKB criteria. Use /criteria/{section_id} for the full child tree."""
+ with SessionLocal() as db:
+ rows = db.execute(text(
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls "
+ "WHERE source_framework = :fw AND kind = 'criterion' ORDER BY source_section"
+ ), {"fw": "BSI QUAIDAL"}).all()
+ return [_row_to_control(r) for r in rows]
+
+
+@router.get("/criteria/{section_id}", response_model=CriterionWithChildren)
+def get_criterion_tree(section_id: str) -> CriterionWithChildren:
+ """Single QKB with the building blocks it references and the measures/metrics those reference.
+
+ `section_id` is the canonical QUAIDAL ID, e.g. `QKB-01`.
+ """
+ section_id_upper = section_id.upper()
+ with SessionLocal() as db:
+ criterion_row = db.execute(text(
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls "
+ "WHERE source_framework = :fw AND source_section = :sid AND kind = 'criterion'"
+ ), {"fw": "BSI QUAIDAL", "sid": section_id_upper}).first()
+ if not criterion_row:
+ raise HTTPException(status_code=404, detail=f"Criterion {section_id_upper} not found")
+
+ building_block_ids = criterion_row.related_quaidal_ids or []
+ building_blocks = []
+ if building_block_ids:
+ qb_rows = db.execute(text(
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls "
+ "WHERE source_framework = :fw AND kind = 'building_block' "
+ "AND source_section = ANY(:ids) ORDER BY source_section"
+ ), {"fw": "BSI QUAIDAL", "ids": building_block_ids}).all()
+ building_blocks = [_row_to_control(r) for r in qb_rows]
+
+ # Collect measure IDs from each building block, then fetch them
+ measure_ids: list[str] = []
+ for qb in building_blocks:
+ measure_ids.extend(mid for mid in qb.related_quaidal_ids if mid.startswith("MA-"))
+ measures = []
+ if measure_ids:
+ ma_rows = db.execute(text(
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls "
+ "WHERE source_framework = :fw AND kind = 'measure' "
+ "AND source_section = ANY(:ids) ORDER BY source_section"
+ ), {"fw": "BSI QUAIDAL", "ids": list(set(measure_ids))}).all()
+ measures = [_row_to_control(r) for r in ma_rows]
+
+ # Collect metric IDs from each measure
+ metric_ids: list[str] = []
+ for ma in measures:
+ metric_ids.extend(mid for mid in ma.related_quaidal_ids if mid.startswith("QM-"))
+ metrics = []
+ if metric_ids:
+ qm_rows = db.execute(text(
+ f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls "
+ "WHERE source_framework = :fw AND kind = 'metric' "
+ "AND source_section = ANY(:ids) ORDER BY source_section"
+ ), {"fw": "BSI QUAIDAL", "ids": list(set(metric_ids))}).all()
+ metrics = [_row_to_control(r) for r in qm_rows]
+
+ return CriterionWithChildren(
+ criterion=_row_to_control(criterion_row),
+ building_blocks=building_blocks,
+ measures=measures,
+ metrics=metrics,
+ )
diff --git a/backend-compliance/main.py b/backend-compliance/main.py
index 0130c83f..03e859cb 100644
--- a/backend-compliance/main.py
+++ b/backend-compliance/main.py
@@ -55,6 +55,7 @@ from compliance.api.saving_scan_routes import router as saving_scan_router
from compliance.api.agent_migration_routes import router as agent_migration_router
from compliance.api.vendor_assessment_routes import router as vendor_assessment_router
from compliance.api.cra_routes import router as cra_router
+from compliance.api.quaidal_routes import router as quaidal_router
# Middleware
from middleware import (
@@ -168,6 +169,7 @@ app.include_router(vendor_assessment_router, prefix="/api")
# CRA (Cyber Resilience Act) Compliance
app.include_router(cra_router, prefix="/api")
+app.include_router(quaidal_router, prefix="/api")
if __name__ == "__main__":