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