Files
breakpilot-compliance/backend-compliance/compliance/services/impressum_multi_entity_check.py
T
Benjamin Admin a2cae94526 fix(b9)+test: real-world false-positives + multi-site GT-bench
Real-World-Smoke gegen Westfield Hamburg (englische DSE) deckte
B9-Bug auf: Pattern matched "If mfi Immobilien Marketing GmbH",
"Discover our Se", "Centre Se" usw. als angebliche Entitäten —
englische Connector-Worte + abgeschnittene "Services"-Strings.

B9 Fix:
  - _name_is_blocked() strenger: min 2 Worte, mind. einer ≥4 Chars
    UND capitalized (vor Legal-Form-Suffix). Filtert "Se", "ag",
    "If ...", "Centre Se" zuverlässig.
  - _clean_entity_name() strippt jetzt führende Lowercase-
    Connector-Worte (kontextuelle Verben wie "by", "If",
    "according to").
  - _dedup_substring() collapses
    "mfi Immobilien Marketing GmbH" + "Marketing GmbH" zum längeren.
  - Anwendung sowohl im HRB-Pfad als auch im Fallback-Pfad.

Multi-Site-Bench (2 neue GTs, 2 Engine-Runs):
  - zeroclaw/docs/ground-truth/westfield_hamburg_2026-06-07.json:
    iAdvize-Chatbot bekannt, Unibail-Management-Verantwortlicher.
  - zeroclaw/docs/ground-truth/allianz_reise_chatbot_2026-06-07.json:
    Twilio-Infrastruktur (US-Transfer), lit. f + 2-Mo-Retention.
  - zeroclaw/docs/audits/2026-06-07-multi-site-walk-results.md:
    Sprint-Briefing mit Detektor × Site Matrix, Audit-Walk-DSMS-
    CIDs, identifizierte Real-World-Bugs + Backlog.

Audit-Walk-Endstand (B17 Stufen 1-3):
  - Westfield: 400 KB Video, CID Qm…WJYfYDt…BXgwt
  - Allianz:   1 MB Video,   CID Qm…XFuiC4z…9mSMM
  Beide DSMS-persistiert, Reviewer kann jederzeit verifizieren.

