Files
breakpilot-compliance/backend-compliance/compliance/profile/to_reasoning.py
T
Benjamin Admin 739a477d3f feat(profile): CanonicalProductRegulatoryProfile convergence layer (types + mappers + tests)
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>
2026-06-26 09:52:46 +02:00

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,
)