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,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,
),
]