Tests: 21/21 grün (test_impressum/test_elli_gt_coverage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 17:51:17 +02:00

262 lines
9.7 KiB
Python

"""B9 — Multi-Entity-Impressum-Check.
Findings, wenn ein Impressum mehrere Entitäten (mehrere GmbH/AG/UG)
nennt, aber Pflichtangaben nur bei einer davon vollständig sind.
Konkreter Elli-Pattern (GT IMPRESSUM-001):
- Entity 1: "Elli Mobility GmbH ... USt-IdNr DE814424009 ..."
- Entity 2: "VW Group Charging GmbH ... [keine USt-IdNr] ..."
→ USt-IdNr fehlt bei Entity 2.
Heuristik:
1. Entitäten erkennen: jede Match auf "<Name> (GmbH|AG|UG|KG|SE)" als
Entity-Boundary; Text-Slice von dort bis zur nächsten Entity.
2. Pro Entity prüfen: USt-IdNr, Handelsregister, Vertretungsberechtigte.
3. Wenn Entity N ein Feld nennt, das Entity M nicht hat → MEDIUM.
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_ENTITY_PAT = re.compile(
r"([A-ZÄÖÜ][\w\-\& ]{1,50}?\s+(?:GmbH|AG|UG|KG|SE|"
r"e\.V\.|GbR|OHG|Limited|Ltd|LLC))",
re.IGNORECASE,
)
_NAME_NOISE_PAT = re.compile(
r"^(?:Impressum|Anbieter|Anbieterkennzeichnung|Diensteanbieter|"
r"Verantwortlich(?:er)?|Kontakt|Adresse|@\S+|.+@.+)\s*[:|\-]?\s*",
re.IGNORECASE,
)
# HRB-/HRA-Eintrag pro Entity. Dies ist der stärkste Anker: jede
# juristische Person muss in Deutschland einen eindeutigen
# HRB/HRA-Eintrag haben. Mehrere HRB-Vorkommen = mehrere Entities.
_HRB_PAT = re.compile(
r"HR[BA]\s*(?:Nr\.?|Nummer)?\s*\d+(?:\s*[A-Z])?",
re.IGNORECASE,
)
# Worte, die NIE ein gültiger Firmenname sind. Filtern False-Positives
# wie "Programmierung der Webseite Elli Mobility GmbH" oder
# "Umsatzsteueridentifikationsnummer der Elli Mobility GmbH".
_NAME_BLOCKLIST = (
"programmierung", "webseite", "umsatzsteueridentifik",
"schlichtungsstelle", "auftragsverarbeitung", "haftung",
"verantwortlich", "diensteanbieter", "geschäftsführer",
"geschaeftsfuehrer", "vorstand", "gesellschaftsregister",
"registergericht", "registriert",
)
_USTID_PAT = re.compile(
# Form A: Abkürzung mit Separator (USt-IdNr.: DE…)
r"(?:"
r"\b(?:USt-?Id(?:Nr)?\.?|VAT(?:-?Id)?)\s*[:.\s]\s*"
r"(DE\d{8,10}|[A-Z]{2}\d{6,12})"
r"|"
# Form B: Vollform mit Bridge (Umsatzsteueridentifikationsnummer
# der Elli Mobility GmbH ist DE…). Max 80 Zeichen Bridge,
# \n erlaubt (Cap schützt vor Cross-Paragraph-Drift).
r"\bUmsatzsteuer[\s-]?Id(?:entifikationsnummer)?\b[\s\S]{0,80}?"
r"(DE\d{8,10}|[A-Z]{2}\d{6,12})"
r")",
re.IGNORECASE,
)
_HR_PAT = re.compile(r"\b(?:HR[BA]|Handelsregister|Registergericht)"
r"\s*[:.\s]*([\w\s\d\-/]{4,80})", re.IGNORECASE)
_GF_PAT = re.compile(r"(?:Geschäftsführer|Vertretungsberechtigt|"
r"vertreten\s+durch)\s*[:.\s]+", re.IGNORECASE)
_LEADING_NOISE_RE = re.compile(
r"^(?:eco|com|de|net|" # email/domain TLD-Artefakte
r"Die|Der|Das|" # deutsche Artikel
r"www\.[\w\.-]+|" # URL-Reste
r"@[\w\.-]+)\s+",
re.IGNORECASE,
)
def _clean_entity_name(raw: str) -> str:
"""Strip leading header noise, leading artifacts + collapse whitespace."""
name = raw.strip()
# If the match spans multiple lines, keep only the last line.
if "\n" in name:
name = name.rsplit("\n", 1)[-1].strip()
name = _NAME_NOISE_PAT.sub("", name).strip()
# Strip leading email-TLD-artifacts ("eco "), German articles
# ("Die ", "Der "), URL-fragments etc. — iterate until stable.
for _ in range(3):
new = _LEADING_NOISE_RE.sub("", name)
if new == name:
break
name = new
# Strip leading lowercase prose words (English connectors leaking
# into the match: "If ...", "by ...", "according to ...").
# Drop tokens until we hit the first Capitalized one.
tokens = name.split()
while tokens and not tokens[0][:1].isupper():
tokens = tokens[1:]
name = " ".join(tokens)
return re.sub(r"\s+", " ", name).strip()
def _dedup_substring(slices: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""Collapse entities whose names are substrings of each other.
'mfi Immobilien Marketing GmbH' and 'Marketing GmbH' both refer to
the same legal person — keep only the longest unique name.
"""
sorted_by_len = sorted(slices, key=lambda x: -len(x[0]))
kept: list[tuple[str, str]] = []
kept_names_lc: list[str] = []
for name, slc in sorted_by_len:
nl = name.lower()
if any(nl in k or k in nl for k in kept_names_lc):
continue
kept.append((name, slc))
kept_names_lc.append(nl)
return kept
def _name_is_blocked(name: str) -> bool:
nl = name.lower()
if any(b in nl for b in _NAME_BLOCKLIST):
return True
# Minimum-name-quality: must have ≥ 2 words, at least one ≥ 4 chars
# before the legal-form suffix. Filters out "Se", "As a se" frags.
parts = name.strip().split()
if len(parts) < 2:
return True
# Strip legal-form from the end if present
legal_suffixes = {
"gmbh", "ag", "ug", "kg", "se", "e.v.", "gbr", "ohg",
"limited", "ltd", "llc",
}
if parts[-1].lower() in legal_suffixes:
non_suffix = parts[:-1]
else:
non_suffix = parts
if not non_suffix or len(non_suffix) < 1:
return True
# At least one company-name token ≥ 4 chars and capitalized
if not any(p[0].isupper() and len(p) >= 4 for p in non_suffix):
return True
return False
def _slice_entities(text: str) -> list[tuple[str, str]]:
"""Return [(entity_name, text_slice)] for each detected entity.
Anker-Strategie: jeder eigenständige HRB/HRA-Eintrag markiert eine
Entity. Pro HRB-Block laufen wir RÜCKWÄRTS bis zum vorherigen
Block-Ende und nehmen den letzten Legal-Form-Match (GmbH/AG/...)
in diesem Fenster als Firmennamen.
Falls keine HRBs vorhanden sind (z.B. e.V. ohne HR-Pflicht),
fallen wir auf den alten Legal-Form-Anker zurück.
"""
hrb_matches = list(_HRB_PAT.finditer(text))
if len(hrb_matches) >= 2:
slices: list[tuple[str, str]] = []
seen_names: set[str] = set()
for i, h in enumerate(hrb_matches):
# Window: from end of previous HRB (or start) to this HRB.
win_start = hrb_matches[i - 1].end() if i > 0 else 0
win_end = h.start()
window = text[win_start:win_end]
# Find the LAST legal-form-match in the window — that's
# the entity-name closest to this HRB-marker.
legal_matches = list(_ENTITY_PAT.finditer(window))
if not legal_matches:
continue
name_raw = legal_matches[-1].group(1)
name = _clean_entity_name(name_raw)
if not name or _name_is_blocked(name):
continue
if name in seen_names:
continue
seen_names.add(name)
# Block-Slice: from the matched name to the END of this
# HRB-block (next HRB-start or EOF).
slice_start = win_start + legal_matches[-1].start()
slice_end = (hrb_matches[i + 1].start()
if i + 1 < len(hrb_matches) else len(text))
slices.append((name, text[slice_start:slice_end]))
slices = _dedup_substring(slices)
if len(slices) >= 2:
return slices
# Fallback (no HRBs / nur einer / parsing scheiterte):
# alter Legal-Form-Anker mit Blocklist-Filter + dedup.
matches = list(_ENTITY_PAT.finditer(text))
if len(matches) < 2:
return []
slices = []
seen_names = set()
for i, m in enumerate(matches):
name = _clean_entity_name(m.group(1))
if not name or _name_is_blocked(name) or name in seen_names:
continue
seen_names.add(name)
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
slices.append((name, text[start:end]))
slices = _dedup_substring(slices)
return slices if len(slices) >= 2 else []
def check_multi_entity_impressum(state: dict) -> list[dict]:
doc_texts = state.get("doc_texts") or {}
imp = doc_texts.get("impressum") or ""
if not imp:
return []
slices = _slice_entities(imp)
if not slices:
return []
# Compute features per entity
features = []
for name, slc in slices:
features.append({
"name": name,
"ust_id": bool(_USTID_PAT.search(slc)),
"hr": bool(_HR_PAT.search(slc)),
"gf": bool(_GF_PAT.search(slc)),
})
# If ALL share the same flags → no inconsistency
findings: list[dict] = []
for field, label in (
("ust_id", "USt-IdNr."),
("hr", "Handelsregister-Eintrag"),
("gf", "Vertretungsberechtigte"),
):
present = [f for f in features if f[field]]
missing = [f for f in features if not f[field]]
if present and missing and len(present) >= 1:
findings.append({
"check_id": f"IMPRESSUM-MULTI-{field.upper()}",
"severity": "MEDIUM",
"severity_reason": "incomplete",
"title": (
f"{label} fehlt bei "
f"{len(missing)} von {len(features)} Entitäten"
),
"norm": "§ 5 Abs. 1 TMG (Pflichtangabe pro Diensteanbieter)",
"entities_present": [f["name"] for f in present],
"entities_missing": [f["name"] for f in missing],
"action": (
f"{label} im Impressum für "
f"{', '.join(f['name'] for f in missing)} ergänzen. "
"Pflichtangabe ist pro Diensteanbieter zu erfüllen, "
"nicht 'eine reicht für alle'."
),
})
if findings:
logger.info("B9 multi-entity impressum: %d findings", len(findings))
return findings