9312ad18ef
The Map Renderer explains the engine's state, it does not extend it. Pure composition of resolve_product_scope (scope verdict) + derive_obligations (registry-linked obligations + overlaps) into one RegulatoryMap. - product_summary, trigger_facts, applicable/uncertain/excluded regulations, unsupported_domains, overlaps (shared_obligations), shared_evidence, and a customer-readable executive_summary. - No own legal decisions: applicable/uncertain mirror the scope verdict exactly. - Obligations shown ONLY when registry-linkable (registry_anchor) — MaschinenVO/ EMV obligations are proposed, so they render empty + a note, never as linked. Overlaps/shared_evidence likewise filtered to registry-linked members. - Uncertain regulations link to the navigator question that would resolve them (RED -> has_radio_module, DataAct -> generates_usage_data). - Environmental appears only as unsupported_domain; executive_summary has NO percentage (counts + "no further regulations identified" instead). - POST /reasoning/regulatory-map (thin handler). Response types are presentation- level, not meta-model classes (freeze v1.0 untouched). - 9 tests; 56 green (existing reasoning MVP stays green), mypy clean, LOC ok. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
6.3 KiB
Python
170 lines
6.3 KiB
Python
"""Regulatory Map renderer (step 4) — pure composition, no new logic.
|
|
|
|
It explains the engine's state, it does not extend it: every statement comes
|
|
from `resolve_product_scope` (scope verdict) or `derive_obligations` (registry-
|
|
linked obligations + overlaps). No legal decisions here; obligations are shown
|
|
ONLY where a registry id is linkable (registry_anchor); the executive summary
|
|
carries counts but NO percentage.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Dict, List
|
|
|
|
from compliance.navigator.engine import navigate
|
|
from compliance.product_scope.orchestrator import resolve_product_scope
|
|
from compliance.product_scope.schemas import RegulatoryScopeResult, ScopeStatus
|
|
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
|
from compliance.profile.to_reasoning import to_reasoning_profile
|
|
from compliance.reasoning.obligation_engine import derive_obligations
|
|
|
|
from .schemas import (
|
|
ApplicableRegulationView,
|
|
ExcludedRegulationView,
|
|
ObligationRef,
|
|
OverlapView,
|
|
RegulatoryMap,
|
|
UncertainRegulationView,
|
|
)
|
|
|
|
_DOMAIN_BY_REG = {
|
|
"CRA": "cyber",
|
|
"MaschinenVO": "machine_safety",
|
|
"RED": "radio",
|
|
"DataAct": "data",
|
|
"EMV": "emv",
|
|
"NIS2": None,
|
|
}
|
|
|
|
|
|
def _product_summary(c: CanonicalProductRegulatoryProfile) -> str:
|
|
bits: List[str] = [c.name or "Produkt"]
|
|
if c.product_type:
|
|
bits.append("(%s)" % c.product_type.value)
|
|
sig: List[str] = []
|
|
if c.is_machine:
|
|
sig.append("Maschine")
|
|
if c.has_remote_access or c.connected_to_internet or "cloud" in c.technologies:
|
|
sig.append("vernetzt")
|
|
if c.has_embedded_software:
|
|
sig.append("Firmware")
|
|
if c.economic_operator_role:
|
|
sig.append("Rolle: %s" % c.economic_operator_role.value)
|
|
if c.markets:
|
|
sig.append("Märkte: %s" % ", ".join(c.markets))
|
|
if sig:
|
|
bits.append("— " + "; ".join(sig))
|
|
return " ".join(bits)
|
|
|
|
|
|
def render_regulatory_map(profile: CanonicalProductRegulatoryProfile) -> RegulatoryMap:
|
|
scope_resp = resolve_product_scope(profile)
|
|
summary = _product_summary(profile)
|
|
|
|
if scope_resp.status == ScopeStatus.NEEDS_FACTS:
|
|
return RegulatoryMap(
|
|
scope_resolved=False,
|
|
product_summary=summary,
|
|
executive_summary=(
|
|
"Regulatorischer Scope noch nicht bestimmbar — zuerst Mindestfakten klären: "
|
|
+ "; ".join(scope_resp.missing_facts[:6])
|
|
+ "."
|
|
),
|
|
)
|
|
|
|
scope = scope_resp.regulatory_scope
|
|
assert scope is not None
|
|
obligations = derive_obligations(to_reasoning_profile(profile))
|
|
nav_questions = navigate(profile).suggested_questions
|
|
|
|
linked_ids = {o.obligation_id for o in obligations.applicable_obligations if o.registry_anchor}
|
|
by_reg: Dict[str, List[ObligationRef]] = {}
|
|
shared_ev: Dict[str, List[str]] = {}
|
|
for o in obligations.applicable_obligations:
|
|
if not o.registry_anchor:
|
|
continue
|
|
by_reg.setdefault(o.source_regulation, []).append(
|
|
ObligationRef(
|
|
obligation_id=o.obligation_id,
|
|
title=o.title,
|
|
legal_basis_refs=o.legal_basis_refs,
|
|
authority_level=o.authority_level,
|
|
)
|
|
)
|
|
for ev in o.required_evidence:
|
|
shared_ev.setdefault(ev, []).append(o.obligation_id)
|
|
|
|
applicable_views = []
|
|
for r in scope.applicable_regulations:
|
|
obs = by_reg.get(r.regulation_id, [])
|
|
applicable_views.append(
|
|
ApplicableRegulationView(
|
|
regulation_id=r.regulation_id,
|
|
name=r.name,
|
|
why_applicable=r.explanation,
|
|
triggered_by=r.trigger_facts,
|
|
obligations=obs,
|
|
obligations_note="" if obs else "Pflichten für dieses Regelwerk sind noch nicht registry-verlinkt.",
|
|
confidence=r.confidence,
|
|
)
|
|
)
|
|
|
|
uncertain_views = []
|
|
for u in scope.uncertain_regulations:
|
|
domain = _DOMAIN_BY_REG.get(u.regulation_id)
|
|
qrefs = [q.question_id for q in nav_questions if domain and domain in q.regulatory_domains_unblocked]
|
|
uncertain_views.append(
|
|
UncertainRegulationView(
|
|
regulation_id=u.regulation_id, name=u.name, missing_facts=u.missing_facts, question_refs=qrefs
|
|
)
|
|
)
|
|
|
|
overlap_views = []
|
|
for ov in obligations.overlaps:
|
|
members = [m for m in ov.obligations if m in linked_ids]
|
|
if len(members) >= 2:
|
|
overlap_views.append(
|
|
OverlapView(overlap_group_id=ov.overlap_group_id, shared_obligations=members, explanation=ov.explanation)
|
|
)
|
|
|
|
trigger_facts: List[str] = []
|
|
for v in applicable_views:
|
|
for t in v.triggered_by:
|
|
if t not in trigger_facts:
|
|
trigger_facts.append(t)
|
|
|
|
return RegulatoryMap(
|
|
scope_resolved=True,
|
|
product_summary=summary,
|
|
trigger_facts=trigger_facts,
|
|
applicable_regulations=applicable_views,
|
|
uncertain_regulations=uncertain_views,
|
|
excluded_regulations=[
|
|
ExcludedRegulationView(regulation_id=e.regulation_id, name=e.name, exclusion_reason=e.reason)
|
|
for e in scope.excluded_regulations
|
|
],
|
|
unsupported_domains=scope.unsupported_domains,
|
|
overlaps=overlap_views,
|
|
shared_evidence={ev: ids for ev, ids in shared_ev.items() if len(ids) > 1},
|
|
executive_summary=_executive_summary(summary, applicable_views, uncertain_views, scope, len(linked_ids)),
|
|
)
|
|
|
|
|
|
def _executive_summary(
|
|
summary: str,
|
|
applicable: List[ApplicableRegulationView],
|
|
uncertain: List[UncertainRegulationView],
|
|
scope: RegulatoryScopeResult,
|
|
n_obligations: int,
|
|
) -> str:
|
|
appl = ", ".join(v.regulation_id for v in applicable) or "—"
|
|
unc = ", ".join(v.regulation_id for v in uncertain) or "keine"
|
|
exc = ", ".join(e.regulation_id for e in scope.excluded_regulations) or "keine"
|
|
uns = ", ".join(d.domain for d in scope.unsupported_domains) or "keine"
|
|
return (
|
|
"Für %s gelten nach derzeitigem Stand wahrscheinlich: %s. Unsicher (fehlende Fakten): %s. "
|
|
"Ausgeschlossen: %s. Nicht abgedeckt (Regelkorpus fehlt): %s. Ermittelt: %d registry-verlinkte "
|
|
"Pflichten. Es wurden keine weiteren Regelwerke im aktuellen Korpus identifiziert."
|
|
% (summary, appl, unc, exc, uns, n_obligations)
|
|
)
|