diff --git a/admin-compliance/app/sdk/coverage/[useCase]/page.tsx b/admin-compliance/app/sdk/coverage/[useCase]/page.tsx new file mode 100644 index 00000000..d4553838 --- /dev/null +++ b/admin-compliance/app/sdk/coverage/[useCase]/page.tsx @@ -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 { + 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 ( +
+ + + + + + + + + + + {rows.map((c) => ( + + + + + + + ))} + +
PrüfaspektSub-ThemaSchwereHerkunft
{c.title} + {c.sub_topic || '—'} + + {c.severity ? ( + + {c.severity} + + ) : null} + + + {provenanceLabel(c)} + +
+
+ ) +} + +export default async function UseCaseControlsPage({ + params, +}: { + params: Promise<{ useCase: string }> +}) { + const { useCase } = await params + const data = await getControls(useCase) + + if (!data) { + return ( +
+ + ← Abdeckung + +
+ Keine atom-grain-Daten für „{useCase}" gefunden. +
+
+ ) + } + + const { core, review } = splitByTier(data.controls) + + return ( +
+
+ + Abdeckung + + / + {data.label} +
+ +
+

{data.label}

+

+ + {data.core_count.toLocaleString('de-DE')} + {' '} + Kern-Pflichten ·{' '} + + {data.review_count.toLocaleString('de-DE')} + {' '} + zur fachlichen Prüfung +

+
+ +
+

+ Kern-Pflichten ({core.length}) +

+ {core.length ? ( + + ) : ( +

+ )} +
+ +
+
+

+ Zur fachlichen Prüfung ({review.length}) +

+

+ Breiter gefasste Aspekte — bewusst eher zu viel als zu wenig. Fachlich + prüfen; nicht zutreffende lassen sich (künftig) als unanwendbar + markieren. +

+
+ {review.length ? ( + + ) : ( +

+ )} +
+ + {data.total > data.controls.length ? ( +

+ Angezeigt: erste {data.controls.length.toLocaleString('de-DE')} von{' '} + {data.total.toLocaleString('de-DE')} — Sub-Thema-Filter folgt. +

+ ) : null} +
+ ) +} diff --git a/admin-compliance/app/sdk/coverage/_helpers.test.ts b/admin-compliance/app/sdk/coverage/_helpers.test.ts index 5ed648bb..7570e628 100644 --- a/admin-compliance/app/sdk/coverage/_helpers.test.ts +++ b/admin-compliance/app/sdk/coverage/_helpers.test.ts @@ -3,9 +3,23 @@ import { licenseTierBadgeClass, commercialBadgeClass, groupUseCases, + provenanceLabel, + provenanceBadgeClass, + splitByTier, + severityBadgeClass, type UseCaseRow, + type ControlItem, } from './_helpers' +const ctrl = (over: Partial): ControlItem => ({ + id: 'id', + title: 'T', + relevant: true, + tier: 'core', + source_type: 'derived', + ...over, +}) + const uc = (over: Partial): 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']) + }) }) diff --git a/admin-compliance/app/sdk/coverage/_helpers.ts b/admin-compliance/app/sdk/coverage/_helpers.ts index a04de891..f665d097 100644 --- a/admin-compliance/app/sdk/coverage/_helpers.ts +++ b/admin-compliance/app/sdk/coverage/_helpers.ts @@ -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 + 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, +): 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 diff --git a/admin-compliance/app/sdk/coverage/page.tsx b/admin-compliance/app/sdk/coverage/page.tsx index 096b9a65..9090bf8e 100644 --- a/admin-compliance/app/sdk/coverage/page.tsx +++ b/admin-compliance/app/sdk/coverage/page.tsx @@ -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 (
@@ -62,8 +64,8 @@ export default async function CoveragePage() {
- - + +
@@ -104,19 +106,30 @@ export default async function CoveragePage() { Use Case Key - relevant - klassifiziert + Kern + zur Prüfung + gesamt Quellen {g.rows.map((u) => ( - - {u.label} + + + + {u.label} + + {u.key} {u.atom_relevant.toLocaleString('de-DE')} + + {(u.atom_total - u.atom_relevant).toLocaleString('de-DE')} + {u.atom_total.toLocaleString('de-DE')} diff --git a/backend-compliance/compliance/api/use_case_controls_routes.py b/backend-compliance/compliance/api/use_case_controls_routes.py index f9d864a8..0f1b631b 100644 --- a/backend-compliance/compliance/api/use_case_controls_routes.py +++ b/backend-compliance/compliance/api/use_case_controls_routes.py @@ -51,6 +51,12 @@ async def controls_for_use_case( use_case: str, primary_only: bool = Query(False, description="master-grain Fallback: nur Primaerzweck"), sub_topic: Optional[str] = Query(None, description="atom-grain: nur dieses Sub-Thema"), + tier: str = Query( + "core", + pattern="^(core|all)$", + description="atom-grain: 'core'=nur validierte Kern-Pflichten (Default), " + "'all'=alle inkl. 'zur Prüfung'-Stufe", + ), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), svc: UseCaseControlsService = Depends(get_use_case_controls_service), @@ -58,4 +64,4 @@ async def controls_for_use_case( """Controls for a topic. Atom-grain (Haiku: relevant + sub_topic) wenn vorhanden, sonst master-grain Seed.""" with translate_domain_errors(): - return svc.controls_for_use_case(use_case, primary_only, limit, offset, sub_topic) + return svc.controls_for_use_case(use_case, primary_only, limit, offset, sub_topic, tier) diff --git a/backend-compliance/compliance/services/use_case_controls.py b/backend-compliance/compliance/services/use_case_controls.py index a9695a08..7125718c 100644 --- a/backend-compliance/compliance/services/use_case_controls.py +++ b/backend-compliance/compliance/services/use_case_controls.py @@ -43,6 +43,21 @@ def relevance_score( return round(min(score, 1.0), 3) +def tier_label(relevant: bool) -> str: + """Soft tier instead of a hard filter: validated obligations are 'core', + the rest are 'review' — shown but flagged for expert curation. The boundary + 'concrete vs. generic' is genuinely fuzzy; hiding 'review' dropped ~25% of + the corpus, much of it real (filter validation 2026-06-15).""" + return "core" if relevant else "review" + + +def source_type(license_rule: Optional[int]) -> str: + """Provenance: 'own_library' = self-written (license_rule 3, no commercial + source); 'derived' = lifted from a sourced document (license 1/2, the + document is in source_regulation).""" + return "own_library" if license_rule == 3 else "derived" + + # Representative member (most severe, then lowest control_id) carries the # human-readable title/objective — master_controls.canonical_name is only the # merge token, so we surface a real member control per master. @@ -76,8 +91,8 @@ _LIST_SQL = text(""" # per-atom relevance + sub-topic. Far more precise + organized than the master # seed. Preferred whenever the use-case has been processed. _ATOM_LIST_SQL = text(""" - SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation, - cc.control_id, cc.title, cc.objective, cc.severity, + SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation, ac.relevant, + cc.control_id, cc.title, cc.objective, cc.severity, cc.license_rule, cpl.source_regulation, cpl.source_article FROM atom_classification ac JOIN canonical_controls cc ON cc.id = ac.control_uuid @@ -86,9 +101,9 @@ _ATOM_LIST_SQL = text(""" FROM control_parent_links cpl WHERE cpl.control_uuid = ac.control_uuid LIMIT 1 ) cpl ON true - WHERE ac.use_case = :uc AND ac.relevant = true + WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true) AND (:sub IS NULL OR ac.sub_topic = :sub) - ORDER BY ac.sub_topic NULLS LAST, + ORDER BY ac.relevant DESC, ac.sub_topic NULLS LAST, CASE cc.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, cc.title LIMIT :lim OFFSET :off @@ -147,18 +162,24 @@ class UseCaseControlsService: limit: int = 50, offset: int = 0, sub_topic: Optional[str] = None, + tier: str = "core", ) -> dict[str, Any]: """Controls for ``use_case``. Prefers the atom-grain Haiku classification (precise + sub-topic-organized) when present; falls back to the - master-grain seed otherwise.""" + master-grain seed otherwise. + + ``tier`` (atom-grain only): 'core' = validated obligations only (default, + keeps the agent/CRA callers precise); 'all' = everything incl. the + 'review' tier (shown, flagged) so the human browse view loses nothing.""" if not is_valid_use_case(use_case): raise NotFoundError(f"Unknown use_case '{use_case}'") uc = REGISTRY[use_case] lim = min(max(int(limit), 1), 200) off = max(int(offset), 0) + tier = tier if tier in ("core", "all") else "core" if self._has_atom_grain(use_case): - return self._atom_grain(uc, lim, off, sub_topic) + return self._atom_grain(uc, lim, off, sub_topic, tier) # --- master-grain fallback (recall seed) --- count_sql = ( @@ -204,23 +225,29 @@ class UseCaseControlsService: ).scalar() or 0) > 0 def _atom_grain( - self, uc, lim: int, off: int, sub_topic: Optional[str], + self, uc, lim: int, off: int, sub_topic: Optional[str], tier: str = "core", ) -> dict[str, Any]: - total = self.db.execute(text( - "SELECT count(*) FROM atom_classification " - "WHERE use_case = :uc AND relevant = true " - "AND (:sub IS NULL OR sub_topic = :sub)" - ), {"uc": uc.key, "sub": sub_topic}).scalar() or 0 + all_flag = tier == "all" + counts = self.db.execute(text( + "SELECT count(*) FILTER (WHERE relevant), " + "count(*) FILTER (WHERE NOT relevant) " + "FROM atom_classification " + "WHERE use_case = :uc AND (:sub IS NULL OR sub_topic = :sub)" + ), {"uc": uc.key, "sub": sub_topic}).first() + core_count = int((counts[0] if counts else 0) or 0) + review_count = int((counts[1] if counts else 0) or 0) + total = core_count + review_count if all_flag else core_count facet = { row[0]: int(row[1]) for row in self.db.execute(text( "SELECT COALESCE(sub_topic, '(none)'), count(*) " - "FROM atom_classification WHERE use_case = :uc AND relevant = true " + "FROM atom_classification WHERE use_case = :uc " + "AND (:all = true OR relevant = true) " "GROUP BY 1 ORDER BY 2 DESC" - ), {"uc": uc.key}).fetchall() + ), {"uc": uc.key, "all": all_flag}).fetchall() } rows = self.db.execute(_ATOM_LIST_SQL, { - "uc": uc.key, "sub": sub_topic, "lim": lim, "off": off, + "uc": uc.key, "all": all_flag, "sub": sub_topic, "lim": lim, "off": off, }).fetchall() controls = [ { @@ -233,11 +260,16 @@ class UseCaseControlsService: "canonical_obligation": r.canonical_obligation, "source_regulation": r.source_regulation, "source_article": r.source_article, + "relevant": bool(r.relevant), + "tier": tier_label(r.relevant), + "source_type": source_type(r.license_rule), } for r in rows ] return { "use_case": uc.key, "label": uc.label, "group": uc.group, - "granularity": "atom", "total": int(total), "limit": lim, "offset": off, + "granularity": "atom", "tier": tier, "total": int(total), + "core_count": core_count, "review_count": review_count, + "limit": lim, "offset": off, "sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls, } diff --git a/backend-compliance/compliance/tests/test_use_case_controls.py b/backend-compliance/compliance/tests/test_use_case_controls.py index c4ff9560..45671eb5 100644 --- a/backend-compliance/compliance/tests/test_use_case_controls.py +++ b/backend-compliance/compliance/tests/test_use_case_controls.py @@ -10,6 +10,8 @@ from compliance.domain import NotFoundError from compliance.services.use_case_controls import ( UseCaseControlsService, relevance_score, + source_type, + tier_label, ) _NET_KW = ("firewall", "tls", "port", "segmentation", "network", "header") @@ -55,3 +57,17 @@ def test_controls_for_unknown_use_case_raises_not_found(): svc = UseCaseControlsService(db=None) # guard runs before any DB access with pytest.raises(NotFoundError): svc.controls_for_use_case("does_not_exist") + + +def test_tier_label_maps_relevance_to_soft_tier(): + assert tier_label(True) == "core" + assert tier_label(False) == "review" + + +def test_source_type_own_library_vs_derived(): + # license_rule 3 = self-written framework, no commercial source + assert source_type(3) == "own_library" + # license 1 (public domain/EU/NIST) and 2 (CC-BY) are derived from a document + assert source_type(1) == "derived" + assert source_type(2) == "derived" + assert source_type(None) == "derived"