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
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:
@@ -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,
|
licenseTierBadgeClass,
|
||||||
commercialBadgeClass,
|
commercialBadgeClass,
|
||||||
groupUseCases,
|
groupUseCases,
|
||||||
|
provenanceLabel,
|
||||||
|
provenanceBadgeClass,
|
||||||
|
splitByTier,
|
||||||
|
severityBadgeClass,
|
||||||
type UseCaseRow,
|
type UseCaseRow,
|
||||||
|
type ControlItem,
|
||||||
} from './_helpers'
|
} 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 => ({
|
const uc = (over: Partial<UseCaseRow>): UseCaseRow => ({
|
||||||
key: 'x',
|
key: 'x',
|
||||||
label: 'X',
|
label: 'X',
|
||||||
@@ -52,4 +66,44 @@ describe('coverage helpers', () => {
|
|||||||
])
|
])
|
||||||
expect(groups.map((g) => g.group)).toEqual(['document', 'mystery'])
|
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 {
|
export interface UseCaseGroup {
|
||||||
group: string
|
group: string
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
type UseCaseRow,
|
type UseCaseRow,
|
||||||
type CorpusOverview,
|
type CorpusOverview,
|
||||||
@@ -47,6 +48,7 @@ export default async function CoveragePage() {
|
|||||||
const groups = groupUseCases(useCases)
|
const groups = groupUseCases(useCases)
|
||||||
const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0)
|
const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0)
|
||||||
const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0)
|
const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0)
|
||||||
|
const totalReview = totalAtoms - totalRelevant
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-10 p-6">
|
<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">
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Stat label="Use Cases" value={useCases.length} />
|
<Stat label="Use Cases" value={useCases.length} />
|
||||||
<Stat label="Pflichten (relevant)" value={totalRelevant.toLocaleString('de-DE')} />
|
<Stat label="Kern-Pflichten" value={totalRelevant.toLocaleString('de-DE')} />
|
||||||
<Stat label="klassifizierte Atome" value={totalAtoms.toLocaleString('de-DE')} />
|
<Stat label="zur Prüfung" value={totalReview.toLocaleString('de-DE')} />
|
||||||
<Stat label="Quell-Dokumente" value={corpus?.totals.documents ?? 0} />
|
<Stat label="Quell-Dokumente" value={corpus?.totals.documents ?? 0} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -104,19 +106,30 @@ export default async function CoveragePage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2">Use Case</th>
|
<th className="px-4 py-2">Use Case</th>
|
||||||
<th className="px-4 py-2">Key</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">Kern</th>
|
||||||
<th className="px-4 py-2 text-right">klassifiziert</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>
|
<th className="px-4 py-2">Quellen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
{g.rows.map((u) => (
|
{g.rows.map((u) => (
|
||||||
<tr key={u.key} className={u.atom_relevant === 0 ? 'text-gray-400' : ''}>
|
<tr key={u.key} className={u.atom_total === 0 ? 'text-gray-400' : ''}>
|
||||||
<td className="px-4 py-2 font-medium text-gray-900">{u.label}</td>
|
<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 font-mono text-xs text-gray-500">{u.key}</td>
|
||||||
<td className="px-4 py-2 text-right font-semibold">
|
<td className="px-4 py-2 text-right font-semibold">
|
||||||
{u.atom_relevant.toLocaleString('de-DE')}
|
{u.atom_relevant.toLocaleString('de-DE')}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-2 text-right text-gray-500">
|
||||||
{u.atom_total.toLocaleString('de-DE')}
|
{u.atom_total.toLocaleString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ async def controls_for_use_case(
|
|||||||
use_case: str,
|
use_case: str,
|
||||||
primary_only: bool = Query(False, description="master-grain Fallback: nur Primaerzweck"),
|
primary_only: bool = Query(False, description="master-grain Fallback: nur Primaerzweck"),
|
||||||
sub_topic: Optional[str] = Query(None, description="atom-grain: nur dieses Sub-Thema"),
|
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),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
svc: UseCaseControlsService = Depends(get_use_case_controls_service),
|
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,
|
"""Controls for a topic. Atom-grain (Haiku: relevant + sub_topic) wenn vorhanden,
|
||||||
sonst master-grain Seed."""
|
sonst master-grain Seed."""
|
||||||
with translate_domain_errors():
|
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)
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ def relevance_score(
|
|||||||
return round(min(score, 1.0), 3)
|
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
|
# Representative member (most severe, then lowest control_id) carries the
|
||||||
# human-readable title/objective — master_controls.canonical_name is only the
|
# human-readable title/objective — master_controls.canonical_name is only the
|
||||||
# merge token, so we surface a real member control per master.
|
# 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
|
# per-atom relevance + sub-topic. Far more precise + organized than the master
|
||||||
# seed. Preferred whenever the use-case has been processed.
|
# seed. Preferred whenever the use-case has been processed.
|
||||||
_ATOM_LIST_SQL = text("""
|
_ATOM_LIST_SQL = text("""
|
||||||
SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation,
|
SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation, ac.relevant,
|
||||||
cc.control_id, cc.title, cc.objective, cc.severity,
|
cc.control_id, cc.title, cc.objective, cc.severity, cc.license_rule,
|
||||||
cpl.source_regulation, cpl.source_article
|
cpl.source_regulation, cpl.source_article
|
||||||
FROM atom_classification ac
|
FROM atom_classification ac
|
||||||
JOIN canonical_controls cc ON cc.id = ac.control_uuid
|
JOIN canonical_controls cc ON cc.id = ac.control_uuid
|
||||||
@@ -86,9 +101,9 @@ _ATOM_LIST_SQL = text("""
|
|||||||
FROM control_parent_links cpl
|
FROM control_parent_links cpl
|
||||||
WHERE cpl.control_uuid = ac.control_uuid LIMIT 1
|
WHERE cpl.control_uuid = ac.control_uuid LIMIT 1
|
||||||
) cpl ON true
|
) 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)
|
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
|
CASE cc.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
||||||
WHEN 'medium' THEN 2 ELSE 3 END, cc.title
|
WHEN 'medium' THEN 2 ELSE 3 END, cc.title
|
||||||
LIMIT :lim OFFSET :off
|
LIMIT :lim OFFSET :off
|
||||||
@@ -147,18 +162,24 @@ class UseCaseControlsService:
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
sub_topic: Optional[str] = None,
|
sub_topic: Optional[str] = None,
|
||||||
|
tier: str = "core",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Controls for ``use_case``. Prefers the atom-grain Haiku classification
|
"""Controls for ``use_case``. Prefers the atom-grain Haiku classification
|
||||||
(precise + sub-topic-organized) when present; falls back to the
|
(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):
|
if not is_valid_use_case(use_case):
|
||||||
raise NotFoundError(f"Unknown use_case '{use_case}'")
|
raise NotFoundError(f"Unknown use_case '{use_case}'")
|
||||||
uc = REGISTRY[use_case]
|
uc = REGISTRY[use_case]
|
||||||
lim = min(max(int(limit), 1), 200)
|
lim = min(max(int(limit), 1), 200)
|
||||||
off = max(int(offset), 0)
|
off = max(int(offset), 0)
|
||||||
|
tier = tier if tier in ("core", "all") else "core"
|
||||||
|
|
||||||
if self._has_atom_grain(use_case):
|
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) ---
|
# --- master-grain fallback (recall seed) ---
|
||||||
count_sql = (
|
count_sql = (
|
||||||
@@ -204,23 +225,29 @@ class UseCaseControlsService:
|
|||||||
).scalar() or 0) > 0
|
).scalar() or 0) > 0
|
||||||
|
|
||||||
def _atom_grain(
|
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]:
|
) -> dict[str, Any]:
|
||||||
total = self.db.execute(text(
|
all_flag = tier == "all"
|
||||||
"SELECT count(*) FROM atom_classification "
|
counts = self.db.execute(text(
|
||||||
"WHERE use_case = :uc AND relevant = true "
|
"SELECT count(*) FILTER (WHERE relevant), "
|
||||||
"AND (:sub IS NULL OR sub_topic = :sub)"
|
"count(*) FILTER (WHERE NOT relevant) "
|
||||||
), {"uc": uc.key, "sub": sub_topic}).scalar() or 0
|
"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 = {
|
facet = {
|
||||||
row[0]: int(row[1])
|
row[0]: int(row[1])
|
||||||
for row in self.db.execute(text(
|
for row in self.db.execute(text(
|
||||||
"SELECT COALESCE(sub_topic, '(none)'), count(*) "
|
"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"
|
"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, {
|
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()
|
}).fetchall()
|
||||||
controls = [
|
controls = [
|
||||||
{
|
{
|
||||||
@@ -233,11 +260,16 @@ class UseCaseControlsService:
|
|||||||
"canonical_obligation": r.canonical_obligation,
|
"canonical_obligation": r.canonical_obligation,
|
||||||
"source_regulation": r.source_regulation,
|
"source_regulation": r.source_regulation,
|
||||||
"source_article": r.source_article,
|
"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
|
for r in rows
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"use_case": uc.key, "label": uc.label, "group": uc.group,
|
"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,
|
"sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from compliance.domain import NotFoundError
|
|||||||
from compliance.services.use_case_controls import (
|
from compliance.services.use_case_controls import (
|
||||||
UseCaseControlsService,
|
UseCaseControlsService,
|
||||||
relevance_score,
|
relevance_score,
|
||||||
|
source_type,
|
||||||
|
tier_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NET_KW = ("firewall", "tls", "port", "segmentation", "network", "header")
|
_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
|
svc = UseCaseControlsService(db=None) # guard runs before any DB access
|
||||||
with pytest.raises(NotFoundError):
|
with pytest.raises(NotFoundError):
|
||||||
svc.controls_for_use_case("does_not_exist")
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user