feat: Backlog 1-5 — soft-hints, chatbot-discovery, API-payload, LLM-Agent
5 Backlog-Items aus dem Multi-Site-Briefing in einem Sprint:
1. B13 B2C-Soft-Hints — Versicherungs/Tarif/Buchungs-Marker
_B2C_WEAK erweitert um "Reiseversicherung", "Tarifrechner",
"Online-Antrag", "Flug buchen", "Stromtarif" etc.
Fängt Allianz-Reise-Chatbot (vorher False-Negative).
2. Chatbot-Policy-Discovery (chatbot_policy_discovery.py)
Probt 14 Standard-Slugs (privacypolicychatbot, chatbot-datenschutz,
ai-policy, ki-datenschutz, ...) × 5 Lang-Prefixe auf jeder
submitted Origin. Successful >300-Wort-Findings werden in
doc_texts['dse'] gemerged. Audit-Trail über
doc_entries[dse].chatbot_policy_sources.
Hebt Westfield-iAdvize-Lücke.
3. API-Response-Payload erweitert
phase_f_persist.response um extra_findings, audit_walk und
html_blocks erweitert. B-Wiring-Output (B1, B3-B18) ist nicht
mehr nur im Mail-HTML versteckt — externe Aufrufer sehen jeden
Finding. Schema additiv, legacy clients ignorieren neue Felder.
4. Plausibility-LLM Empty-Response-Fix
Resilienz-Strategie A→B→C→D:
A) format='json' (strict, default)
B) format='' (loose, _try_extract_json mit ```json-fence + prose-
wrap-Unterstützung)
C) Split-Batch-Recursion (vorhanden)
D) Give up, leeres dict (callers behandeln als skipped)
Plus _post_llm() als isolierter LLM-Call-Helper, catched
Network-Errors.
5. Specialist-Agents Phase 2 LLM (MVP) — Impressum-Agent
impressum_agent_llm.py: qwen3:30b-a3b mit § 5 TMG System-Prompt,
business_scope-hints aus profile_dict. Output identisches Schema
wie pattern-agent für ein Merge ohne API-Bruch.
_b18_wiring.py orchestriert beide Agents + deduplet nach
field_id, rendert lila V2-Block mit KB/LLM-Tags pro Finding.
Pattern-first im Dedup (deterministisch + stable).
Tests: 107/107 grün (7 Test-Suites + chatbot-discovery + b18).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""Impressum-Specialist-Agent Phase 2 — LLM-gestützt.
|
||||
|
||||
Komplementiert den Pattern-Match-Agent (impressum_agent.py) durch
|
||||
eine LLM-Pass. Beide Output-Formate sind identisch, sodass das B-Wiring
|
||||
beide kombinieren / dedupen kann.
|
||||
|
||||
LLM-Setup:
|
||||
- Modell: qwen3:30b-a3b (Standard Ollama, siehe Plausibility-Check)
|
||||
- System-Prompt: KB der § 5 TMG Pflichtangaben
|
||||
- User-Prompt: Impressum-Text + business_scope-Hinweis
|
||||
- Output: JSON-Liste mit {field_id, severity, hint, evidence}
|
||||
|
||||
Phase-2-Ziel: schwer-mit-Regex-erfassbare Lücken finden, z.B.
|
||||
- "Geschäftsführer" wird genannt aber ohne Vor- oder Nachname
|
||||
- Aufsichtsbehörde-Pflicht erkannt, aber für falsche Branche
|
||||
- Vertretungsberechtigte einer GmbH bei mehreren Personen unvollständig
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_URL = os.environ.get(
|
||||
"OLLAMA_URL", "http://bp-core-ollama:11434",
|
||||
)
|
||||
MODEL = os.environ.get("IMPRESSUM_AGENT_MODEL", "qwen3:30b-a3b")
|
||||
TIMEOUT = float(os.environ.get("IMPRESSUM_AGENT_TIMEOUT", "60"))
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """Du bist ein deutscher Datenschutz-Anwalt mit Fokus
|
||||
§ 5 TMG / DDG (Anbieterkennzeichnung). Deine Aufgabe: einen Impressum-
|
||||
Text auf Vollständigkeit der Pflichtangaben prüfen und Lücken /
|
||||
Mängel strukturiert auflisten.
|
||||
|
||||
Pflichtangaben nach § 5 TMG (Standard):
|
||||
- Anbieter-Name + Anschrift (juristische Person: Firma + Sitz)
|
||||
- Vertretungsberechtigte (bei juristischen Personen: ALLE Geschäftsführer
|
||||
mit Vor- und Nachname)
|
||||
- E-Mail UND Telefon (Schnelle elektronische Kontaktaufnahme + UNMITTELBAR)
|
||||
- Handelsregister-Eintrag (HRB/HRA + Registergericht)
|
||||
- USt-IdNr. (falls vorhanden — DE\\d{9})
|
||||
- Bei B2C/Onlineshop: Verbraucherschlichtung + OS-Plattform
|
||||
- Bei reglementiertem Beruf: Berufsbezeichnung + Kammer
|
||||
- Bei genehmigungspflichtigen Tätigkeiten: Aufsichtsbehörde
|
||||
|
||||
Ausgabe: NUR gültiges JSON mit Feld "findings", jedes Element:
|
||||
{
|
||||
"field_id": "kurzer-id",
|
||||
"severity": "HIGH"|"MEDIUM"|"LOW",
|
||||
"title": "kurze Lücken-Beschreibung",
|
||||
"evidence": "wörtliches Zitat aus dem Impressum, das das Problem belegt",
|
||||
"action": "konkrete Empfehlung"
|
||||
}
|
||||
|
||||
Keine Erklärung außerhalb JSON. Keine Prosa. Wenn alles vollständig:
|
||||
gib {"findings": []} zurück.
|
||||
"""
|
||||
|
||||
|
||||
def _user_prompt(impressum_text: str,
|
||||
business_scope: set[str] | None) -> str:
|
||||
scope_hint = ""
|
||||
if business_scope:
|
||||
scope_hint = (
|
||||
f"BUSINESS-SCOPE-HINTS: "
|
||||
f"{', '.join(sorted(business_scope))}\n\n"
|
||||
)
|
||||
return (
|
||||
f"{scope_hint}"
|
||||
f"IMPRESSUM-TEXT:\n"
|
||||
f"{impressum_text[:4000]}\n\n"
|
||||
"Liste Lücken nach § 5 TMG. Nur JSON."
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(content: str) -> list[dict]:
|
||||
"""Robust JSON extraction (handles ```json fences, prose-wrap)."""
|
||||
if not content:
|
||||
return []
|
||||
s = content.strip()
|
||||
if s.startswith("```"):
|
||||
s = s.strip("`")
|
||||
if s.lower().startswith("json"):
|
||||
s = s[4:]
|
||||
s = s.strip()
|
||||
first = s.find("{")
|
||||
last = s.rfind("}")
|
||||
if first >= 0 and last > first:
|
||||
s = s[first:last + 1]
|
||||
try:
|
||||
data = json.loads(s)
|
||||
except Exception:
|
||||
# Try array directly
|
||||
first = content.find("[")
|
||||
last = content.rfind("]")
|
||||
if first >= 0 and last > first:
|
||||
try:
|
||||
arr = json.loads(content[first:last + 1])
|
||||
return arr if isinstance(arr, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
findings = data.get("findings") if isinstance(data, dict) else data
|
||||
return findings if isinstance(findings, list) else []
|
||||
|
||||
|
||||
async def evaluate_llm(
|
||||
impressum_text: str,
|
||||
business_scope: set[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""LLM-gestützte Impressum-Analyse. Returns finding dicts in the
|
||||
same shape as impressum_agent.evaluate() so callers can merge."""
|
||||
if not impressum_text or len(impressum_text.strip()) < 100:
|
||||
return []
|
||||
body = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": _user_prompt(
|
||||
impressum_text, business_scope,
|
||||
)},
|
||||
],
|
||||
"format": "json",
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.0, "seed": 42, "num_predict": 1200},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as c:
|
||||
r = await c.post(f"{OLLAMA_URL}/api/chat", json=body)
|
||||
r.raise_for_status()
|
||||
content = (r.json().get("message") or {}).get("content", "") or ""
|
||||
except Exception as e:
|
||||
logger.warning("impressum_agent_llm call failed: %s", e)
|
||||
return []
|
||||
|
||||
raw_findings = _parse_response(content)
|
||||
out: list[dict] = []
|
||||
for f in raw_findings:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
fid = re.sub(r"[^\w\-]", "_",
|
||||
str(f.get("field_id") or "unknown"))[:40]
|
||||
sev = (f.get("severity") or "MEDIUM").upper()
|
||||
if sev not in ("HIGH", "MEDIUM", "LOW", "INFO"):
|
||||
sev = "MEDIUM"
|
||||
out.append({
|
||||
"check_id": f"IMPRESSUM-AGENT-LLM-{fid.upper()}",
|
||||
"agent": "impressum_agent_v2_llm",
|
||||
"field_id": fid,
|
||||
"severity": sev,
|
||||
"severity_reason": "missing",
|
||||
"title": str(f.get("title") or "")[:200],
|
||||
"norm": "§ 5 TMG / DDG (LLM-Analyse)",
|
||||
"evidence": str(f.get("evidence") or "")[:300],
|
||||
"action": str(f.get("action") or "")[:400],
|
||||
})
|
||||
if out:
|
||||
logger.info("impressum_agent_llm: %d finding(s)", len(out))
|
||||
return out
|
||||
Reference in New Issue
Block a user