feat(navigator): Product Regulatory Navigator as a thin missing-facts layer

Step 2 of the convergence sequence. The Navigator sits over the
CanonicalProductRegulatoryProfile (prefilled from company-profile / ProductWizard)
and reports ONLY which facts are still missing + prioritized questions to collect
them. It decides which facts are needed, NEVER what applies — that stays with the
Scope Engine (step 3). No regulation logic, no UI, no Go, no RAG.

- NavigatorQuestion (interaction type, NOT a compliance-meta-model class — freeze
  v1.0 untouched): question_id, target_field, label, why_needed,
  regulatory_domains_unblocked (static metadata), answer_type, options, priority.
- QUESTION_CATALOG: 12 questions over canonical gaps — P0 (markets, role,
  lifecycle, machine/component), P1 (radio, usage-data, security-function,
  environmental wastewater/air/chemicals triggers), P2 (structured BOM).
- engine: navigate() -> missing_facts + suggested_questions (priority-sorted) +
  completeness_summary (ready_for_scope = no P0 missing); apply_answers() ->
  updated profile. Pure field-presence; no scope import.
- 8 tests: <=10 questions for a filled company-profile, known facts not re-asked,
  environmental = trigger questions only (no law evaluation), apply round-trip,
  P0 ordering, ready_for_scope. 41 tests 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:05:27 +02:00
