diff --git a/backend-compliance/compliance/services/specialist_agents/impressum/mcs.py b/backend-compliance/compliance/services/specialist_agents/impressum/mcs.py index b336fe28..f8b6cd99 100644 --- a/backend-compliance/compliance/services/specialist_agents/impressum/mcs.py +++ b/backend-compliance/compliance/services/specialist_agents/impressum/mcs.py @@ -66,7 +66,8 @@ MCS: tuple[MC, ...] = ( norm="§ 5 Abs. 1 Nr. 2 TMG", severity_if_missing="MEDIUM", patterns=(re.compile( - r"(?:Tel(?:efon)?|Phone)\.?\s*[:.\s]\s*[\+\d][\d\s/\-()]{5,}", + r"(?:Tel(?:efon(?:nummer)?)?|Phone|Fon)\.?\s*[:.\s]\s*" + r"[\+\d][\d\s/\-()]{5,}", re.IGNORECASE, ),), ), diff --git a/backend-compliance/tests/fixtures/__init__.py b/backend-compliance/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend-compliance/tests/fixtures/impressum_groundtruth.py b/backend-compliance/tests/fixtures/impressum_groundtruth.py new file mode 100644 index 00000000..07b48459 --- /dev/null +++ b/backend-compliance/tests/fixtures/impressum_groundtruth.py @@ -0,0 +1,209 @@ +"""Ground-Truth-Fixtures: 5 Impressums + erwartete Findings. + +Quelle: User-Vorgabe 2026-06-09. Texte sind die echten Impressums von: + - ETO Gruppe Technologies GmbH (Stockach) + - SafetyKon GmbH (Freiburg) + - BMW AG (München) + - Elli (Volkswagen Group Charging + Elli Mobility, Berlin) + - Hectronic Vertriebs- und Service GmbH (Bonndorf) + +Pro Impressum: + - text: Volltext (User-eingegebene Bereinigung) + - expected_findings: field_ids die wir POSITIV erwarten + - expected_clean: field_ids die NICHT auftauchen sollten + - placement_concerns: Texte die deplatziert sind (für Cross-Agent) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class ImpressumGT: + name: str + text: str + expected_findings: tuple[str, ...] = field(default_factory=tuple) + expected_clean: tuple[str, ...] = field(default_factory=tuple) + placement_concerns: tuple[str, ...] = field(default_factory=tuple) + business_scope: tuple[str, ...] = field(default_factory=tuple) + + +ETO = ImpressumGT( + name="ETO Gruppe", + text=( + "Impressum\n\n" + "Anbieterin i.S.d. § 5 DDG:\n\n" + "ETO GRUPPE TECHNOLOGIES GmbH\n" + "Hardtring 8\n" + "78333 Stockach\n" + "DEUTSCHLAND\n\n" + "Telefon: +49 7771 809-0\n" + "Telefax: +49 7771 809-100\n" + "E-Mail: info@etogruppe.com\n\n" + "Geschäftsführer: Volker Groß, Patrick Boos und Hubertus Stroetmann\n" + "Handelsregister: Amtsgericht Freiburg, HRB707267\n" + "USt-ID-Nr.: DE280394267\n" + ), + expected_clean=( + # Alle Pflichtangaben da → keine HIGH-Findings + "name_anbieter", "kontakt_email", "kontakt_telefon", + "handelsregister", "ust_id", "vertretungsberechtigte", + "vertretungsberechtigte_label_korrekt", + ), + placement_concerns=(), # ETO-Block selbst ist sauber +) + + +SAFETYKON = ImpressumGT( + name="SafetyKon GmbH", + text=( + "Datenschutz Verantwortlich für die Inhalte dieser Website\n\n" + "SafetyKon GmbH\nMerzhauser Str. 144\n" + "79100 Freiburg im Breisgau\n" + "Telefon 0761 / 48 98 09 01\n" + "E-Mail: info@safetykon.de\n\n" + "Geschäftsführung: Dr. Oliver Kirchwehm\n\n" + "Sitz und Registergericht: Handelsregister AG Freiburg, HRB 709859\n\n" + "Umsatzsteueridentifikationsnummer: DE288952921\n\n" + "Urheberrecht\n" + "Diese Webseiten dienen der Information über die SafetyKon GmbH...\n" + "Bilder & Lizenzen\n" + "Screenshot SISTEMA: Aleksandr_Samochernyi / Freepik\n" + "Haftungsausschluss für Informationen\n" + "Die Inhalte dieser Webseiten dienen lediglich allgemeinen Informationszwecken...\n" + "Haftungsausschluss für Links\n" + "Inhalte derjenigen fremden Internetseiten...\n" + ), + expected_clean=( + "name_anbieter", "kontakt_email", "kontakt_telefon", + "handelsregister", "ust_id", "vertretungsberechtigte", + "vertretungsberechtigte_label_korrekt", + ), + placement_concerns=( + "urheberrecht", # gehört in Legal/Copyright-Seite + "bilder_lizenzen", # gehört in Legal/Copyright-Seite + "haftungsausschluss", # gehört in Legal/Disclaimer-Seite + ), +) + + +BMW = ImpressumGT( + name="BMW AG", + text=( + "Impressum.\n" + "Diese Webseite wird von der Bayerische Motoren Werke " + "Aktiengesellschaft (Petuelring 130, 80809 München) betrieben.\n\n" + "Kontakt BMW: kundenbetreuung@bmw.de\n" + "Telefon: 089 1250 160 00\n\n" + "Die Bayerischen Motoren Werke Aktiengesellschaft (BMW AG) wird " + "gesetzlich durch den Vorstand (Milan Nedeljković, Vorsitzender, " + "Jochen Goller, Ilka Horstmeier, Nicolai Martin, Walter Mertl, " + "Joachim Post, Raymond Wittmann) vertreten.\n\n" + "Vorsitzender des Aufsichtsrats: Nicolas Peter\n\n" + "Sitz und Registergericht: München HRB 42243\n" + "Umsatzsteueridentifikationsnummer: DE129273398\n\n" + "Versicherungsvermittlerregister: D-GKZS-MTLXN-32\n\n" + "Erlaubnisbefreiung nach § 34 d Abs. 6 GewO, Aufsichtsbehörde:\n" + "IHK für München und Oberbayern\n" + "Max-Joseph-Straße 2\n80333 München\n\n" + "Berufsbezeichnung: Versicherungsvertreter mit Erlaubnisbefreiung " + "nach § 34d Abs. 6 GewO\n\n" + "Berufsrechtliche Regelungen:\n" + "• § 34 d Gewerbeordnung\n• §§ 59-68 VVG\n• VersVermV\n" + ), + expected_clean=( + "name_anbieter", "kontakt_email", "kontakt_telefon", + "handelsregister", "ust_id", "vertretungsberechtigte", + "vertretungsberechtigte_label_korrekt", + # BMW listet IHK München als Aufsicht → MC-008 sollte OK sein + "aufsichtsbehoerde", + ), + # Berufsangaben ist da → bei scope regulated_profession OK + business_scope=("regulated_profession",), +) + + +ELLI = ImpressumGT( + name="Elli (VW Group Charging + Elli Mobility)", + text=( + "Impressum\n" + "Anbieterkennzeichnung für Inhalte zu Zuhause laden, Flexpole " + "und Energielösungen:\n\n" + "Volkswagen Group Charging GmbH\n" + "Sitz in Berlin.\n" + "Geschäftsführer:\n" + "Giovanni Palazzo (CEO)\n" + "Mark Möller (CTO)\n" + "Dr. Tobias Canz (CFO)\n" + "Anja Christmann (CHRO)\n\n" + "Postanschrift: Karl-Liebknecht-Str. 32, 10178 Berlin\n" + "Telefonnummer: 00800 3554 1111\n" + "E-Mail: info@elli.eco\n\n" + "Handelsregister Amtsgericht Charlottenburg HRB 208967 B\n\n" + "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:\n" + "Giovanni Palazzo, Karl-Liebknecht-Str. 32, 10178 Berlin\n\n" + "Anbieterkennzeichnung für Inhalte zu Öffentlich laden (MSP), " + "Flottenlösungen, CSM, Charge&Fuel:\n\n" + "Elli Mobility GmbH\nSitz in Berlin.\n" + "Geschäftsführer: Joschi Jennermann, Sebastian Steffen\n\n" + "Postanschrift: Karl-Liebknecht-Str. 32, 10178 Berlin\n" + "Telefonnummer: 00800 – 00002030\n" + "E-Mail: ellimobility@elli.eco\n\n" + "Handelsregister Amtsgericht Charlottenburg HRB 274616 B\n\n" + "Umsatzsteueridentifikationsnummer: DE814424009\n\n" + "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:\n" + "Joschi Jennermann und Sebastian Steffen\n\n" + "Die Europäische Kommission stellt eine Plattform zur " + "Online-Streitbeilegung (OS) bereit: " + "http://ec.europa.eu/consumers/odr/.\n\n" + "Schlichtungsstelle Energie e.V., Friedrichstr. 133, 10117 Berlin\n" + ), + expected_clean=( + "name_anbieter", "kontakt_email", "kontakt_telefon", + "handelsregister", "ust_id", "vertretungsberechtigte", + "vertretungsberechtigte_label_korrekt", + "odr_link", # explizit drin + # § 18 MStV bzw. § 55 Abs. 2 RStV ist genannt; bei editorial-scope OK + ), + business_scope=("ecommerce", "b2c"), # Charging-Provider B2C +) + + +HECTRONIC = ImpressumGT( + name="Hectronic Vertriebs- und Service GmbH", + text=( + "Impressum\n\n" + "Hectronic Vertriebs- und Service GmbH | Allmendstrasse 15 | " + "79848 Bonndorf | Tel. +49 30 8632459 10 | " + "Fax: +49 30 8632 459 89 | info@hectronic.de\n\n" + "Geschäftsführer: Stefan Schiefelbein, Sebastian Mömkes, " + "Stefan Forster\n\n" + "Sitz der Gesellschaft: D-79848 Bonndorf\n" + "Amtsgericht Freiburg, HRB 709669 | USt-IdNr.: DE287652484\n\n" + "Inhaltlich Verantwortlich gemäß § 18 Abs. 2 MStV\n" + "Eckhard Fechtig (Anschrift siehe oben)\n\n" + "Angaben nach dem Elektro- und Elektronikgerätegesetz (ElektroG)\n" + "WEEE-Reg.-Nr.: DE 64824538\n\n" + "Copyright\n" + "Alle verwendeten Fotos, Grafiken, Texte und sonstigen Bestandteile " + "dieser Website unterliegen dem Copyright der Hectronic GmbH...\n\n" + "Haftungsausschluss\n" + "Die Inhalte unserer Internetseiten werden sorgfältig geprüft...\n" + ), + expected_clean=( + "name_anbieter", "kontakt_email", "kontakt_telefon", + "handelsregister", "ust_id", "vertretungsberechtigte", + "vertretungsberechtigte_label_korrekt", + "verantwortlicher_redaktion", # Fechtig nach § 18 MStV genannt + ), + placement_concerns=( + "weee_elektrog", # gehört eher in Produkt/Recycling-Seite + "copyright", # gehört in Legal/Copyright-Seite + "haftungsausschluss", # gehört in Legal/Disclaimer-Seite + ), + business_scope=("editorial",), # weil § 18 MStV genannt +) + + +ALL_GROUND_TRUTH = (ETO, SAFETYKON, BMW, ELLI, HECTRONIC) diff --git a/backend-compliance/tests/test_impressum_groundtruth.py b/backend-compliance/tests/test_impressum_groundtruth.py new file mode 100644 index 00000000..595197ce --- /dev/null +++ b/backend-compliance/tests/test_impressum_groundtruth.py @@ -0,0 +1,110 @@ +"""Ground-Truth-Vergleich: lässt jedes Impressum durch den Agenten +laufen und vergleicht Output gegen expected_findings / expected_clean. + +Hauptzweck: Pattern-Lücken sofort sichtbar machen sobald sie auftauchen. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from compliance.services.specialist_agents import AgentInput, ImpressumAgent +from tests.fixtures.impressum_groundtruth import ALL_GROUND_TRUTH + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +@pytest.fixture(autouse=True) +def _no_llm(monkeypatch): + """Skip LLM-Eskalation in den GT-Tests — wir testen MC-Pattern, + nicht LLM-Halluzinationen.""" + async def _no_cascade(*a, **kw): return None, [] + monkeypatch.setattr( + "compliance.services.specialist_agents.impressum.agent.cascade", + _no_cascade, + ) + + +@pytest.mark.parametrize("gt", ALL_GROUND_TRUTH, ids=lambda g: g.name) +def test_no_false_positives_on_expected_clean(gt): + """Felder die laut GT da sind dürfen keine Findings produzieren.""" + agent = ImpressumAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", + text=gt.text, + business_scope=list(gt.business_scope), + ))) + fp_field_ids = { + f.field_id for f in out.findings + if f.field_id in gt.expected_clean + } + assert not fp_field_ids, ( + f"{gt.name}: FALSE-POSITIVE Findings für " + f"explizit erwartete Felder: {sorted(fp_field_ids)}. " + f"Alle Findings: " + f"{sorted({f.field_id for f in out.findings})}." + ) + + +@pytest.mark.parametrize("gt", ALL_GROUND_TRUTH, ids=lambda g: g.name) +def test_high_findings_have_norm_and_action(gt): + """Falls Findings da sind, müssen sie norm + action enthalten.""" + agent = ImpressumAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", + text=gt.text, + business_scope=list(gt.business_scope), + ))) + for f in out.findings: + assert f.norm, f"{gt.name}: Finding {f.check_id} ohne norm" + assert f.action, f"{gt.name}: Finding {f.check_id} ohne action" + + +def test_eto_no_findings_at_all(): + """ETO-Impressum ist vollständig — 0 Findings erwartet.""" + agent = ImpressumAgent() + gt = next(g for g in ALL_GROUND_TRUTH if "ETO" in g.name) + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", + text=gt.text, + business_scope=list(gt.business_scope), + ))) + assert not out.findings, ( + f"ETO sollte 0 Findings haben, hat aber: " + f"{[f.field_id for f in out.findings]}" + ) + + +def test_bmw_passes_full_check(): + """BMW-Impressum hat alle Pflichtangaben — 0 Findings.""" + agent = ImpressumAgent() + gt = next(g for g in ALL_GROUND_TRUTH if "BMW" in g.name) + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", + text=gt.text, + business_scope=list(gt.business_scope), + ))) + assert not out.findings, ( + f"BMW sollte 0 Findings haben, hat aber: " + f"{[f.field_id for f in out.findings]}" + ) + + +def test_hectronic_passes_with_editorial_scope(): + """Hectronic nennt § 18 MStV → kein Finding bei editorial-scope.""" + agent = ImpressumAgent() + gt = next(g for g in ALL_GROUND_TRUTH if "Hectronic" in g.name) + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", + text=gt.text, + business_scope=list(gt.business_scope), + ))) + field_ids = {f.field_id for f in out.findings} + assert "verantwortlicher_redaktion" not in field_ids, ( + f"Hectronic nennt § 18 MStV — sollte kein Finding sein. " + f"Got: {sorted(field_ids)}" + )