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:
@@ -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,
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user