parent 739a477d3f
commit 78aeedafae
4 changed files with 443 additions and 0 deletions
@@ -0,0 +1,29 @@
"""Product Regulatory Navigator — thin missing-facts layer.
Sits above the CanonicalProductRegulatoryProfile (prefilled from company-profile /
ProductWizard) and reports only which facts are still missing + prioritized
questions to collect them. It decides which facts are needed, NOT what regulation
applies — that stays with the Scope Engine (step 3). No regulation logic, no UI,
no Go, no RAG.
"""
from __future__ import annotations
from .engine import CompletenessSummary, NavigatorResult, apply_answers, navigate
from .questions import (
QUESTION_CATALOG,
AnswerType,
NavigatorQuestion,
QuestionPriority,
)
__all__ = [
"navigate",
"apply_answers",
"NavigatorResult",
"CompletenessSummary",
"NavigatorQuestion",
"AnswerType",
"QuestionPriority",
"QUESTION_CATALOG",
]
@@ -0,0 +1,116 @@
"""Product Regulatory Navigator engine — missing-facts only.
`navigate(profile)` reports which canonical fields are still unknown and the
prioritized questions to fill them. `apply_answers(profile, answers)` returns the
updated profile. It NEVER decides what applies — that is the Scope Engine (step 3).
Pure field-presence checking; no scope-engine import, no regulation evaluation.
"""
from __future__ import annotations
from typing import Any, Dict, List, Type
from pydantic import BaseModel, Field
from compliance.profile.canonical import (
CanonicalLifecyclePhase,
CanonicalProductRegulatoryProfile,
EconomicOperatorRole,
ProductComponent,
)
from .questions import QUESTION_CATALOG, NavigatorQuestion, QuestionPriority
_ENUM_FIELDS: Dict[str, Type[Any]] = {
"economic_operator_role": EconomicOperatorRole,
"lifecycle_phase": CanonicalLifecyclePhase,
}
class CompletenessSummary(BaseModel):
total_relevant: int
answered: int
missing: int
missing_by_priority: Dict[str, int] = Field(default_factory=dict)
ready_for_scope: bool # True once no P0 fact is missing
note: str = ""
class NavigatorResult(BaseModel):
missing_facts: List[str] = Field(default_factory=list) # canonical target fields
suggested_questions: List[NavigatorQuestion] = Field(default_factory=list)
completeness_summary: CompletenessSummary
def _value(profile: CanonicalProductRegulatoryProfile, dotted: str) -> Any:
if "." in dotted:
head, tail = dotted.split(".", 1)
return getattr(getattr(profile, head), tail, None)
return getattr(profile, dotted, None)
def _is_unknown(profile: CanonicalProductRegulatoryProfile, q: NavigatorQuestion) -> bool:
value = _value(profile, q.target_field)
if value is None:
return True
if isinstance(value, list) and not value:
return True
return False
def navigate(profile: CanonicalProductRegulatoryProfile) -> NavigatorResult:
missing = [q for q in QUESTION_CATALOG if _is_unknown(profile, q)]
missing.sort(key=lambda q: q.order())
by_priority: Dict[str, int] = {}
for q in missing:
by_priority[q.priority.value] = by_priority.get(q.priority.value, 0) + 1
ready = QuestionPriority.P0.value not in by_priority
total = len(QUESTION_CATALOG)
summary = CompletenessSummary(
total_relevant=total,
answered=total - len(missing),
missing=len(missing),
missing_by_priority=by_priority,
ready_for_scope=ready,
note=(
"%d von %d Fakten vorhanden; %d offen. Scope-Engine startklar: %s."
% (total - len(missing), total, len(missing), "ja" if ready else "nein (P0 fehlt)")
),
)
return NavigatorResult(
missing_facts=[q.target_field for q in missing],
suggested_questions=missing,
completeness_summary=summary,
)
def _coerce(q: NavigatorQuestion, value: Any) -> Any:
if q.target_field in _ENUM_FIELDS:
return _ENUM_FIELDS[q.target_field](value)
if q.target_field == "components":
return [c if isinstance(c, ProductComponent) else ProductComponent(**c) for c in (value or [])]
if q.answer_type.value in {"country_list", "multiselect"}:
return list(value or [])
if q.answer_type.value == "bool":
return bool(value)
return value
def apply_answers(
profile: CanonicalProductRegulatoryProfile, answers: Dict[str, Any]
) -> CanonicalProductRegulatoryProfile:
updated = profile.model_copy(deep=True)
by_id = {q.question_id: q for q in QUESTION_CATALOG}
for question_id, raw in answers.items():
q = by_id.get(question_id)
if q is None or raw is None:
continue
value = _coerce(q, raw)
if "." in q.target_field:
head, tail = q.target_field.split(".", 1)
setattr(getattr(updated, head), tail, value)
else:
setattr(updated, q.target_field, value)
return updated
@@ -0,0 +1,171 @@
"""Product Regulatory Navigator — question catalog.
The Navigator is a THIN missing-facts layer over CanonicalProductRegulatoryProfile.
It does NOT decide what applies — `regulatory_domains_unblocked` is static metadata
(which domains a fact would help the Scope Engine decide later), never an
evaluation. No regulation logic, no UI, no Go, no RAG.
`NavigatorQuestion` is an interaction type, NOT a compliance-meta-model class
(architecture freeze v1.0 untouched).
"""
from __future__ import annotations
from enum import Enum
from typing import List
from pydantic import BaseModel, Field
from compliance.profile.canonical import CanonicalLifecyclePhase, EconomicOperatorRole
class AnswerType(str, Enum):
BOOL = "bool"
ENUM = "enum"
MULTISELECT = "multiselect"
TEXT = "text"
COUNTRY_LIST = "country_list"
COMPONENT_LIST = "component_list"
class QuestionPriority(str, Enum):
P0 = "P0" # blocks scope: EU-vs-not, role, lifecycle, machine/component
P1 = "P1" # unblocks a specific domain: RED, Data Act, environment, security
P2 = "P2" # refinement: structured BOM
_PRIORITY_ORDER = {QuestionPriority.P0: 0, QuestionPriority.P1: 1, QuestionPriority.P2: 2}
class NavigatorQuestion(BaseModel):
question_id: str
target_field: str # dotted path into the canonical profile
label: str
why_needed: str
regulatory_domains_unblocked: List[str] = Field(default_factory=list)
answer_type: AnswerType
options: List[str] = Field(default_factory=list)
priority: QuestionPriority
def order(self) -> int:
return _PRIORITY_ORDER[self.priority]
_ROLE_OPTIONS = [e.value for e in EconomicOperatorRole]
_PHASE_OPTIONS = [e.value for e in CanonicalLifecyclePhase]
QUESTION_CATALOG: List[NavigatorQuestion] = [
# ── P0: block the scope decision itself ───────────────────────────
NavigatorQuestion(
question_id="markets",
target_field="markets",
label="In welche Märkte / Länder liefern Sie das Produkt?",
why_needed="Bestimmt EU- vs. Nicht-EU-Anwendbarkeit und nationale Pflichten.",
regulatory_domains_unblocked=["cyber", "machine_safety", "data", "radio", "emv", "environment"],
answer_type=AnswerType.COUNTRY_LIST,
priority=QuestionPriority.P0,
),
NavigatorQuestion(
question_id="economic_operator_role",
target_field="economic_operator_role",
label="Welche Rolle nehmen Sie ein?",
why_needed="Pflichten hängen von der Rolle ab (Hersteller/Importeur/Händler/Betreiber/Service).",
regulatory_domains_unblocked=["cyber", "machine_safety", "data"],
answer_type=AnswerType.ENUM,
options=_ROLE_OPTIONS,
priority=QuestionPriority.P0,
),
NavigatorQuestion(
question_id="lifecycle_phase",
target_field="lifecycle_phase",
label="In welcher Lebenszyklusphase betrachten Sie das Produkt?",
why_needed="Manche Pflichten greifen nur beim Inverkehrbringen oder in der Wartung.",
regulatory_domains_unblocked=["cyber", "machine_safety"],
answer_type=AnswerType.ENUM,
options=_PHASE_OPTIONS,
priority=QuestionPriority.P0,
),
NavigatorQuestion(
question_id="is_machine",
target_field="is_machine",
label="Ist das Produkt eine (vollständige) Maschine?",
why_needed="Entscheidet die Anwendbarkeit der Maschinenverordnung.",
regulatory_domains_unblocked=["machine_safety"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P0,
),
NavigatorQuestion(
question_id="is_component",
target_field="is_component",
label="Ist das Produkt ein Bauteil / eine unvollständige Maschine?",
why_needed="Sicherheitsbauteil vs. vollständige Maschine ändert die Pflichten.",
regulatory_domains_unblocked=["machine_safety"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P0,
),
# ── P1: unblock one specific domain ───────────────────────────────
NavigatorQuestion(
question_id="has_radio_module",
target_field="has_radio_module",
label="Enthält das Produkt ein Funkmodul (WLAN/Bluetooth/Mobilfunk)?",
why_needed="Ein Funkmodul löst die Funkanlagen-Richtlinie (RED) aus.",
regulatory_domains_unblocked=["radio"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P1,
),
NavigatorQuestion(
question_id="generates_usage_data",
target_field="generates_usage_data",
label="Erzeugt das vernetzte Produkt nutzbare Produkt-/Nutzungsdaten?",
why_needed="Erzeugte Nutzungsdaten entscheiden über Data-Act-Pflichten.",
regulatory_domains_unblocked=["data"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P1,
),
NavigatorQuestion(
question_id="has_security_function",
target_field="has_security_function",
label="Hat das Produkt eine dedizierte Security-Funktion (gegen böswillige Akteure)?",
why_needed="Trennt Security- von Safety-Funktion (CRA vs. MaschinenVO).",
regulatory_domains_unblocked=["cyber", "machine_safety"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P1,
),
NavigatorQuestion(
question_id="env_wastewater",
target_field="environmental.discharges_to_wastewater",
label="Gibt das Produkt Stoffe an Wasser / Abwasser ab?",
why_needed="Abwassereinleitung löst Abwasser-/Gewässerrecht aus.",
regulatory_domains_unblocked=["environment_water"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P1,
),
NavigatorQuestion(
question_id="env_air",
target_field="environmental.emits_to_air",
label="Entstehen Luftemissionen (VOC, Staub, Verbrennung, Aerosole)?",
why_needed="Luftemissionen lösen Immissionsschutzrecht aus.",
regulatory_domains_unblocked=["environment_air"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P1,
),
NavigatorQuestion(
question_id="env_chemicals",
target_field="environmental.uses_cleaning_chemicals",
label="Werden Reinigungs-, Desinfektions- oder Biozidmittel verwendet/mitgeliefert?",
why_needed="Chemikalien lösen REACH/CLP/Detergenzien-/Biozidrecht aus.",
regulatory_domains_unblocked=["chemicals"],
answer_type=AnswerType.BOOL,
priority=QuestionPriority.P1,
),
# ── P2: refinement ────────────────────────────────────────────────
NavigatorQuestion(
question_id="components",
target_field="components",
label="Aus welchen wesentlichen Komponenten besteht das Produkt?",
why_needed="Eine strukturierte Stückliste verfeinert komponenten-abgeleitete Pflichten.",
regulatory_domains_unblocked=["radio", "emv", "environment_water", "chemicals"],
answer_type=AnswerType.COMPONENT_LIST,
priority=QuestionPriority.P2,
),
]