739a477d3f
ONE canonical product profile so the Go gap engine and the Python reasoning
engine stop diverging ("SPS mit Remote Access" means the same everywhere).
gap.ProductProfile LEADS; the reasoning ProductProfile becomes an adapter/DTO.
Types + mappers only — no regulation logic, no Go changes, no UI, no new questions.
- CanonicalProductRegulatoryProfile mirrors gap.ProductProfile + the Navigator
gaps the audit found: economic-operator role, radio_module, generates_usage_data,
lifecycle_phase, structured BOM (ProductComponent), safety-vs-security split,
machine-vs-component + a forward-looking EnvironmentalImpact domain (wastewater/
air/chemicals triggers — fields only, no rules yet).
- Mappers: from_product_wizard (lossless), from_company_profile (prefill incl.
the machineBuilder block), to_gap_profile (emits the unchanged gap JSON shape),
to_reasoning_profile (projects into the reasoning ProductProfile; AI stays
delegated to ai-act/ucca). Only profile->reasoning is coupled; reasoning stays
hermetic.
- 10 tests = the 10 acceptance criteria incl. ProductWizard round-trip lossless,
markets no longer forced ['EU'], and canonical->reasoning->discover_scope
proving one semantic profile drives the engine. 33 tests green, mypy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
89 lines
3.7 KiB
Python
89 lines
3.7 KiB
Python
"""CanonicalProductRegulatoryProfile -> reasoning ProductProfile (adapter/DTO).
|
|
|
|
The reasoning engine stays the consumer, never the source of truth (spec): the
|
|
canonical leads, this projects it into the Python reasoning ProductProfile so the
|
|
Reasoning engine and the Go gap engine run off ONE semantic profile (acceptance
|
|
#10). AI classification is NOT done here — only `uses_ai` is forwarded; risk
|
|
classification stays delegated to ai-act/ucca (acceptance #3).
|
|
|
|
This is the ONLY one-way coupling profile -> reasoning; reasoning never imports
|
|
profile, so the reasoning layer stays hermetic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Optional
|
|
|
|
from compliance.reasoning.enums import ManufacturerRole, MarketModel, ProductLifecyclePhase
|
|
from compliance.reasoning.schemas import ProductProfile
|
|
|
|
from .canonical import CanonicalProductRegulatoryProfile, CanonicalProductType
|
|
|
|
_SOFTWARE_TYPES = {CanonicalProductType.SOFTWARE, CanonicalProductType.SAAS, CanonicalProductType.IOT}
|
|
_SOFTWARE_TECH = {"ai", "api", "database", "encryption", "ota_updates", "cloud", "blockchain"}
|
|
_EU_HINTS = {"DE", "AT", "FR", "IT", "NL", "LU", "LI", "EU", "EWR", "EEA", "DACH"}
|
|
_B2X = {"B2B": MarketModel.B2B, "B2C": MarketModel.B2C, "B2B_B2C": MarketModel.BOTH, "B2B2C": MarketModel.BOTH}
|
|
|
|
|
|
def _or_none(*values: Optional[bool]) -> Optional[bool]:
|
|
"""True if any value is truthy; None if all are None/absent; else False."""
|
|
if any(v is True for v in values):
|
|
return True
|
|
if all(v is None for v in values):
|
|
return None
|
|
return False
|
|
|
|
|
|
def _has_software(c: CanonicalProductRegulatoryProfile) -> Optional[bool]:
|
|
type_sig = True if c.product_type in _SOFTWARE_TYPES else None
|
|
tech_sig = True if (set(c.technologies) & _SOFTWARE_TECH) else None
|
|
return _or_none(c.has_embedded_software, c.has_software_updates, c.uses_ai, type_sig, tech_sig)
|
|
|
|
|
|
def _eu_market(markets: List[str]) -> Optional[bool]:
|
|
if not markets:
|
|
return None
|
|
return True if (set(markets) & _EU_HINTS) else False
|
|
|
|
|
|
def _has_radio(c: CanonicalProductRegulatoryProfile) -> Optional[bool]:
|
|
if c.has_radio_module is not None:
|
|
return c.has_radio_module
|
|
if any(comp.kind.value == "radio_module" for comp in c.components):
|
|
return True
|
|
return None
|
|
|
|
|
|
def to_reasoning_profile(c: CanonicalProductRegulatoryProfile) -> ProductProfile:
|
|
role = ManufacturerRole(c.economic_operator_role.value) if c.economic_operator_role else None
|
|
phase = ProductLifecyclePhase(c.lifecycle_phase.value) if c.lifecycle_phase else None
|
|
b2x = _B2X.get(c.b2b_or_b2c) if c.b2b_or_b2c else None
|
|
is_machine = c.is_machine if c.is_machine is not None else (
|
|
True if c.product_type == CanonicalProductType.MACHINERY else None
|
|
)
|
|
generates_data = c.generates_usage_data if c.generates_usage_data is not None else (
|
|
True if "telemetry" in c.data_processing else None
|
|
)
|
|
return ProductProfile(
|
|
product_name=c.name or "Produkt",
|
|
product_profile_id=c.product_profile_id,
|
|
manufacturer_role=role,
|
|
product_type=[c.product_type.value] if c.product_type else [],
|
|
has_software=_has_software(c),
|
|
has_embedded_software=c.has_embedded_software,
|
|
has_remote_access=c.has_remote_access,
|
|
has_cloud_connection=True if "cloud" in c.technologies else None,
|
|
has_ai_functionality=c.uses_ai,
|
|
has_radio_module=_has_radio(c),
|
|
has_safety_function=c.has_safety_function,
|
|
generates_usage_data=generates_data,
|
|
is_machine=is_machine,
|
|
is_component=c.is_component,
|
|
is_spare_part=c.is_spare_part,
|
|
eu_market=_eu_market(c.markets),
|
|
b2b_or_b2c=b2x,
|
|
lifecycle_phase=phase,
|
|
company_size=c.company_size,
|
|
sector=c.sector_industry,
|
|
)
|