feat(agent): Impressum-Tab auf Haupt-Engine + Profil/§36-Fixes

Ergebnis-Tab rendert jetzt result.results (Haupt-Doc-Check) statt des
abweichenden v3-Agenten — BMW korrekt statt False Positives:
- DocResultView: ein Dokument als Pflichtangaben-Tabelle (Label + gefundener
  Text + 3-Tier-Status), KEINE MC-IDs. ComplianceResultTabs speist Tabs aus
  result.results; ChecklistView-Bausteine exportiert + wiederverwendet.
- profile_extractor: Firmenname/Rechtsform = fruehester Treffer + ausge-
  schriebene Formen (Aktiengesellschaft) -> BMW AG statt "juris GmbH".
- 36 VSBG (MC-010): reines b2c -> POSSIBLY_APPLICABLE (Pruef-Hinweis) statt
  MEDIUM-FAIL; hart nur bei ecommerce. possibly_hint pro MC.
- McCoverage traegt label + found (Snippet); mc_possibly-Aggregat.
- AgentFindingCard/Methodik: interne check_id/mc_id nicht mehr angezeigt.

Tests: test_four_status (16) + Frontend-Vitest gruen; CI-Suite 206, v3/GT
unveraendert. Nur eigene Dateien (geteilter Tree).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-10 23:44:01 +02:00
parent a7dc12f30f
commit 3f23a64d5f
15 changed files with 450 additions and 187 deletions
@@ -107,6 +107,11 @@ class McCoverage(BaseModel):
mc_id: str
status: str
reason: str = ""
# Menschlicher Feldname (für die Pflichtangaben-Tabelle im Frontend —
# NICHT die mc_id zeigen, sonst Reverse-Engineering der MC-Bibliothek).
label: str = ""
# Der tatsächlich gefundene Text/Wert (Snippet) bei status=ok.
found: str = ""
class EscalationLog(BaseModel):
@@ -78,6 +78,25 @@ def _build_measure(label: str, norm: str) -> str:
return msg
def _line_of(text: str, start_pos: int, end_pos: int) -> str:
"""Die Zeile um einen Regex-Treffer — als 'gefundener Wert' für die
Pflichtangaben-Tabelle. Gekappt + bereinigt."""
start = text.rfind("\n", 0, start_pos) + 1
end = text.find("\n", end_pos)
if end == -1:
end = len(text)
return " ".join(text[start:end].split())[:160]
def _coverage(mc, status: str, reason: str, found: str = "") -> McCoverage:
"""McCoverage mit menschlichem Label (mc.label) — das Frontend zeigt
NIE die mc_id (Reverse-Engineering-Schutz)."""
return McCoverage(
mc_id=mc.mc_id, status=status, reason=reason,
label=mc.label, found=found,
)
class ImpressumAgent(BaseSpecialistAgent):
agent_id = "impressum"
agent_version = "3.0"
@@ -103,10 +122,7 @@ class ImpressumAgent(BaseSpecialistAgent):
if len(text) < 100:
# Doc zu kurz — alle eigenen Pattern-IDs als skipped
for mc in MCS:
coverage.append(McCoverage(
mc_id=mc.mc_id, status="skipped",
reason="text too short",
))
coverage.append(_coverage(mc, "skipped", "text too short"))
return self._finalize(
start, findings, esc_logs, coverage,
confidence=0.0,
@@ -129,27 +145,35 @@ class ImpressumAgent(BaseSpecialistAgent):
for mc in MCS:
disp = scope_disposition(mc, scope, is_auto)
if disp == "na":
coverage.append(McCoverage(
mc_id=mc.mc_id, status="na",
reason="nicht anwendbar (Rechtsform/Branche)",
))
coverage.append(_coverage(
mc, "na", "nicht anwendbar (Rechtsform/Branche)"))
continue
if any(p.search(text) for p in mc.patterns):
coverage.append(McCoverage(
mc_id=mc.mc_id, status="ok", reason="Pattern-Treffer",
matched = None
for p in mc.patterns:
m = p.search(text)
if m:
matched = m
break
if matched is not None:
coverage.append(_coverage(
mc, "ok", "Pattern-Treffer",
found=_line_of(text, matched.start(), matched.end()),
))
continue
if mc.optional:
# fehlt + optional → KEIN Finding (z.B. USt-IdNr;
# Kleinunternehmer §19 haben legitim keine).
coverage.append(McCoverage(
mc_id=mc.mc_id, status="na",
reason="optional — nicht angegeben",
))
coverage.append(_coverage(
mc, "na", "optional — nicht angegeben"))
continue
if disp == "possible":
# Graubereich (z.B. Corporate-Blog → §18 MStV evtl.)
# POSSIBLY_APPLICABLE: Pruef-Hinweis (LOW), kein Verstoss.
# Graubereich (z.B. Corporate-Blog → §18, OEM-Markenseite
# §36 VSBG) → POSSIBLY_APPLICABLE: Pruef-Hinweis (LOW),
# kein Verstoss. Hinweistext kommt MC-spezifisch.
hint = mc.possibly_hint or (
f"Diese Angabe ist nur situativ Pflicht ({mc.norm}). "
"Bitte prüfen, ob sie auf Ihre Seite zutrifft."
)
findings.append(Finding(
check_id=f"IMP-{mc.field_id}",
agent=self.agent_id,
@@ -161,12 +185,7 @@ class ImpressumAgent(BaseSpecialistAgent):
title=f"{mc.label}: ggf. relevant — manuell prüfen",
norm=mc.norm,
evidence="",
action=(
"Bei journalistisch-redaktionellen Inhalten "
"(Nachrichten/Magazin) ist ein Verantwortlicher nach "
"§ 18 MStV anzugeben. Bei reinem Corporate-Blog meist "
"nicht erforderlich — bitte prüfen."
),
action=hint,
confidence=0.5,
sources=[EvidenceSource(
source_type=SourceType.REGEX,
@@ -175,10 +194,8 @@ class ImpressumAgent(BaseSpecialistAgent):
confidence=0.5,
)],
))
coverage.append(McCoverage(
mc_id=mc.mc_id, status="possibly_applicable",
reason="Graubereich — manuelle Prüfung",
))
coverage.append(_coverage(
mc, "possibly_applicable", "Graubereich — manuelle Prüfung"))
continue
if mc.legal_form_dependent and not form_known:
# Rechtsform unbestimmt → kein hartes FAIL, sondern
@@ -207,10 +224,8 @@ class ImpressumAgent(BaseSpecialistAgent):
confidence=0.4,
)],
))
coverage.append(McCoverage(
mc_id=mc.mc_id, status="insufficient_evidence",
reason="Rechtsform unbestimmt",
))
coverage.append(_coverage(
mc, "insufficient_evidence", "Rechtsform unbestimmt"))
continue
sev = _SEV_TO_ENUM.get(mc.severity_if_missing, Severity.MEDIUM)
findings.append(Finding(
@@ -233,10 +248,8 @@ class ImpressumAgent(BaseSpecialistAgent):
confidence=0.9,
)],
))
coverage.append(McCoverage(
mc_id=mc.mc_id, status=sev.value.lower(),
reason="kein Pattern-Treffer",
))
coverage.append(_coverage(
mc, sev.value.lower(), "kein Pattern-Treffer"))
n_fail = sum(1 for f in findings
if f.status == CheckStatus.FAIL.value)
n_unklar = sum(1 for f in findings
@@ -40,6 +40,9 @@ class MC:
# ist die MC NICHT hart anwendbar, sondern POSSIBLY_APPLICABLE — Pruef-
# Hinweis (severity LOW) statt FAIL. Z.B. Corporate-Blog (§18 MStV evtl.).
possibly_applies_scope: tuple[str, ...] = field(default_factory=tuple)
# MC-spezifischer Pruef-Hinweis fuer den POSSIBLY_APPLICABLE-Fall
# (warum Graubereich + was der Nutzer pruefen soll).
possibly_hint: str = ""
MCS: tuple[MC, ...] = (
@@ -182,6 +185,11 @@ MCS: tuple[MC, ...] = (
severity_if_missing="MEDIUM",
requires_scope=("editorial",),
possibly_applies_scope=("editorial_possible",),
possibly_hint=(
"Bei journalistisch-redaktionellen Inhalten (Nachrichten/Magazin) "
"ist ein Verantwortlicher nach § 18 MStV anzugeben. Bei reinem "
"Corporate-Blog meist nicht erforderlich — bitte prüfen."
),
patterns=(re.compile(
r"(?:Verantwortlich(?:er|e)?\s+(?:f(?:ue|ü)r|i\.S\.d\.|"
r"nach|gem(?:ae|ä)ß)\s+§\s*18|"
@@ -194,9 +202,17 @@ MCS: tuple[MC, ...] = (
mc_id="IMP-MC-010",
field_id="verbraucher_streitbeilegung",
label="Verbraucher-Streitbeilegung-Hinweis",
norm="§ 36 VSBG (B2C-Anbieter Pflicht)",
norm="§ 36 VSBG (Verbraucherverträge über die Website)",
severity_if_missing="MEDIUM",
requires_scope=("ecommerce", "b2c"),
# Hart nur bei echtem Online-Verkauf; reine B2C-Orientierung (z.B.
# OEM-Markenseite, Verkauf über Händler) = Graubereich → Prüf-Hinweis.
requires_scope=("ecommerce",),
possibly_applies_scope=("b2c",),
possibly_hint=(
"§ 36 VSBG gilt, wenn auf dieser Seite Verbraucherverträge "
"geschlossen werden. Bei reiner Marken-/Info-Seite (Verkauf über "
"Händler/Vertragspartner) meist nicht erforderlich — bitte prüfen."
),
patterns=(re.compile(
r"(?:Verbraucherschlichtungs|VSBG|"
r"Streitbeilegung|"