feat(use-case-controls): Adressat-Achse — out-of-scope advisory + additiver GOV-Tag
2-Pass-Haiku-Klassifikation (konservativ + Re-Confirm jeder Nicht-unternehmen- Einstufung) der Review-Tier-Atome: wer muss die Pflicht erfuellen? - Migration 155: atom_classification.addressee (unternehmen/oeffentliche_stelle/ aufsichtsbefugnis/staat_eu/dritter/meta), additiv, kein CHECK. [migration-approved] - Service: addressee + applicable + is_gov pro Control; include_out_of_scope-Param (Default false -> out-of-scope advisory ausgeblendet, NIE geloescht); out_of_scope_count. Pure Helper addressee_applicable/addressee_is_gov (+ Tests). - Route: optionaler include_out_of_scope-Query (contract-safe, additiv). - Frontend: GOV-Chip (additiv) + "kein Kunden-Pruefaspekt"-Chip + 1-Klick-Toggle zum Einblenden der out-of-scope-Atome. Daten: 40.859 Adressat-Tags auf macmini geladen (81% applicable, 19% advisory, 3.146 GOV). Konservativ: NULL/Unklar = applicable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
provenanceBadgeClass,
|
provenanceBadgeClass,
|
||||||
severityBadgeClass,
|
severityBadgeClass,
|
||||||
splitByTier,
|
splitByTier,
|
||||||
|
addresseeLabel,
|
||||||
} from '../_helpers'
|
} from '../_helpers'
|
||||||
|
|
||||||
const BACKEND_URL =
|
const BACKEND_URL =
|
||||||
@@ -13,12 +14,15 @@ const BACKEND_URL =
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
async function getControls(useCase: string): Promise<ControlsResponse | null> {
|
async function getControls(
|
||||||
|
useCase: string,
|
||||||
|
oos: boolean,
|
||||||
|
): Promise<ControlsResponse | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BACKEND_URL}/api/compliance/v1/controls/use-cases/${encodeURIComponent(
|
`${BACKEND_URL}/api/compliance/v1/controls/use-cases/${encodeURIComponent(
|
||||||
useCase,
|
useCase,
|
||||||
)}/controls?tier=all&limit=200`,
|
)}/controls?tier=all&limit=200&include_out_of_scope=${oos}`,
|
||||||
{ cache: 'no-store' },
|
{ cache: 'no-store' },
|
||||||
)
|
)
|
||||||
return res.ok ? ((await res.json()) as ControlsResponse) : null
|
return res.ok ? ((await res.json()) as ControlsResponse) : null
|
||||||
@@ -42,7 +46,23 @@ function ControlsTable({ rows }: { rows: ControlItem[] }) {
|
|||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
{rows.map((c) => (
|
{rows.map((c) => (
|
||||||
<tr key={c.id}>
|
<tr key={c.id}>
|
||||||
<td className="px-4 py-2 text-gray-900">{c.title}</td>
|
<td className="px-4 py-2 text-gray-900">
|
||||||
|
<div>{c.title}</div>
|
||||||
|
{c.is_gov || !c.applicable ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{c.is_gov ? (
|
||||||
|
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800">
|
||||||
|
GOV · Öffentliche Stelle
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!c.applicable ? (
|
||||||
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800">
|
||||||
|
kein Kunden-Prüfaspekt · {addresseeLabel(c.addressee)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-xs text-gray-500">
|
<td className="px-4 py-2 text-xs text-gray-500">
|
||||||
{c.sub_topic || '—'}
|
{c.sub_topic || '—'}
|
||||||
</td>
|
</td>
|
||||||
@@ -77,11 +97,15 @@ function ControlsTable({ rows }: { rows: ControlItem[] }) {
|
|||||||
|
|
||||||
export default async function UseCaseControlsPage({
|
export default async function UseCaseControlsPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ useCase: string }>
|
params: Promise<{ useCase: string }>
|
||||||
|
searchParams: Promise<{ [k: string]: string | string[] | undefined }>
|
||||||
}) {
|
}) {
|
||||||
const { useCase } = await params
|
const { useCase } = await params
|
||||||
const data = await getControls(useCase)
|
const sp = await searchParams
|
||||||
|
const oos = sp.oos === '1'
|
||||||
|
const data = await getControls(useCase, oos)
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +147,25 @@ export default async function UseCaseControlsPage({
|
|||||||
</span>{' '}
|
</span>{' '}
|
||||||
zur fachlichen Prüfung
|
zur fachlichen Prüfung
|
||||||
</p>
|
</p>
|
||||||
|
{data.out_of_scope_count > 0 ? (
|
||||||
|
<p className="text-xs">
|
||||||
|
{data.include_out_of_scope ? (
|
||||||
|
<Link
|
||||||
|
href={`/sdk/coverage/${useCase}`}
|
||||||
|
className="text-purple-600 hover:underline"
|
||||||
|
>
|
||||||
|
← Nur Kunden-Prüfaspekte ({data.out_of_scope_count.toLocaleString('de-DE')} Behörde/Mitgliedstaat/Dritter ausblenden)
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/sdk/coverage/${useCase}?oos=1`}
|
||||||
|
className="text-amber-700 hover:underline"
|
||||||
|
>
|
||||||
|
+ {data.out_of_scope_count.toLocaleString('de-DE')} ausgeblendet (kein Kunden-Prüfaspekt: Behörde/Mitgliedstaat/Dritter) — einblenden
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
provenanceBadgeClass,
|
provenanceBadgeClass,
|
||||||
splitByTier,
|
splitByTier,
|
||||||
severityBadgeClass,
|
severityBadgeClass,
|
||||||
|
addresseeLabel,
|
||||||
type UseCaseRow,
|
type UseCaseRow,
|
||||||
type ControlItem,
|
type ControlItem,
|
||||||
} from './_helpers'
|
} from './_helpers'
|
||||||
@@ -17,6 +18,8 @@ const ctrl = (over: Partial<ControlItem>): ControlItem => ({
|
|||||||
relevant: true,
|
relevant: true,
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
source_type: 'derived',
|
source_type: 'derived',
|
||||||
|
applicable: true,
|
||||||
|
is_gov: false,
|
||||||
...over,
|
...over,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,6 +100,14 @@ describe('coverage helpers', () => {
|
|||||||
expect(severityBadgeClass(null)).toContain('gray')
|
expect(severityBadgeClass(null)).toContain('gray')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('addressee label maps keys to German labels', () => {
|
||||||
|
expect(addresseeLabel('oeffentliche_stelle')).toBe('Öffentliche Stelle')
|
||||||
|
expect(addresseeLabel('aufsichtsbefugnis')).toBe('Aufsichtsbehörde')
|
||||||
|
expect(addresseeLabel('staat_eu')).toBe('Mitgliedstaat/EU')
|
||||||
|
expect(addresseeLabel(null)).toBe('')
|
||||||
|
expect(addresseeLabel('unbekannt_neu')).toBe('unbekannt_neu')
|
||||||
|
})
|
||||||
|
|
||||||
it('splitByTier separates core (relevant) from review', () => {
|
it('splitByTier separates core (relevant) from review', () => {
|
||||||
const { core, review } = splitByTier([
|
const { core, review } = splitByTier([
|
||||||
ctrl({ id: 'a', relevant: true }),
|
ctrl({ id: 'a', relevant: true }),
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ export interface ControlItem {
|
|||||||
relevant: boolean
|
relevant: boolean
|
||||||
tier: 'core' | 'review'
|
tier: 'core' | 'review'
|
||||||
source_type: 'derived' | 'own_library'
|
source_type: 'derived' | 'own_library'
|
||||||
|
addressee?: string | null
|
||||||
|
applicable: boolean
|
||||||
|
is_gov: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlsResponse {
|
export interface ControlsResponse {
|
||||||
@@ -105,6 +108,8 @@ export interface ControlsResponse {
|
|||||||
total: number
|
total: number
|
||||||
core_count: number
|
core_count: number
|
||||||
review_count: number
|
review_count: number
|
||||||
|
out_of_scope_count: number
|
||||||
|
include_out_of_scope: boolean
|
||||||
limit: number
|
limit: number
|
||||||
offset: number
|
offset: number
|
||||||
sub_topic: string | null
|
sub_topic: string | null
|
||||||
@@ -112,6 +117,22 @@ export interface ControlsResponse {
|
|||||||
controls: ControlItem[]
|
controls: ControlItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Addressee axis: who must fulfil an obligation. out-of-scope (authority power /
|
||||||
|
// member-state-EU / third party / meta) is advisory — hidden by default, never
|
||||||
|
// deleted. oeffentliche_stelle = additive GOV hint (public-sector customer).
|
||||||
|
export const ADDRESSEE_LABELS: Record<string, string> = {
|
||||||
|
unternehmen: 'Unternehmen',
|
||||||
|
oeffentliche_stelle: 'Öffentliche Stelle',
|
||||||
|
aufsichtsbefugnis: 'Aufsichtsbehörde',
|
||||||
|
staat_eu: 'Mitgliedstaat/EU',
|
||||||
|
dritter: 'Dritter',
|
||||||
|
meta: 'Meta',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addresseeLabel(a?: string | null): string {
|
||||||
|
return a ? ADDRESSEE_LABELS[a] || a : ''
|
||||||
|
}
|
||||||
|
|
||||||
// Provenance line: own library vs derived-from-document (with the document, and
|
// 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.
|
// article when known). The user wants to see WHERE a derived control came from.
|
||||||
export function provenanceLabel(
|
export function provenanceLabel(
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ async def controls_for_use_case(
|
|||||||
description="atom-grain: 'core'=nur validierte Kern-Pflichten (Default), "
|
description="atom-grain: 'core'=nur validierte Kern-Pflichten (Default), "
|
||||||
"'all'=alle inkl. 'zur Prüfung'-Stufe",
|
"'all'=alle inkl. 'zur Prüfung'-Stufe",
|
||||||
),
|
),
|
||||||
|
include_out_of_scope: bool = Query(
|
||||||
|
False,
|
||||||
|
description="atom-grain: out-of-scope-Adressaten (Aufsichtsbefugnis/"
|
||||||
|
"Mitgliedstaat/Dritter/meta) einblenden (Default: advisory ausgeblendet)",
|
||||||
|
),
|
||||||
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),
|
||||||
@@ -64,4 +69,7 @@ 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, tier)
|
return svc.controls_for_use_case(
|
||||||
|
use_case, primary_only, limit, offset, sub_topic, tier,
|
||||||
|
include_out_of_scope,
|
||||||
|
)
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ def source_type(license_rule: Optional[int]) -> str:
|
|||||||
return "own_library" if license_rule == 3 else "derived"
|
return "own_library" if license_rule == 3 else "derived"
|
||||||
|
|
||||||
|
|
||||||
|
_OUT_OF_SCOPE_ADDRESSEES = ("aufsichtsbefugnis", "staat_eu", "dritter", "meta")
|
||||||
|
|
||||||
|
|
||||||
|
def addressee_applicable(addressee: Optional[str]) -> bool:
|
||||||
|
"""An obligation is applicable to a (potential) customer unless its addressee
|
||||||
|
is clearly someone else: a supervisory authority's power, a member state / EU
|
||||||
|
institution, a foreign third party, or pure meta. NULL = not yet classified =
|
||||||
|
treated as applicable (conservative — nothing hidden by default)."""
|
||||||
|
return addressee not in _OUT_OF_SCOPE_ADDRESSEES
|
||||||
|
|
||||||
|
|
||||||
|
def addressee_is_gov(addressee: Optional[str]) -> bool:
|
||||||
|
"""Public-body-as-obligor → an additive GOV hint (Kommune/Stadt = potential
|
||||||
|
public-sector customer). The atom keeps its use-case; this is only a tag."""
|
||||||
|
return addressee == "oeffentliche_stelle"
|
||||||
|
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -92,6 +109,7 @@ _LIST_SQL = text("""
|
|||||||
# 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, ac.relevant,
|
SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation, ac.relevant,
|
||||||
|
ac.addressee,
|
||||||
cc.control_id, cc.title, cc.objective, cc.severity, cc.license_rule,
|
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
|
||||||
@@ -102,6 +120,8 @@ _ATOM_LIST_SQL = text("""
|
|||||||
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 (:all = true OR ac.relevant = true)
|
WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true)
|
||||||
|
AND (:incl_oos = true OR ac.addressee IS NULL
|
||||||
|
OR ac.addressee NOT IN ('aufsichtsbefugnis','staat_eu','dritter','meta'))
|
||||||
AND (:sub IS NULL OR ac.sub_topic = :sub)
|
AND (:sub IS NULL OR ac.sub_topic = :sub)
|
||||||
ORDER BY ac.relevant DESC, 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
|
||||||
@@ -163,6 +183,7 @@ class UseCaseControlsService:
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
sub_topic: Optional[str] = None,
|
sub_topic: Optional[str] = None,
|
||||||
tier: str = "core",
|
tier: str = "core",
|
||||||
|
include_out_of_scope: bool = False,
|
||||||
) -> 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
|
||||||
@@ -170,7 +191,10 @@ class UseCaseControlsService:
|
|||||||
|
|
||||||
``tier`` (atom-grain only): 'core' = validated obligations only (default,
|
``tier`` (atom-grain only): 'core' = validated obligations only (default,
|
||||||
keeps the agent/CRA callers precise); 'all' = everything incl. the
|
keeps the agent/CRA callers precise); 'all' = everything incl. the
|
||||||
'review' tier (shown, flagged) so the human browse view loses nothing."""
|
'review' tier (shown, flagged) so the human browse view loses nothing.
|
||||||
|
``include_out_of_scope``: by default out-of-scope addressees (authority
|
||||||
|
power / member-state / foreign / meta) are hidden (advisory, never
|
||||||
|
deleted); set true to surface them."""
|
||||||
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]
|
||||||
@@ -179,7 +203,8 @@ class UseCaseControlsService:
|
|||||||
tier = tier if tier in ("core", "all") else "core"
|
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, tier)
|
return self._atom_grain(uc, lim, off, sub_topic, tier,
|
||||||
|
bool(include_out_of_scope))
|
||||||
|
|
||||||
# --- master-grain fallback (recall seed) ---
|
# --- master-grain fallback (recall seed) ---
|
||||||
count_sql = (
|
count_sql = (
|
||||||
@@ -226,28 +251,38 @@ class UseCaseControlsService:
|
|||||||
|
|
||||||
def _atom_grain(
|
def _atom_grain(
|
||||||
self, uc, lim: int, off: int, sub_topic: Optional[str], tier: str = "core",
|
self, uc, lim: int, off: int, sub_topic: Optional[str], tier: str = "core",
|
||||||
|
include_out_of_scope: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
all_flag = tier == "all"
|
all_flag = tier == "all"
|
||||||
counts = self.db.execute(text(
|
counts = self.db.execute(text(
|
||||||
"SELECT count(*) FILTER (WHERE relevant), "
|
"SELECT count(*) FILTER (WHERE relevant), "
|
||||||
"count(*) FILTER (WHERE NOT relevant) "
|
"count(*) FILTER (WHERE NOT relevant), "
|
||||||
|
"count(*) FILTER (WHERE addressee IN "
|
||||||
|
" ('aufsichtsbefugnis','staat_eu','dritter','meta') "
|
||||||
|
" AND (:all = true OR relevant = true)) "
|
||||||
"FROM atom_classification "
|
"FROM atom_classification "
|
||||||
"WHERE use_case = :uc AND (:sub IS NULL OR sub_topic = :sub)"
|
"WHERE use_case = :uc AND (:sub IS NULL OR sub_topic = :sub)"
|
||||||
), {"uc": uc.key, "sub": sub_topic}).first()
|
), {"uc": uc.key, "all": all_flag, "sub": sub_topic}).first()
|
||||||
core_count = int((counts[0] if counts else 0) or 0)
|
core_count = int((counts[0] if counts else 0) or 0)
|
||||||
review_count = int((counts[1] 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
|
oos_count = int((counts[2] if counts else 0) or 0)
|
||||||
|
tier_total = core_count + review_count if all_flag else core_count
|
||||||
|
total = tier_total if include_out_of_scope else tier_total - oos_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 "
|
"FROM atom_classification WHERE use_case = :uc "
|
||||||
"AND (:all = true OR relevant = true) "
|
"AND (:all = true OR relevant = true) "
|
||||||
|
"AND (:incl_oos = true OR addressee IS NULL OR addressee NOT IN "
|
||||||
|
" ('aufsichtsbefugnis','staat_eu','dritter','meta')) "
|
||||||
"GROUP BY 1 ORDER BY 2 DESC"
|
"GROUP BY 1 ORDER BY 2 DESC"
|
||||||
), {"uc": uc.key, "all": all_flag}).fetchall()
|
), {"uc": uc.key, "all": all_flag,
|
||||||
|
"incl_oos": include_out_of_scope}).fetchall()
|
||||||
}
|
}
|
||||||
rows = self.db.execute(_ATOM_LIST_SQL, {
|
rows = self.db.execute(_ATOM_LIST_SQL, {
|
||||||
"uc": uc.key, "all": all_flag, "sub": sub_topic, "lim": lim, "off": off,
|
"uc": uc.key, "all": all_flag, "incl_oos": include_out_of_scope,
|
||||||
|
"sub": sub_topic, "lim": lim, "off": off,
|
||||||
}).fetchall()
|
}).fetchall()
|
||||||
controls = [
|
controls = [
|
||||||
{
|
{
|
||||||
@@ -263,6 +298,9 @@ class UseCaseControlsService:
|
|||||||
"relevant": bool(r.relevant),
|
"relevant": bool(r.relevant),
|
||||||
"tier": tier_label(r.relevant),
|
"tier": tier_label(r.relevant),
|
||||||
"source_type": source_type(r.license_rule),
|
"source_type": source_type(r.license_rule),
|
||||||
|
"addressee": r.addressee,
|
||||||
|
"applicable": addressee_applicable(r.addressee),
|
||||||
|
"is_gov": addressee_is_gov(r.addressee),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -270,6 +308,8 @@ class UseCaseControlsService:
|
|||||||
"use_case": uc.key, "label": uc.label, "group": uc.group,
|
"use_case": uc.key, "label": uc.label, "group": uc.group,
|
||||||
"granularity": "atom", "tier": tier, "total": int(total),
|
"granularity": "atom", "tier": tier, "total": int(total),
|
||||||
"core_count": core_count, "review_count": review_count,
|
"core_count": core_count, "review_count": review_count,
|
||||||
|
"out_of_scope_count": oos_count,
|
||||||
|
"include_out_of_scope": bool(include_out_of_scope),
|
||||||
"limit": lim, "offset": off,
|
"limit": lim, "offset": off,
|
||||||
"sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls,
|
"sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import pytest
|
|||||||
from compliance.domain import NotFoundError
|
from compliance.domain import NotFoundError
|
||||||
from compliance.services.use_case_controls import (
|
from compliance.services.use_case_controls import (
|
||||||
UseCaseControlsService,
|
UseCaseControlsService,
|
||||||
|
addressee_applicable,
|
||||||
|
addressee_is_gov,
|
||||||
relevance_score,
|
relevance_score,
|
||||||
source_type,
|
source_type,
|
||||||
tier_label,
|
tier_label,
|
||||||
@@ -71,3 +73,21 @@ def test_source_type_own_library_vs_derived():
|
|||||||
assert source_type(1) == "derived"
|
assert source_type(1) == "derived"
|
||||||
assert source_type(2) == "derived"
|
assert source_type(2) == "derived"
|
||||||
assert source_type(None) == "derived"
|
assert source_type(None) == "derived"
|
||||||
|
|
||||||
|
|
||||||
|
def test_addressee_applicable_defaults_to_true_when_unknown():
|
||||||
|
# NULL / company / public body = applicable (nothing hidden by default)
|
||||||
|
assert addressee_applicable(None) is True
|
||||||
|
assert addressee_applicable("unternehmen") is True
|
||||||
|
assert addressee_applicable("oeffentliche_stelle") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_addressee_applicable_false_for_out_of_scope():
|
||||||
|
for ad in ("aufsichtsbefugnis", "staat_eu", "dritter", "meta"):
|
||||||
|
assert addressee_applicable(ad) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_addressee_is_gov_only_for_public_body():
|
||||||
|
assert addressee_is_gov("oeffentliche_stelle") is True
|
||||||
|
assert addressee_is_gov("unternehmen") is False
|
||||||
|
assert addressee_is_gov(None) is False
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Migration 155: Adressat-Achse (addressee) auf atom_classification.
|
||||||
|
-- Wer muss eine Pflicht erfuellen? unternehmen / oeffentliche_stelle (=GOV-
|
||||||
|
-- Routing, Public-Sector-Kunde) / aufsichtsbefugnis / staat_eu / dritter / meta.
|
||||||
|
-- Ergebnis eines 2-Pass-Haiku-Laufs (konservativ + Re-Confirm jeder Nicht-
|
||||||
|
-- unternehmen-Einstufung, 2026-06-16). Verwendung: out-of-scope (aufsichts-
|
||||||
|
-- befugnis/staat_eu/dritter/meta) = ADVISORY (default-aus, NICHT geloescht);
|
||||||
|
-- oeffentliche_stelle = ADDITIVER GOV-Hinweis (Atom bleibt im Use Case).
|
||||||
|
-- NULL = (noch) nicht klassifiziert -> gilt als applicable. KEIN CHECK (neue
|
||||||
|
-- Werte ohne Migration). Additiv, idempotent. [migration-approved]
|
||||||
|
|
||||||
|
SET search_path TO compliance, public;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'compliance'
|
||||||
|
AND table_name = 'atom_classification') THEN
|
||||||
|
|
||||||
|
ALTER TABLE atom_classification
|
||||||
|
ADD COLUMN IF NOT EXISTS addressee VARCHAR(24);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_atomcls_addressee
|
||||||
|
ON atom_classification(use_case, addressee);
|
||||||
|
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
Reference in New Issue
Block a user