feat(regulatory-map): customer-readable read-model over the scope (step 4)

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>
This commit is contained in:
Benjamin Admin
2026-06-26 10:36:06 +02:00
parent 4e8eb2dc0e
commit 9312ad18ef
5 changed files with 436 additions and 0 deletions
@@ -8,6 +8,7 @@ pure deterministic rule evaluation.
POST /reasoning/implementation-reasoning -> claim->obligation mapping (Welt 1, no verdict)
POST /reasoning/interpretation-assessment -> verdict on a customer interpretation
POST /reasoning/product-scope -> gate on facts, else run discover_scope once
POST /reasoning/regulatory-map -> customer-readable read-model over the scope
"""
from __future__ import annotations
@@ -19,6 +20,7 @@ from compliance.product_scope import (
ProductScopeResponse,
resolve_product_scope,
)
from compliance.regulatory_map import RegulatoryMap, RegulatoryMapRequest, render_regulatory_map
from compliance.reasoning import (
assess_interpretation,
derive_obligations,
@@ -64,6 +66,11 @@ def product_scope(req: ProductScopeRequest) -> ProductScopeResponse:
return resolve_product_scope(req.product_profile)
@router.post("/regulatory-map", response_model=RegulatoryMap)
def regulatory_map(req: RegulatoryMapRequest) -> RegulatoryMap:
return render_regulatory_map(req.product_profile)
@router.post("/interpretation-assessment", response_model=InterpretationResponse)
def interpretation_assessment(req: InterpretationRequest) -> InterpretationResponse:
result = assess_interpretation(req.customer_interpretation, req.product_profile)
@@ -0,0 +1,31 @@
"""Regulatory Map — customer-readable read-model over the engine's scope output.
Composes scope + registry-linked obligations + overlaps into one map:
product -> trigger facts -> applicable / uncertain / excluded regulations ->
obligations -> overlaps -> unsupported domains -> executive summary. Explains the
engine's state, never extends it. No new logic, no UI, no RAG, no percentage.
"""
from __future__ import annotations
from .renderer import render_regulatory_map
from .schemas import (
ApplicableRegulationView,
ExcludedRegulationView,
ObligationRef,
OverlapView,
RegulatoryMap,
RegulatoryMapRequest,
UncertainRegulationView,
)
__all__ = [
"render_regulatory_map",
"RegulatoryMap",
"RegulatoryMapRequest",
"ApplicableRegulationView",
"UncertainRegulationView",
"ExcludedRegulationView",
"OverlapView",
"ObligationRef",
]
@@ -0,0 +1,169 @@
"""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)
)
@@ -0,0 +1,70 @@
"""Read-model for the Regulatory Map (step 4).
A customer-readable view that COMPOSES what the engine already computed (scope +
obligations + overlaps). It adds no scope/obligation logic. All fields are
application-level presentation types — NOT compliance-meta-model classes
(architecture freeze v1.0 untouched).
"""
from __future__ import annotations
from typing import Dict, List
from pydantic import BaseModel, Field
from compliance.product_scope.schemas import UnsupportedDomain
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
from compliance.reasoning.enums import AuthorityLevel, Confidence
class RegulatoryMapRequest(BaseModel):
product_profile: CanonicalProductRegulatoryProfile
class ObligationRef(BaseModel):
obligation_id: str
title: str
legal_basis_refs: List[str] = Field(default_factory=list)
authority_level: AuthorityLevel
class ApplicableRegulationView(BaseModel):
regulation_id: str
name: str
why_applicable: str
triggered_by: List[str] = Field(default_factory=list)
obligations: List[ObligationRef] = Field(default_factory=list)
obligations_note: str = "" # set when obligations are not yet registry-linkable
confidence: Confidence
class UncertainRegulationView(BaseModel):
regulation_id: str
name: str
missing_facts: List[str] = Field(default_factory=list)
question_refs: List[str] = Field(default_factory=list)
class ExcludedRegulationView(BaseModel):
regulation_id: str
name: str
exclusion_reason: str
class OverlapView(BaseModel):
overlap_group_id: str
shared_obligations: List[str] = Field(default_factory=list)
explanation: str = ""
class RegulatoryMap(BaseModel):
scope_resolved: bool
product_summary: str
trigger_facts: List[str] = Field(default_factory=list)
applicable_regulations: List[ApplicableRegulationView] = Field(default_factory=list)
uncertain_regulations: List[UncertainRegulationView] = Field(default_factory=list)
excluded_regulations: List[ExcludedRegulationView] = Field(default_factory=list)
unsupported_domains: List[UnsupportedDomain] = Field(default_factory=list)
overlaps: List[OverlapView] = Field(default_factory=list)
shared_evidence: Dict[str, List[str]] = Field(default_factory=dict)
executive_summary: str = ""