78aeedafae
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>
172 lines
7.1 KiB
Python
172 lines
7.1 KiB
Python
"""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,
|
|
),
|
|
]
|