From 154e8c293ba5221cb609c9fb21fdeaa7e00d15b2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 9 Jun 2026 08:19:57 +0200 Subject: [PATCH] feat(agents): Cross-Placement-Agent (deplatzierter Content) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 1.9 (User-Vorgabe 2026-06-09): Erkennt im Impressum Inhalts-Sektionen die thematisch besser in einen Footer-Reiter 'Legal' gehoeren: - Urheberrecht / Copyright -> LOW (Footer 'Legal') - Bilder & Lizenzen -> LOW (Seite 'Bildquellen') - Haftungsausschluss / Disclaimer -> LOW (Seite 'Disclaimer') - Nutzungsbedingungen -> LOW (Seite 'AGB') - Aenderungsvorbehalt -> LOW - ElektroG / WEEE-Reg -> MEDIUM (Produktinfo) - VerpackG / LUCID -> MEDIUM - BattG -> MEDIUM Each Finding empfiehlt konkret den 'Legal'-Footer-Reiter einzufuehren als Best Practice ('Impressum bleibt schlank und enthaelt ausschliesslich die Pflichtangaben nach § 5 TMG/DDG'). Tests gegen die 5 GT-Impressums: - Safetykon: 3 Findings (Urheberrecht, Bilder/Lizenzen, Haftungsausschluss) - Hectronic: 3 Findings (WEEE-MEDIUM, Copyright, Haftung) - ETO/BMW/Elli: 0 Findings (sauber) - 9/9 Tests gruen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/specialist_agents/__init__.py | 3 + .../cross_placement/__init__.py | 10 + .../cross_placement/agent.py | 147 +++++++++++++++ .../cross_placement/sections.py | 177 ++++++++++++++++++ .../tests/test_cross_placement_agent.py | 122 ++++++++++++ 5 files changed, 459 insertions(+) create mode 100644 backend-compliance/compliance/services/specialist_agents/cross_placement/__init__.py create mode 100644 backend-compliance/compliance/services/specialist_agents/cross_placement/agent.py create mode 100644 backend-compliance/compliance/services/specialist_agents/cross_placement/sections.py create mode 100644 backend-compliance/tests/test_cross_placement_agent.py diff --git a/backend-compliance/compliance/services/specialist_agents/__init__.py b/backend-compliance/compliance/services/specialist_agents/__init__.py index c3713608..41478785 100644 --- a/backend-compliance/compliance/services/specialist_agents/__init__.py +++ b/backend-compliance/compliance/services/specialist_agents/__init__.py @@ -29,15 +29,18 @@ from ._base import ( ) from ._registry import REGISTRY from .cookie_policy import CookiePolicyAgent +from .cross_placement import CrossPlacementAgent from .impressum import ImpressumAgent # Self-register all agents REGISTRY.register(ImpressumAgent()) REGISTRY.register(CookiePolicyAgent()) +REGISTRY.register(CrossPlacementAgent()) __all__ = [ "AgentInput", "AgentOutput", "BaseSpecialistAgent", "EscalationLog", "EvidenceSource", "Finding", "McCoverage", "Recommendation", "Severity", "SourceType", "REGISTRY", "ImpressumAgent", "CookiePolicyAgent", + "CrossPlacementAgent", ] diff --git a/backend-compliance/compliance/services/specialist_agents/cross_placement/__init__.py b/backend-compliance/compliance/services/specialist_agents/cross_placement/__init__.py new file mode 100644 index 00000000..591f0f0a --- /dev/null +++ b/backend-compliance/compliance/services/specialist_agents/cross_placement/__init__.py @@ -0,0 +1,10 @@ +"""Cross-Placement-Agent — erkennt deplatzierten Content. + +User-Vorgabe 2026-06-09: bei Hectronic/Safetykon stehen Copyright, +Haftungsausschluss, Bilder-Lizenzen, WEEE/ElektroG im Impressum, +gehoeren aber in einen separaten 'Legal'-Footer-Reiter. +""" + +from .agent import CrossPlacementAgent + +__all__ = ["CrossPlacementAgent"] diff --git a/backend-compliance/compliance/services/specialist_agents/cross_placement/agent.py b/backend-compliance/compliance/services/specialist_agents/cross_placement/agent.py new file mode 100644 index 00000000..ac700116 --- /dev/null +++ b/backend-compliance/compliance/services/specialist_agents/cross_placement/agent.py @@ -0,0 +1,147 @@ +"""Cross-Placement-Agent — empfiehlt Footer-Reiter 'Legal' bei +deplatziertem Content. + +Methodik: + 1. Findet Sektions-Headings im Impressum (Copyright, Disclaimer, + ElektroG, …). + 2. Pro Treffer ein LOW/MEDIUM-Finding mit Best-Practice-Empfehlung + wohin der Inhalt umziehen sollte. + 3. Rollup buendelt mehrere Treffer zu einer Master-Empfehlung + ('Footer-Reiter \"Legal\" einfuehren'). +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from .._base import ( + AgentInput, + AgentOutput, + BaseSpecialistAgent, + EvidenceSource, + Finding, + McCoverage, + Severity, + SourceType, + lint_output, +) +from .._rollup import rollup +from .sections import SECTIONS, find_sections + +logger = logging.getLogger(__name__) + + +_SEVERITY_FOR_CATEGORY = { + "recommended_separate": Severity.LOW, + "product_compliance": Severity.MEDIUM, + "mandatory": Severity.INFO, +} + + +class CrossPlacementAgent(BaseSpecialistAgent): + agent_id = "cross_placement" + agent_version = "1.0" + doc_type = "impressum" # arbeitet auf Impressum-Texten + owned_mc_ids = tuple(f"CP-PLACE-{s.section_id.upper()}" + for s in SECTIONS) + + async def evaluate(self, agent_input: AgentInput) -> AgentOutput: + start = datetime.now(timezone.utc) + text = (agent_input.text or "").strip() + coverage: list[McCoverage] = [] + findings: list[Finding] = [] + + if len(text) < 100: + for sec in SECTIONS: + coverage.append(McCoverage( + mc_id=f"CP-PLACE-{sec.section_id.upper()}", + status="skipped", + reason="text too short", + )) + return self._finalize(start, findings, coverage, confidence=0.0, + notes="Text zu kurz für Placement-Check.") + + hits = find_sections(text) + hit_ids = {sec.section_id for sec, _ in hits} + for sec in SECTIONS: + mc_id = f"CP-PLACE-{sec.section_id.upper()}" + if sec.section_id not in hit_ids: + coverage.append(McCoverage( + mc_id=mc_id, status="ok", + reason="section not present", + )) + continue + evidence = next(s for sec2, s in hits + if sec2.section_id == sec.section_id) + sev = _SEVERITY_FOR_CATEGORY.get(sec.category, Severity.LOW) + findings.append(Finding( + check_id=f"CROSS-PLACE-{sec.section_id.upper()}", + agent=self.agent_id, + agent_version=self.agent_version, + field_id=sec.section_id, + severity=sev, + severity_reason="misplaced_content", + title=( + f"Sektion '{sec.label}' im Impressum — empfohlene " + f"Platzierung: {sec.recommended_location}" + ), + norm=sec.norm, + evidence=f'„{evidence}"', + action=( + f"Sektion '{sec.label}' aus dem Impressum auslagern " + f"nach {sec.recommended_location}. Best Practice: " + f"einen Footer-Reiter 'Legal' einrichten, der die " + f"folgenden Inhalte sammelt: Copyright, Bildquellen, " + f"Disclaimer, Nutzungsbedingungen. Das Impressum " + f"bleibt schlank und enthält ausschließlich die " + f"Pflichtangaben nach § 5 TMG/DDG." + ), + confidence=0.85, + sources=[EvidenceSource( + source_type=SourceType.MC, + source_id=mc_id, + detail=f"heading detected via regex", + confidence=0.85, + )], + )) + coverage.append(McCoverage( + mc_id=mc_id, status=sev.value.lower(), + reason="misplaced content detected", + )) + + confidence = 0.92 if not findings else 0.85 + return self._finalize( + start, findings, coverage, confidence=confidence, + ) + + def _finalize( + self, + start: datetime, + findings: list[Finding], + coverage: list[McCoverage], + confidence: float, + notes: str = "", + ) -> AgentOutput: + end = datetime.now(timezone.utc) + recs = rollup(findings) + out = AgentOutput( + agent=self.agent_id, + agent_version=self.agent_version, + started_at=start, + finished_at=end, + duration_ms=int((end - start).total_seconds() * 1000), + findings=findings, + recommendations=recs, + mc_coverage=coverage, + escalation_log=[], + confidence=confidence, + notes=notes, + mc_total=len(coverage), + mc_ok=sum(1 for c in coverage if c.status == "ok"), + mc_na=sum(1 for c in coverage if c.status == "na"), + mc_high=sum(1 for c in coverage if c.status == "high"), + mc_medium=sum(1 for c in coverage if c.status == "medium"), + mc_low=sum(1 for c in coverage if c.status == "low"), + ) + return lint_output(out) diff --git a/backend-compliance/compliance/services/specialist_agents/cross_placement/sections.py b/backend-compliance/compliance/services/specialist_agents/cross_placement/sections.py new file mode 100644 index 00000000..24729fcc --- /dev/null +++ b/backend-compliance/compliance/services/specialist_agents/cross_placement/sections.py @@ -0,0 +1,177 @@ +"""Klassifikation von Impressum-Sektionen — was gehört rein, was nicht. + +Pflicht-Sektionen (gehören ins Impressum): + - Anbieter (Name, Anschrift) + - Kontakt (Tel/E-Mail) + - Vertretungsberechtigte + - Handelsregister + USt-IdNr. + - § 18 MStV (Verantwortlicher) + - § 36 VSBG + Art. 14 EU-VO 524/2013 + - Aufsichtsbehoerde + Berufsangaben (regulated) + +Empfohlen-anders-platziert (Best-Practice in eigenem Footer-Bereich): + - Copyright / Urheberrecht / Marken + - Bilder / Lizenzen / Quellen + - Haftungsausschluss / Disclaimer / Disclaimer-Links + - Nutzungsbedingungen / AGB + - ElektroG / WEEE / VerpackG / BattG (Produkt-Compliance) + - Datenschutz-Erklaerung (eigene Seite) +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Pattern + + +@dataclass(frozen=True) +class SectionPattern: + """Eine Sektion die wir im Impressum erkennen können.""" + section_id: str + label: str + # Heading-Pattern: matched typischerweise H2/H3-artige Sektions- + # Überschriften innerhalb des Impressum-Texts. + heading_patterns: tuple[Pattern[str], ...] = field(default_factory=tuple) + # In welche Kategorie gehoert die Sektion? + # 'mandatory' = klassisches Impressum + # 'recommended_separate' = gehoert besser in 'Legal'-Footer + # 'product_compliance' = ElektroG/WEEE etc, kontextabhaengig + category: str = "recommended_separate" + # Best-Practice-Vorschlag wohin der Inhalt umziehen sollte + recommended_location: str = "" + norm: str = "" + + +SECTIONS: tuple[SectionPattern, ...] = ( + SectionPattern( + section_id="urheberrecht", + label="Urheberrecht", + heading_patterns=( + re.compile(r"\bUrheberrecht(?:e|sschutz|svermerk)?\b", + re.IGNORECASE), + re.compile(r"\bCopyright\b", re.IGNORECASE), + ), + category="recommended_separate", + recommended_location="Footer-Reiter 'Legal' / Seite 'Copyright'", + norm="UrhG — gehört nicht zwingend ins Impressum nach § 5 TMG", + ), + SectionPattern( + section_id="bilder_lizenzen", + label="Bilder & Lizenzen", + heading_patterns=( + re.compile(r"\bBild(?:er|nachweis|quellen)\s*[&und]+\s*Lizenz", + re.IGNORECASE), + re.compile(r"\bLizenz(?:en|hinweis|nachweis)e?\b", + re.IGNORECASE), + re.compile(r"\b(?:Bild|Foto|Image)\s*[:\-]\s*\w+", + re.IGNORECASE), + ), + category="recommended_separate", + recommended_location="Footer-Reiter 'Legal' / Seite 'Bildquellen'", + norm="Lizenzgeber-Pflicht (z.B. CC-BY) — eigene Seite üblich", + ), + SectionPattern( + section_id="haftungsausschluss", + label="Haftungsausschluss / Disclaimer", + heading_patterns=( + re.compile(r"\bHaftungs(?:ausschluss|beschr(?:ä|ae)nkung|" + r"hinweis|begrenzung)", + re.IGNORECASE), + re.compile(r"\bDisclaimer\b", re.IGNORECASE), + ), + category="recommended_separate", + recommended_location="Footer-Reiter 'Legal' / Seite 'Disclaimer'", + norm="BGB — Risikoabgrenzung gehört nicht ins § 5-TMG-Impressum", + ), + SectionPattern( + section_id="nutzungsbedingungen", + label="Nutzungsbedingungen", + heading_patterns=( + re.compile(r"\bNutzungsbedingungen\b", re.IGNORECASE), + re.compile(r"\bTerms\s+of\s+Use\b", re.IGNORECASE), + ), + category="recommended_separate", + recommended_location="Eigene Seite 'Nutzungsbedingungen' im Footer", + norm="§§ 305 ff. BGB — eigene AGB-Seite üblich", + ), + SectionPattern( + section_id="weee_elektrog", + label="ElektroG / WEEE-Reg-Nr.", + heading_patterns=( + re.compile( + r"(?:Elektro-?\s*und\s*Elektronikger(?:ä|ae)tegesetz|" + r"\bElektroG\b|\bWEEE\b|" + r"WEEE[\-\s]?Reg)", + re.IGNORECASE, + ), + ), + category="product_compliance", + recommended_location=( + "Eigene Seite 'Produktinformationen' oder Hinweis auf " + "Produkt-/Recycling-Seite" + ), + norm="§ 6 Abs. 1 ElektroG — Pflichtkennzeichnung am Produkt, " + "nicht zwingend im Impressum", + ), + SectionPattern( + section_id="verpackg", + label="VerpackG (Verpackungsregister)", + heading_patterns=( + re.compile( + r"(?:Verpackungsgesetz|\bVerpackG\b|" + r"LUCID[\-\s]?Reg|Verpackungs-?Reg)", + re.IGNORECASE, + ), + ), + category="product_compliance", + recommended_location="Eigene Seite 'Produktinformationen'", + norm="§ 7 VerpackG — Registrierung beim ZSVR", + ), + SectionPattern( + section_id="battg", + label="BattG (Batteriegesetz)", + heading_patterns=( + re.compile( + r"(?:Batteriegesetz|\bBattG\b|" + r"GRS-?Registrierung)", + re.IGNORECASE, + ), + ), + category="product_compliance", + recommended_location="Eigene Seite 'Batterieinformationen'", + norm="§ 4 BattG — Rücknahmepflicht", + ), + SectionPattern( + section_id="aenderungsvorbehalt", + label="Änderungsvorbehalt", + heading_patterns=( + re.compile(r"(?:Aenderungs|Änderungs)vorbehalt", + re.IGNORECASE), + re.compile(r"\bbeh(?:ä|ae)lt\s+sich\s+vor", re.IGNORECASE), + ), + category="recommended_separate", + recommended_location="In den Nutzungsbedingungen / Footer 'Legal'", + norm="BGB-Anrede — gehört in AGB nicht ins Impressum", + ), +) + + +def find_sections(text: str) -> list[tuple[SectionPattern, str]]: + """Sucht im Text nach Sektions-Headings. + + Returns: + Liste (SectionPattern, evidence_snippet) — pro gefundener Sektion. + """ + if not text: + return [] + hits: list[tuple[SectionPattern, str]] = [] + for sec in SECTIONS: + for pat in sec.heading_patterns: + m = pat.search(text) + if m: + snippet = text[max(0, m.start() - 20):m.end() + 80] + snippet = " ".join(snippet.split())[:120] + hits.append((sec, snippet)) + break # eines reicht pro Sektion + return hits diff --git a/backend-compliance/tests/test_cross_placement_agent.py b/backend-compliance/tests/test_cross_placement_agent.py new file mode 100644 index 00000000..8d79cdcc --- /dev/null +++ b/backend-compliance/tests/test_cross_placement_agent.py @@ -0,0 +1,122 @@ +"""Tests für den Cross-Placement-Agent (deplatzierter Content).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from compliance.services.specialist_agents import ( + REGISTRY, + AgentInput, + CrossPlacementAgent, + Severity, +) +from tests.fixtures.impressum_groundtruth import ( + ETO, HECTRONIC, SAFETYKON, BMW, +) + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +def test_agent_is_registered(): + agent = REGISTRY.get("cross_placement") + assert agent is not None + assert agent.doc_type == "impressum" + + +def test_short_text_skipped(): + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput(doc_type="impressum", text="x"))) + assert all(c.status == "skipped" for c in out.mc_coverage) + + +def test_eto_finds_nutzungsbedingungen_and_haftung(): + """ETO-Impressum hat Nutzungsbedingungen + Haftungsbeschränkung — + Cross-Placement-Agent muss das melden.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=ETO.text, + ))) + field_ids = {f.field_id for f in out.findings} + # ETO-Impressum hat keine Sektionen wie Copyright/Disclaimer → + # nur die Pflichtangaben. Test prüft dass ETO sauber ist. + # User-GT: placement_concerns = () → 0 Findings erwartet + assert not out.findings, ( + f"ETO sollte keine Placement-Findings haben, hat aber: " + f"{field_ids}" + ) + + +def test_safetykon_finds_urheberrecht_and_haftung(): + """Safetykon hat Urheberrecht + Bilder/Lizenzen + Haftungsausschluss + im Impressum-Block — alle drei sollen gemeldet werden.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=SAFETYKON.text, + ))) + field_ids = {f.field_id for f in out.findings} + assert "urheberrecht" in field_ids + assert "bilder_lizenzen" in field_ids + assert "haftungsausschluss" in field_ids + + +def test_hectronic_finds_weee_copyright_haftung(): + """Hectronic hat WEEE-Reg + Copyright + Haftungsausschluss im + Impressum-Block.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=HECTRONIC.text, + ))) + field_ids = {f.field_id for f in out.findings} + assert "weee_elektrog" in field_ids + assert "urheberrecht" in field_ids # 'Copyright' heading + assert "haftungsausschluss" in field_ids + + +def test_bmw_is_clean(): + """BMW-Impressum ist sauber (nur Pflichtangaben) — 0 Findings.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=BMW.text, + ))) + assert not out.findings, ( + f"BMW sollte sauber sein, hat aber: " + f"{[f.field_id for f in out.findings]}" + ) + + +def test_recommendation_mentions_legal_footer(): + """Best-Practice-Empfehlung muss 'Legal'-Footer-Tab vorschlagen.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=HECTRONIC.text, + ))) + assert out.findings + sample = out.findings[0] + assert "Legal" in sample.action + assert "Footer" in sample.action + + +def test_weee_is_product_compliance_medium(): + """ElektroG/WEEE ist eine echte Pflicht aber Ort umstritten → + MEDIUM statt LOW.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=HECTRONIC.text, + ))) + weee = next(f for f in out.findings if f.field_id == "weee_elektrog") + assert weee.severity == Severity.MEDIUM.value + + +def test_copyright_is_low(): + """Copyright/Urheberrecht ist BPa nicht zwingend Impressum-Pflicht → + LOW.""" + agent = CrossPlacementAgent() + out = _run(agent.evaluate(AgentInput( + doc_type="impressum", text=SAFETYKON.text, + ))) + cp = next(f for f in out.findings if f.field_id == "urheberrecht") + assert cp.severity == Severity.LOW.value