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>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Tests for the Product Profile convergence layer.
|
||||
|
||||
Covers the 10 acceptance criteria of the CanonicalProductRegulatoryProfile spec:
|
||||
lossless ProductWizard mapping, company-profile prefill, AI stays delegated,
|
||||
markets no longer hardcoded, and the new Navigator fields (role/radio/usage-data/
|
||||
lifecycle/BOM) plus one-semantic-profile across reasoning + gap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.profile import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
ComponentKind,
|
||||
EconomicOperatorRole,
|
||||
ProductComponent,
|
||||
from_company_profile,
|
||||
from_product_wizard,
|
||||
to_gap_profile,
|
||||
to_reasoning_profile,
|
||||
)
|
||||
from compliance.reasoning import discover_scope
|
||||
from compliance.reasoning.enums import ManufacturerRole, ProductLifecyclePhase
|
||||
|
||||
# A realistic ProductWizard payload — exactly the gap.ProductProfile JSON shape.
|
||||
WIZARD = {
|
||||
"name": "Industriespülmaschine",
|
||||
"description": "vernetzte Spülmaschine",
|
||||
"product_type": "machinery",
|
||||
"technologies": ["cloud", "ota_updates", "sensor", "actuator"],
|
||||
"data_processing": ["telemetry"],
|
||||
"markets": ["EU"],
|
||||
"connected_to_internet": True,
|
||||
"has_software_updates": True,
|
||||
"uses_ai": False,
|
||||
"processes_personal_data": False,
|
||||
"is_critical_infra_supplier": False,
|
||||
"existing_certifications": ["CE"],
|
||||
"applied_norms": ["ISO12100"],
|
||||
"has_risk_assessment": True,
|
||||
"has_technical_file": True,
|
||||
"has_operating_manual": True,
|
||||
"has_sbom": False,
|
||||
"has_vuln_management": False,
|
||||
"has_update_mechanism": True,
|
||||
"has_incident_response": False,
|
||||
"has_supply_chain_mgmt": False,
|
||||
"ce_marking_since": "",
|
||||
"product_age": "5",
|
||||
}
|
||||
|
||||
COMPANY = {
|
||||
"company_name": "ACME Maschinen GmbH",
|
||||
"industry": "Maschinenbau",
|
||||
"business_model": "B2B",
|
||||
"company_size": "medium",
|
||||
"target_markets": ["DE", "EU"],
|
||||
"primary_jurisdiction": "DE",
|
||||
"headquarters_country": "DE",
|
||||
"uses_ai": False,
|
||||
"is_data_controller": True,
|
||||
"machine_builder": {
|
||||
"productDescription": "Industriespülmaschine",
|
||||
"productTypes": ["special_machine"],
|
||||
"containsSoftware": True,
|
||||
"containsFirmware": True,
|
||||
"containsAI": False,
|
||||
"hasSafetyFunction": True,
|
||||
"safetyFunctionDescription": "Türverriegelung",
|
||||
"isNetworked": True,
|
||||
"hasRemoteAccess": True,
|
||||
"hasOTAUpdates": True,
|
||||
"hasRiskAssessment": True,
|
||||
"criticalSectorClients": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# 1. ProductWizard data maps losslessly into the canonical and back to gap shape.
|
||||
def test_product_wizard_lossless_roundtrip():
|
||||
canonical = from_product_wizard(WIZARD)
|
||||
assert to_gap_profile(canonical) == WIZARD
|
||||
|
||||
|
||||
# 2. company-profile can prefill the canonical profile.
|
||||
def test_company_profile_prefill():
|
||||
c = from_company_profile(COMPANY)
|
||||
assert c.sector_industry == "Maschinenbau"
|
||||
assert c.b2b_or_b2c == "B2B"
|
||||
assert c.company_size == "medium"
|
||||
assert "DE" in c.markets and "EU" in c.markets
|
||||
assert c.has_safety_function is True
|
||||
assert c.has_remote_access is True
|
||||
assert c.has_embedded_software is True
|
||||
assert c.is_machine is True
|
||||
assert c.description == "Industriespülmaschine"
|
||||
|
||||
|
||||
# 3. AI-Act/ucca stays delegated — only uses_ai is forwarded, no risk classification.
|
||||
def test_ai_classification_stays_delegated():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", uses_ai=True)
|
||||
rp = to_reasoning_profile(c)
|
||||
assert rp.has_ai_functionality is True
|
||||
assert not hasattr(rp, "ai_risk_category") # no AI classification produced here
|
||||
|
||||
|
||||
# 4. markets are a real list, never hardcoded ['EU'].
|
||||
def test_markets_not_hardcoded_eu():
|
||||
assert CanonicalProductRegulatoryProfile(name="X").markets == []
|
||||
c = from_product_wizard({**WIZARD, "markets": ["US", "JP", "CA"]})
|
||||
assert c.markets == ["US", "JP", "CA"]
|
||||
assert to_gap_profile(c)["markets"] == ["US", "JP", "CA"]
|
||||
assert to_reasoning_profile(c).eu_market is False # non-EU markets -> not EU
|
||||
|
||||
|
||||
# 5. economic-operator role exists and maps to the reasoning role.
|
||||
def test_economic_operator_role_exists():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", economic_operator_role=EconomicOperatorRole.IMPORTER)
|
||||
assert to_reasoning_profile(c).manufacturer_role == ManufacturerRole.IMPORTER
|
||||
|
||||
|
||||
# 6. radio_module exists (direct + inferred from a BOM component).
|
||||
def test_radio_module_exists():
|
||||
assert to_reasoning_profile(CanonicalProductRegulatoryProfile(name="X", has_radio_module=True)).has_radio_module is True
|
||||
c = CanonicalProductRegulatoryProfile(name="X", components=[ProductComponent(name="WLAN", kind=ComponentKind.RADIO_MODULE)])
|
||||
assert to_reasoning_profile(c).has_radio_module is True
|
||||
|
||||
|
||||
# 7. generates_usage_data exists (direct + inferred from telemetry).
|
||||
def test_generates_usage_data_exists():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", generates_usage_data=True)
|
||||
assert to_reasoning_profile(c).generates_usage_data is True
|
||||
inferred = from_product_wizard(WIZARD) # data_processing has telemetry
|
||||
assert to_reasoning_profile(inferred).generates_usage_data is True
|
||||
|
||||
|
||||
# 8. lifecycle_phase exists and maps.
|
||||
def test_lifecycle_phase_exists():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", lifecycle_phase=CanonicalLifecyclePhase.MAINTENANCE)
|
||||
assert to_reasoning_profile(c).lifecycle_phase == ProductLifecyclePhase.MAINTENANCE
|
||||
|
||||
|
||||
# 9. BOM components are structured.
|
||||
def test_bom_components_structured():
|
||||
c = CanonicalProductRegulatoryProfile(
|
||||
name="Spülmaschine",
|
||||
components=[
|
||||
ProductComponent(name="Umwälzpumpe", kind=ComponentKind.PUMP),
|
||||
ProductComponent(name="Heizung", kind=ComponentKind.HEATING),
|
||||
ProductComponent(name="SPS", kind=ComponentKind.PLC),
|
||||
ProductComponent(name="Abwasserablauf", kind=ComponentKind.WASTEWATER_OUTLET),
|
||||
],
|
||||
)
|
||||
kinds = {comp.kind for comp in c.components}
|
||||
assert ComponentKind.PLC in kinds and ComponentKind.WASTEWATER_OUTLET in kinds
|
||||
|
||||
|
||||
# 10. reasoning engine + gap engine run off ONE semantic profile.
|
||||
def test_one_semantic_profile_reasoning_and_gap():
|
||||
canonical = CanonicalProductRegulatoryProfile(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
markets=["EU", "DE"],
|
||||
is_machine=True,
|
||||
has_safety_function=True,
|
||||
has_remote_access=True,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
gap = to_gap_profile(canonical)
|
||||
rp = to_reasoning_profile(canonical)
|
||||
|
||||
# same facts, two projections
|
||||
assert gap["markets"] == ["EU", "DE"]
|
||||
assert rp.eu_market is True
|
||||
assert rp.has_remote_access is True
|
||||
assert rp.has_cloud_connection is True
|
||||
assert rp.is_machine is True
|
||||
assert rp.manufacturer_role == ManufacturerRole.MANUFACTURER
|
||||
|
||||
# the projected reasoning profile actually drives the reasoning engine
|
||||
scope = discover_scope(rp)
|
||||
applicable = {r.regulation_id for r in scope.applicable_regulations}
|
||||
assert "CRA" in applicable
|
||||
assert "MaschinenVO" in applicable
|
||||
Reference in New Issue
Block a user