""" P73 — MC-Solution-Generator. Generiert pro Fail-MC eine konkrete Einfuege-Empfehlung mit Anchor: "Bitte ergaenzen Sie nach Abschnitt 'Kontaktdaten DSB' folgenden Absatz: ...". LLM-Cascade Qwen (lokal) -> OVH 120B. Cache: in-process LRU per (mc_id, doc_md5) damit Re-Runs derselben Site denselben Vorschlag liefern. Volle DB-Cache kommt spaeter (P31). Integration: wird im build_critical_findings_html / mc-detail-rendering unter jedem HIGH-Fail als eingeklappbarer Block angezeigt. """ from __future__ import annotations import hashlib import json import logging import os from functools import lru_cache from typing import Iterable import httpx logger = logging.getLogger(__name__) _SYSTEM_PROMPT = ( "Du bist Datenschutz-Redakteur. Du formulierst kurze, einfueg-bereite " "Absaetze fuer Datenschutz-Dokumente — sachlich, in deutscher " "Rechtssprache, ohne Marketing-Floskeln.\n\n" "Du bekommst:\n" "- den FAIL-MC (was geprueft wurde, warum es nicht erfuellt ist)\n" "- einen Auszug aus dem Ist-Dokument\n" "- den Dokument-Typ\n\n" "Du lieferst JSON:\n" '{\n' ' "solution_text": "<3-6 Saetze Vorschlags-Absatz fuer das Dokument>",\n' ' "anchor_hint": "",\n' ' "effort_min": ""\n' '}\n\n' "Regeln:\n" "- KEINE Normtexte 1:1 zitieren — eigene Formulierung + Norm-Referenz.\n" "- KEINE Annahmen ueber Konkretes (z.B. Firmennamen, Adressen) — " "Platzhalter [Ihr Firmenname] / [Ihre Adresse] verwenden.\n" "- Wenn schon eine schwache Variante im Dokument steht, anchor_hint " "auf 'ersetzen' setzen statt einfuegen.\n" "- Nur reines JSON, keine Prosa, keine Code-Fences." ) def _doc_hash(doc_text: str) -> str: return hashlib.md5(doc_text.encode("utf-8")).hexdigest()[:12] _CACHE: dict[str, dict] = {} _CACHE_MAX = 500 def _cache_get(key: str) -> dict | None: return _CACHE.get(key) def _cache_put(key: str, val: dict) -> None: if len(_CACHE) >= _CACHE_MAX: # Drop oldest 50 entries for k in list(_CACHE.keys())[:50]: _CACHE.pop(k, None) _CACHE[key] = val async def _call_ollama(prompt: str) -> str: base = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") model = os.getenv("MC_SOLUTION_MODEL", os.getenv("CMP_LLM_MODEL", "qwen3:30b-a3b")) payload = { "model": model, "stream": False, "format": "json", "messages": [ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], "options": {"temperature": 0.1, "num_predict": 600}, } try: async with httpx.AsyncClient(timeout=90.0) as client: resp = await client.post(f"{base.rstrip('/')}/api/chat", json=payload) resp.raise_for_status() return (resp.json().get("message") or {}).get("content", "") except Exception as e: logger.warning("Qwen MC-solution failed: %s", e) return "" async def _call_ovh(prompt: str) -> str: base = os.getenv("OVH_LLM_URL", "").strip() key = os.getenv("OVH_LLM_KEY", "").strip() model = os.getenv("OVH_LLM_MODEL", "").strip() if not base or not model: return "" headers = {"Content-Type": "application/json"} if key: headers["Authorization"] = f"Bearer {key}" payload = { "model": model, "temperature": 0.1, "max_tokens": 600, "messages": [ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], "response_format": {"type": "json_object"}, } try: async with httpx.AsyncClient(timeout=45.0) as client: resp = await client.post( f"{base.rstrip('/')}/v1/chat/completions", json=payload, headers=headers, ) resp.raise_for_status() choice = (resp.json().get("choices") or [{}])[0] return (choice.get("message") or {}).get("content", "") or "" except Exception as e: logger.warning("OVH MC-solution failed: %s", e) return "" def _parse(content: str) -> dict | None: if not content: return None txt = content.strip() if txt.startswith("```"): txt = "\n".join(txt.split("\n")[1:-1]) a, b = txt.find("{"), txt.rfind("}") if 0 <= a < b: try: obj = json.loads(txt[a:b + 1]) if isinstance(obj, dict) and obj.get("solution_text"): return { "solution_text": str(obj["solution_text"])[:1200], "anchor_hint": str(obj.get("anchor_hint", ""))[:200], "effort_min": str(obj.get("effort_min", "mittel"))[:20], } except Exception: return None return None async def generate_solution( mc: dict, doc_text: str, doc_type: str, ) -> dict | None: """Generates a solution dict for a single FAIL-MC. mc must contain: label, hint, severity. Returns {solution_text, anchor_hint, effort_min} or None. """ if not mc or not doc_text: return None mc_id = str(mc.get("id") or mc.get("label", ""))[:80] cache_key = f"{mc_id}:{doc_type}:{_doc_hash(doc_text)}" cached = _cache_get(cache_key) if cached: return cached excerpt = doc_text[:3500] prompt = ( f"FAIL-MC: {mc.get('label', '')}\n" f"Severity: {mc.get('severity', 'MEDIUM')}\n" f"Aktueller Hint: {mc.get('hint', '')[:300]}\n\n" f"Dokument-Typ: {doc_type}\n" f"Dokument-Auszug:\n---\n{excerpt}\n---\n\n" "Liefere die Loesung als JSON." ) # P31: tiered Cascade (Qwen → OVH → Anthropic) mit Valkey-Cache. try: from compliance.services.llm_cascade import call_with_cascade res = await call_with_cascade( system=_SYSTEM_PROMPT, user=prompt, min_confidence=0.5, max_tokens=600, ) parsed = _parse(res.get("text", "")) if parsed: _cache_put(cache_key, parsed) return parsed except Exception: # fall through to legacy direct calls pass content = await _call_ollama(prompt) parsed = _parse(content) if not parsed: content = await _call_ovh(prompt) parsed = _parse(content) if parsed: _cache_put(cache_key, parsed) return parsed async def generate_solutions_for_fails( failed_mcs: Iterable[dict], doc_text: str, doc_type: str, limit: int = 5, ) -> list[dict]: """Returns a list of {mc_label, severity, solution_text, anchor_hint, effort_min} for the top-N HIGH/CRITICAL fails. Skips MEDIUM/LOW to keep latency bounded.""" sev_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} high_fails = [m for m in (failed_mcs or []) if (m.get("severity") or "").upper() in ("CRITICAL", "HIGH")] high_fails.sort(key=lambda m: sev_order.get( (m.get("severity") or "").upper(), 3)) high_fails = high_fails[:limit] out: list[dict] = [] for mc in high_fails: sol = await generate_solution(mc, doc_text, doc_type) if not sol: continue out.append({ "mc_label": mc.get("label", "")[:200], "severity": mc.get("severity", "MEDIUM"), "solution_text": sol["solution_text"], "anchor_hint": sol["anchor_hint"], "effort_min": sol["effort_min"], }) return out def build_solutions_block_html(solutions: list[dict]) -> str: """Renders the LLM-generated solutions as a Mail-Block.""" if not solutions: return "" items: list[str] = [] for s in solutions: sev_color = "#dc2626" if s["severity"].upper() == "CRITICAL" else "#d97706" items.append( f'
  • ' f'
    ' f'[{s["severity"]}] {s["mc_label"]}
    ' f'
    {s["solution_text"]}
    ' f'
    ' f'Anchor: {s["anchor_hint"] or "—"} ' f' ·  Aufwand: {s["effort_min"]}' f'
  • ' ) return ( '
    ' '
    ' 'Loesungs-Vorschlaege (KI-generiert)
    ' f'

    ' f'{len(solutions)} konkrete Einfuege-Empfehlung' f'{"en" if len(solutions) != 1 else ""} ' 'fuer die kritischen Findings

    ' '

    ' 'Folgende Absaetze koennen Sie direkt uebernehmen — Platzhalter ' '[Ihr Firmenname] / [Ihre Adresse] sind zu ersetzen. Inhaltliche ' 'Korrektheit ist mit DSB / Rechtsabteilung zu pruefen.

    ' '
      ' + "".join(items) + '
    ' '

    Generiert via Qwen3-30b lokal (Fallback: ' 'OVH 120B). Vorschlaege sind kein Rechts-Beratung.

    ' '
    ' )