feat(agent): 4-Status-Modell (NOT_APPLICABLE/INSUFFICIENT_EVIDENCE/POSSIBLY_APPLICABLE) für Impressum
Kanonisches Compliance-Datenmodell, Impressum-Agent als Referenz: - CheckStatus-Enum + Finding.status GETRENNT von severity (Verdikt ≠ Risiko) - Unbestimmte Rechtsform (weder Text noch Wizard) → INSUFFICIENT_EVIDENCE (INFO) statt hartem HIGH-FAIL; legal_form_dependent-Gate + detect_legal_form_present - §18-MStV-Graubereich (Corporate-Blog via has_editorial_content) → POSSIBLY_APPLICABLE (LOW Prüf-Hinweis); 3-stufig via scope_disposition - Recommendations nur aus echten FAILs; mc_insufficient/mc_possibly-Aggregate - Frontend: Verdikt-Pill + Coverage-Vokabular - 19 neue Tests (test_four_status.py, AgentFindingCard); CI-Suite 204 grün, v3 25 / GT 13 unverändert Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user