"""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