"""4-Status-Modell: Applicability ≠ Compliance, Unknown ≠ Fail. User-Datenmodell 2026-06-10: - Rechtsform-abhaengige Pflicht (Handelsregister/Vertretungsberechtigte) bei UNBESTIMMTER Rechtsform → INSUFFICIENT_EVIDENCE (severity INFO), NICHT hartes FAIL ('Muster Consulting' ohne Rechtsform). - Rechtsform im Text ODER im Wizard-Scope → FAIL bei Fehlen (wie bisher). - Ausgeschlossene Rechtsform (Verein→Handelsregister) → NOT_APPLICABLE. - status (Verdikt) ist getrennt von severity (Risiko). """ from __future__ import annotations import asyncio import pytest from compliance.api.agent_check._agent_outputs import _derive_scope from compliance.services.specialist_agents import AgentInput from compliance.services.specialist_agents._base import CheckStatus, Severity from compliance.services.specialist_agents.impressum._classification import ( scan_context_to_scope, ) from compliance.services.specialist_agents.impressum.agent import ImpressumAgent from compliance.services.specialist_agents.impressum.mcs import ( MCS, detect_legal_form_present, scope_disposition, ) _MC009 = next(m for m in MCS if m.mc_id == "IMP-MC-009") # Pflicht-Felder vorhanden (Name/Email/Telefon) ABER keine Rechtsform, # kein HR, keine Vertretungsangabe im Text. TEXT_NO_LEGAL_FORM = ( "Angaben gemäß § 5 TMG\n\n" "Muster Consulting\n" "Musterstraße 1\n" "12345 Berlin\n\n" "E-Mail: info@example.com\n" "Telefon: +49 30 1234567\n" "Weitere Hinweise finden Sie auf unserer Seite.\n" ) # Gleicher Text, aber Rechtsform GmbH im Text → registerpflichtig erkennbar. TEXT_WITH_GMBH = TEXT_NO_LEGAL_FORM.replace( "Muster Consulting", "Muster Consulting GmbH") @pytest.fixture(autouse=True) def _llm_offline(monkeypatch): async def _no_validate(*_a, **_kw): return {} monkeypatch.setattr( "compliance.services.specialist_agents.impressum.agent.validate_present", _no_validate, raising=False, ) def _run(scan_context: dict | None, text: str = TEXT_NO_LEGAL_FORM): agent = ImpressumAgent() scope = scan_context_to_scope(scan_context) if scan_context else [] return asyncio.run(agent.evaluate(AgentInput( doc_type="impressum", text=text, business_scope=scope))) def _by_field(out, field_id): return next((f for f in out.findings if f.field_id == field_id), None) # ── detect_legal_form_present ─────────────────────────────────────── def test_detector_recognizes_rechtsform(): assert detect_legal_form_present("Muster Consulting GmbH") assert detect_legal_form_present("Beispiel AG, Berlin") assert detect_legal_form_present("Max Mustermann e.K.") def test_detector_no_rechtsform(): assert not detect_legal_form_present(TEXT_NO_LEGAL_FORM) assert not detect_legal_form_present("Muster Consulting, Berlin") # ── Unknown ≠ Fail: unbestimmte Rechtsform → INSUFFICIENT_EVIDENCE ── def test_unknown_legal_form_is_insufficient_not_fail(): out = _run(None) # kein Wizard-Scope, keine Rechtsform im Text hr = _by_field(out, "handelsregister") vt = _by_field(out, "vertretungsberechtigte") assert hr is not None and vt is not None for f in (hr, vt): assert f.status == CheckStatus.INSUFFICIENT_EVIDENCE.value assert f.severity == Severity.INFO.value # kein HIGH! assert f.severity_reason == "rechtsform_unbestimmt" def test_insufficient_evidence_counted_in_aggregate(): out = _run(None) assert out.mc_insufficient >= 2 cov = {c.mc_id: c.status for c in out.mc_coverage} assert cov["IMP-MC-004"] == "insufficient_evidence" assert cov["IMP-MC-006"] == "insufficient_evidence" def test_insufficient_findings_produce_no_recommendation(): # Hinweise sind keine Pflicht-Massnahmen → kein Rollup. out = _run(None) insuf_ids = {f.check_id for f in out.findings if f.status == CheckStatus.INSUFFICIENT_EVIDENCE.value} for rec in out.recommendations: assert not (set(rec.related_finding_ids) & insuf_ids) # ── Rechtsform bekannt → FAIL (Text oder Wizard) ──────────────────── def test_rechtsform_in_text_yields_fail(): out = _run(None, text=TEXT_WITH_GMBH) hr = _by_field(out, "handelsregister") assert hr is not None assert hr.status == CheckStatus.FAIL.value assert hr.severity == Severity.HIGH.value def test_rechtsform_in_wizard_scope_yields_fail(): out = _run({"legal_form": "gmbh"}) # Text ohne Rechtsform, Wizard kennt sie hr = _by_field(out, "handelsregister") assert hr is not None assert hr.status == CheckStatus.FAIL.value assert hr.severity == Severity.HIGH.value # ── Applicability ≠ Compliance: Verein → NOT_APPLICABLE ───────────── def test_verein_handelsregister_not_applicable(): out = _run({"legal_form": "verein"}) assert _by_field(out, "handelsregister") is None # kein Finding cov = {c.mc_id: c.status for c in out.mc_coverage} assert cov["IMP-MC-004"] == "na" def test_default_finding_status_is_fail(): # Nicht-rechtsform-abhaengige Pflicht (Name) bleibt FAIL bei Fehlen. out = _run(None, text="Angaben gemäß § 5 TMG\n" + "x" * 120) name = _by_field(out, "name_anbieter") assert name is not None assert name.status == CheckStatus.FAIL.value # ── POSSIBLY_APPLICABLE: §18-MStV-Graubereich (editorial) ─────────── def test_scope_disposition_three_way(): assert scope_disposition(_MC009, {"editorial"}, False) == "applies" assert scope_disposition(_MC009, {"editorial_possible"}, False) == "possible" assert scope_disposition(_MC009, set(), False) == "na" def test_editorial_hard_yields_fail(): # industry=media (Verlag/Presse) → harte §18-Pflicht. out = _run({"industry": "media"}) red = _by_field(out, "verantwortlicher_redaktion") assert red is not None assert red.status == CheckStatus.FAIL.value assert red.severity == Severity.MEDIUM.value def test_editorial_possible_yields_possibly_applicable(): # Corporate-Blog (has_editorial_content, kein media) → Graubereich. out = ImpressumAgent() out = asyncio.run(out.evaluate(AgentInput( doc_type="impressum", text=TEXT_NO_LEGAL_FORM, business_scope=["editorial_possible"]))) red = _by_field(out, "verantwortlicher_redaktion") assert red is not None assert red.status == CheckStatus.POSSIBLY_APPLICABLE.value assert red.severity == Severity.LOW.value assert out.mc_possibly >= 1 assert "§ 18 MStV" in red.action # MC-spezifischer Hinweis def test_editorial_absent_is_not_applicable(): out = _run(None) # kein editorial-Signal assert _by_field(out, "verantwortlicher_redaktion") is None cov = {c.mc_id: c.status for c in out.mc_coverage} assert cov["IMP-MC-009"] == "na" def test_derive_scope_editorial_tiers(): assert "editorial_possible" in _derive_scope({"has_editorial_content": True}) assert "editorial" in _derive_scope({"industry": "media"}) # Medienunternehmen gewinnt — nicht beide Tokens. s = _derive_scope({"industry": "media", "has_editorial_content": True}) assert "editorial" in s and "editorial_possible" not in s # ── §36 VSBG Graubereich (BMW-Fall): reines b2c ≠ harter Verstoß ──── def test_vsbg_b2c_is_possibly_applicable(): # Reine B2C-Orientierung (z.B. OEM-Markenseite, Verkauf über Händler) → # §36 VSBG = Graubereich, KEIN MEDIUM-FAIL (BMW-False-Positive-Fix). out = asyncio.run(ImpressumAgent().evaluate(AgentInput( doc_type="impressum", text=TEXT_NO_LEGAL_FORM, business_scope=["b2c"]))) vsbg = _by_field(out, "verbraucher_streitbeilegung") assert vsbg is not None assert vsbg.status == CheckStatus.POSSIBLY_APPLICABLE.value assert vsbg.severity == Severity.LOW.value assert "VSBG" in vsbg.action # §36-Hinweis, nicht §18 def test_vsbg_ecommerce_is_hard_fail(): # Echter Online-Shop (ecommerce) → §36 VSBG harte Pflicht (MEDIUM). out = asyncio.run(ImpressumAgent().evaluate(AgentInput( doc_type="impressum", text=TEXT_NO_LEGAL_FORM, business_scope=["ecommerce"]))) vsbg = _by_field(out, "verbraucher_streitbeilegung") assert vsbg is not None assert vsbg.status == CheckStatus.FAIL.value assert vsbg.severity == Severity.MEDIUM.value