feat(use-case-controls): relevant als Stufe statt Hard-Filter + Provenance
CI / test-python-backend (push) Successful in 30s
CI / test-python-document-crawler (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 12s
CI / validate-canonical-controls (push) Successful in 12s
CI / loc-budget (push) Successful in 25s
CI / go-lint (push) Has been skipped
CI / detect-changes (push) Successful in 15s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / nodejs-build (push) Successful in 3m9s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped

Der harte relevant=true-Filter versteckte ~25% des Korpus (40.926 Atome),
~70% davon echte Pflichten (500er-Validierung). relevant wird zur Stufe:

- Service: tier-Param (core=Default schuetzt Agent/CRA; all=alles inkl. review),
  ORDER BY relevant DESC; pro Control relevant/tier/source_type
  (own_library bei license_rule=3, sonst derived) + source_regulation/article;
  core_count/review_count. Pure Helper tier_label + source_type (+ Tests).
- Route: optionaler tier-Query (default core) — contract-safe (additiv).
- Frontend: Coverage-Drill-down /sdk/coverage/[useCase] — Kern-Pflichten vs.
  "zur fachlichen Pruefung", je mit Herkunfts-Badge; Uebersicht zeigt Delta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-15 20:58:25 +02:00
parent e140477c0b
commit 926dc02a09
7 changed files with 385 additions and 23 deletions
@@ -0,0 +1,165 @@
import Link from 'next/link'
import {
type ControlsResponse,
type ControlItem,
provenanceLabel,
provenanceBadgeClass,
severityBadgeClass,
splitByTier,
} from '../_helpers'
const BACKEND_URL =
process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
export const dynamic = 'force-dynamic'
async function getControls(useCase: string): Promise<ControlsResponse | null> {
try {
const res = await fetch(
`${BACKEND_URL}/api/compliance/v1/controls/use-cases/${encodeURIComponent(
useCase,
)}/controls?tier=all&limit=200`,
{ cache: 'no-store' },
)
return res.ok ? ((await res.json()) as ControlsResponse) : null
} catch {
return null
}
}
function ControlsTable({ rows }: { rows: ControlItem[] }) {
return (
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-2">Prüfaspekt</th>
<th className="px-4 py-2">Sub-Thema</th>
<th className="px-4 py-2">Schwere</th>
<th className="px-4 py-2">Herkunft</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{rows.map((c) => (
<tr key={c.id}>
<td className="px-4 py-2 text-gray-900">{c.title}</td>
<td className="px-4 py-2 text-xs text-gray-500">
{c.sub_topic || '—'}
</td>
<td className="px-4 py-2">
{c.severity ? (
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${severityBadgeClass(
c.severity,
)}`}
>
{c.severity}
</span>
) : null}
</td>
<td className="px-4 py-2">
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${provenanceBadgeClass(
c.source_type,
)}`}
title={c.source_article || undefined}
>
{provenanceLabel(c)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default async function UseCaseControlsPage({
params,
}: {
params: Promise<{ useCase: string }>
}) {
const { useCase } = await params
const data = await getControls(useCase)
if (!data) {
return (
<div className="mx-auto max-w-7xl space-y-4 p-6">
<Link
href="/sdk/coverage"
className="text-sm text-purple-600 hover:underline"
>
Abdeckung
</Link>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-amber-800">
Keine atom-grain-Daten für {useCase}" gefunden.
</div>
</div>
)
}
const { core, review } = splitByTier(data.controls)
return (
<div className="mx-auto max-w-7xl space-y-8 p-6">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Link href="/sdk/coverage" className="hover:text-purple-600">
Abdeckung
</Link>
<span>/</span>
<span className="text-gray-900">{data.label}</span>
</div>
<header className="space-y-1">
<h1 className="text-2xl font-bold text-gray-900">{data.label}</h1>
<p className="text-sm text-gray-600">
<span className="font-semibold text-gray-900">
{data.core_count.toLocaleString('de-DE')}
</span>{' '}
Kern-Pflichten ·{' '}
<span className="font-semibold text-gray-900">
{data.review_count.toLocaleString('de-DE')}
</span>{' '}
zur fachlichen Prüfung
</p>
</header>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900">
Kern-Pflichten ({core.length})
</h2>
{core.length ? (
<ControlsTable rows={core} />
) : (
<p className="text-sm text-gray-500">—</p>
)}
</section>
<section className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Zur fachlichen Prüfung ({review.length})
</h2>
<p className="text-xs text-gray-500">
Breiter gefasste Aspekte — bewusst eher zu viel als zu wenig. Fachlich
prüfen; nicht zutreffende lassen sich (künftig) als unanwendbar
markieren.
</p>
</div>
{review.length ? (
<ControlsTable rows={review} />
) : (
<p className="text-sm text-gray-500">—</p>
)}
</section>
{data.total > data.controls.length ? (
<p className="text-xs text-gray-400">
Angezeigt: erste {data.controls.length.toLocaleString('de-DE')} von{' '}
{data.total.toLocaleString('de-DE')} Sub-Thema-Filter folgt.
</p>
) : null}
</div>
)
}
@@ -3,9 +3,23 @@ import {
licenseTierBadgeClass,
commercialBadgeClass,
groupUseCases,
provenanceLabel,
provenanceBadgeClass,
splitByTier,
severityBadgeClass,
type UseCaseRow,
type ControlItem,
} from './_helpers'
const ctrl = (over: Partial<ControlItem>): ControlItem => ({
id: 'id',
title: 'T',
relevant: true,
tier: 'core',
source_type: 'derived',
...over,
})
const uc = (over: Partial<UseCaseRow>): UseCaseRow => ({
key: 'x',
label: 'X',
@@ -52,4 +66,44 @@ describe('coverage helpers', () => {
])
expect(groups.map((g) => g.group)).toEqual(['document', 'mystery'])
})
it('provenance label: own library vs derived (with document + article)', () => {
expect(provenanceLabel(ctrl({ source_type: 'own_library' }))).toBe(
'Eigene Bibliothek',
)
expect(
provenanceLabel(
ctrl({ source_type: 'derived', source_regulation: 'DSGVO' }),
),
).toBe('Abgeleitet · DSGVO')
expect(
provenanceLabel(
ctrl({
source_type: 'derived',
source_regulation: 'DSGVO',
source_article: 'Art. 30',
}),
),
).toBe('Abgeleitet · DSGVO Art. 30')
// derived but no document known → graceful fallback
expect(provenanceLabel(ctrl({ source_type: 'derived' }))).toBe('Abgeleitet')
})
it('provenance + severity badge classes', () => {
expect(provenanceBadgeClass('own_library')).toContain('amber')
expect(provenanceBadgeClass('derived')).toContain('blue')
expect(severityBadgeClass('critical')).toContain('red')
expect(severityBadgeClass('high')).toContain('orange')
expect(severityBadgeClass(null)).toContain('gray')
})
it('splitByTier separates core (relevant) from review', () => {
const { core, review } = splitByTier([
ctrl({ id: 'a', relevant: true }),
ctrl({ id: 'b', relevant: false, tier: 'review' }),
ctrl({ id: 'c', relevant: true }),
])
expect(core.map((c) => c.id)).toEqual(['a', 'c'])
expect(review.map((c) => c.id)).toEqual(['b'])
})
})
@@ -79,6 +79,82 @@ export function commercialBadgeClass(commercial: string | null): string {
}
}
// --- Controls drill-down (#80 Stufe-Flip + Provenance) ---
export interface ControlItem {
id: string
control_id?: string | null
title: string
objective?: string | null
severity?: string | null
sub_topic?: string | null
canonical_obligation?: string | null
source_regulation?: string | null
source_article?: string | null
relevant: boolean
tier: 'core' | 'review'
source_type: 'derived' | 'own_library'
}
export interface ControlsResponse {
use_case: string
label: string
group: string
granularity: string
tier: string
total: number
core_count: number
review_count: number
limit: number
offset: number
sub_topic: string | null
subtopic_counts: Record<string, number>
controls: ControlItem[]
}
// Provenance line: own library vs derived-from-document (with the document, and
// article when known). The user wants to see WHERE a derived control came from.
export function provenanceLabel(
c: Pick<ControlItem, 'source_type' | 'source_regulation' | 'source_article'>,
): string {
if (c.source_type === 'own_library') return 'Eigene Bibliothek'
const doc = c.source_regulation?.trim()
if (!doc) return 'Abgeleitet'
const art = c.source_article?.trim()
return art ? `Abgeleitet · ${doc} ${art}` : `Abgeleitet · ${doc}`
}
export function provenanceBadgeClass(sourceType: string): string {
return sourceType === 'own_library'
? 'bg-amber-100 text-amber-800'
: 'bg-blue-100 text-blue-800'
}
export function severityBadgeClass(sev: string | null | undefined): string {
switch ((sev || '').toLowerCase()) {
case 'critical':
return 'bg-red-100 text-red-800'
case 'high':
return 'bg-orange-100 text-orange-800'
case 'medium':
return 'bg-yellow-100 text-yellow-800'
default:
return 'bg-gray-100 text-gray-600'
}
}
// Split into the two display tiers: Kern-Pflichten (relevant) and the
// 'zur Prüfung' tier (shown but flagged) — never hidden.
export function splitByTier(controls: ControlItem[]): {
core: ControlItem[]
review: ControlItem[]
} {
const core: ControlItem[] = []
const review: ControlItem[] = []
for (const c of controls) (c.relevant ? core : review).push(c)
return { core, review }
}
export interface UseCaseGroup {
group: string
label: string
+19 -6
View File
@@ -1,3 +1,4 @@
import Link from 'next/link'
import {
type UseCaseRow,
type CorpusOverview,
@@ -47,6 +48,7 @@ export default async function CoveragePage() {
const groups = groupUseCases(useCases)
const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0)
const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0)
const totalReview = totalAtoms - totalRelevant
return (
<div className="mx-auto max-w-7xl space-y-10 p-6">
@@ -62,8 +64,8 @@ export default async function CoveragePage() {
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Use Cases" value={useCases.length} />
<Stat label="Pflichten (relevant)" value={totalRelevant.toLocaleString('de-DE')} />
<Stat label="klassifizierte Atome" value={totalAtoms.toLocaleString('de-DE')} />
<Stat label="Kern-Pflichten" value={totalRelevant.toLocaleString('de-DE')} />
<Stat label="zur Prüfung" value={totalReview.toLocaleString('de-DE')} />
<Stat label="Quell-Dokumente" value={corpus?.totals.documents ?? 0} />
</section>
@@ -104,19 +106,30 @@ export default async function CoveragePage() {
<tr>
<th className="px-4 py-2">Use Case</th>
<th className="px-4 py-2">Key</th>
<th className="px-4 py-2 text-right">relevant</th>
<th className="px-4 py-2 text-right">klassifiziert</th>
<th className="px-4 py-2 text-right">Kern</th>
<th className="px-4 py-2 text-right">zur Prüfung</th>
<th className="px-4 py-2 text-right">gesamt</th>
<th className="px-4 py-2">Quellen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{g.rows.map((u) => (
<tr key={u.key} className={u.atom_relevant === 0 ? 'text-gray-400' : ''}>
<td className="px-4 py-2 font-medium text-gray-900">{u.label}</td>
<tr key={u.key} className={u.atom_total === 0 ? 'text-gray-400' : ''}>
<td className="px-4 py-2 font-medium">
<Link
href={`/sdk/coverage/${u.key}`}
className="text-purple-700 hover:underline"
>
{u.label}
</Link>
</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500">{u.key}</td>
<td className="px-4 py-2 text-right font-semibold">
{u.atom_relevant.toLocaleString('de-DE')}
</td>
<td className="px-4 py-2 text-right text-amber-700">
{(u.atom_total - u.atom_relevant).toLocaleString('de-DE')}
</td>
<td className="px-4 py-2 text-right text-gray-500">
{u.atom_total.toLocaleString('de-DE')}
</td>