"""Local privacy gateway. Personal generative egress is not allowed to skip this layer.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any import contextvars import re from entity_detect import detect_and_remember from identity_store import KINSHIP, is_maskable_label, list_mappings from journal_reconstruct import claim_texts, fake_reconstruction, is_dialogue_role_line from prompt_budget import ( ERROR_PROVIDER_CONTEXT_LENGTH, JOURNAL_PURPOSES, JournalBudgetError, USER_MESSAGES, assert_input_fits, merge_usage, ) from pronoun_bind import bind_user_lines from providers import ChatResult, ProviderError, complete_chat, generate_provider MAX_EGRESS_CHARS = 24000 debug_calls = 0 last_compact: dict[str, Any] | None = None _test_recorder: contextvars.ContextVar[list | None] = contextvars.ContextVar( "kansho_gateway_recorder", default=None, ) COMPACT_DIAGNOSTIC_KEYS = ( "model", "actual_model", "provider", "purpose", "prompt_slug", "effective_context_window", "estimated_input_tokens", "prompt_tokens", "completion_tokens", "total_tokens", "reserved_output_tokens", "max_tokens", "safety_margin", "context_compression", "cost", "budget_ok", "abort_reason", "status", ) class PrivacyGatewayError(Exception): def __init__(self, code: str, message: str, status_code: int = 503, diagnostics: dict | None = None): super().__init__(message) self.code = code self.message = message self.status_code = status_code self.diagnostics = diagnostics or {} @dataclass class GatewayRequest: prompt_id: str | None purpose: str data_class: str payload: dict[str, Any] = field(default_factory=dict) profile_id: str | None = None @dataclass class GatewayResult: allowed: bool reason: str provider: str | None = None content: str | None = None diagnostics: dict[str, Any] = field(default_factory=dict) trace: dict[str, Any] | None = None checked_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) ALLOWED_CLASSES = {"A", "B", "C"} LOCAL_ONLY_CLASS = "A" def compact_diagnostics(data: dict[str, Any] | None) -> dict[str, Any]: payload = {} for key in COMPACT_DIAGNOSTIC_KEYS: if data and key in data and data[key] is not None: payload[key] = data[key] return payload def install_test_recorder() -> list[dict[str, Any]]: """Test-local observer. Production never installs this.""" records: list[dict[str, Any]] = [] _test_recorder.set(records) return records def _record_test(event: dict[str, Any]) -> None: records = _test_recorder.get() if records is not None: records.append(event) def reset_debug() -> None: global last_compact, debug_calls last_compact = None debug_calls = 0 _test_recorder.set(None) def inspect(request: GatewayRequest) -> GatewayResult: """Fail closed unless a policy-complete generative provider is configured.""" data_class = (request.data_class or "").upper() if data_class not in ALLOWED_CLASSES: return GatewayResult(allowed=False, reason="unknown_data_class") if data_class == LOCAL_ONLY_CLASS: return GatewayResult(allowed=False, reason="class_a_never_leaves_local_zone") config = generate_provider() if not config: return GatewayResult(allowed=False, reason="no_egress_provider_configured") return GatewayResult(allowed=True, reason="policy_ok", provider=config.name) def _minimize(text: str, purpose: str = "") -> str: cleaned = (text or "").strip() if purpose in JOURNAL_PURPOSES: return cleaned if len(cleaned) <= MAX_EGRESS_CHARS: return cleaned return cleaned[:MAX_EGRESS_CHARS] _LETTER = r"A-Za-zÄÖÜäöüß" _WORD = re.compile(rf"[{_LETTER}]+") THING_GOVERNORS = { "esse", "essen", "isst", "aß", "aßest", "gegessen", "koche", "kochen", "kochte", "gekocht", "trinke", "trinken", "trank", "getrunken", "kaufe", "kaufen", "kaufte", "gekauft", "bestelle", "bestellen", "bestellte", "hole", "holen", "holte", } def _words_before(text: str, index: int, n: int = 4) -> list[str]: return [word.lower() for word in _WORD.findall(text[:index])[-n:]] def _words_after(text: str, index: int, n: int = 3) -> list[str]: return [word.lower() for word in _WORD.findall(text[index:])[:n]] def _is_person_token(token: str) -> bool: raw = (token or "").strip().upper() raw = raw[2:-2] if raw.startswith("[[") and raw.endswith("]]") else raw return raw.startswith("PERSON:") def _mask_person_hit(text: str, start: int, end: int) -> bool: prev = _words_before(text, start) nxt = _words_after(text, end) if prev and prev[-1] in KINSHIP: return True if any(word in THING_GOVERNORS for word in prev + nxt): return False return True def _is_identity_mention(text: str, start: int, end: int, token: str) -> bool: """Same rule as masking: food/thing homonyms are not identity.""" if not _is_person_token(token): return True return _mask_person_hit(text, start, end) def _label_pattern(label: str) -> re.Pattern[str]: return re.compile( rf"(? str: masked = text for item in sorted(mappings, key=lambda row: len(row.get("local_label") or ""), reverse=True): label = (item.get("local_label") or "").strip() token = (item.get("token") or "").strip() if not label or not token or not is_maskable_label(label): continue placeholder = token if token.startswith("[[") else f"[[{token}]]" pattern = _label_pattern(label) def repl(match: re.Match, *, _token=token, _ph=placeholder) -> str: if not _is_identity_mention(masked, match.start(), match.end(), _token): return match.group(0) return _ph masked = pattern.sub(repl, masked) return masked def _mask(text: str, mappings: list[dict], *, personal_lines_only: bool = False) -> str: if not personal_lines_only: return _mask_body(text, mappings) parts: list[str] = [] for line in (text or "").splitlines(keepends=True): raw = line[:-1] if line.endswith("\n") else line newline = "\n" if line.endswith("\n") else "" if is_dialogue_role_line(raw): parts.append(_mask_body(raw, mappings) + newline) else: parts.append(line) return "".join(parts) IDENTITY_LEAK_RETRY = ( "Korrektur: Keine Klartext-Identität. Nur die vorhandenen Platzhalter [[…]], " "keine Klarnamen, keine Klarorte." ) def mask_for_egress(rendered: str, mappings: list[dict], purpose: str) -> str: """Mask names in the full rendered prompt. Bind pronouns only on user lines. Opening hints, Space-Ausschnitte and titles are personal context, not instructions. Journal generation keeps sie/er/ihr so demask does not turn every reference into the name. """ masked = _mask(rendered, mappings) if purpose == "dialogue_turn": return bind_user_lines(masked) return masked def _demask(text: str, mappings: list[dict]) -> str: result = text or "" for item in mappings: label = (item.get("local_label") or "").strip() token = (item.get("token") or "").strip() if not label or not token: continue placeholder = token if token.startswith("[[") else f"[[{token}]]" result = result.replace(placeholder, label) return result def _validate_response(content: str, mappings: list[dict] | None = None) -> str: text = (content or "").strip() if not text: raise PrivacyGatewayError("empty_provider_response", "Der Provider lieferte keine Antwort.") leaked: list[str] = [] for item in mappings or []: label = (item.get("local_label") or "").strip() token = (item.get("token") or "").strip() if not label or not is_maskable_label(label): continue for match in _label_pattern(label).finditer(text): if _is_identity_mention(text, match.start(), match.end(), token): leaked.append(label) break if leaked: raise PrivacyGatewayError( "response_validation_failed", "Antwort enthielt Klartext-Identität vor der Demaskierung.", ) return text def _fake_complete(purpose: str, rendered: str) -> str: if purpose == "profile_review": return ( '{"kind":"kansho.profile_analysis_result","format_version":1,"target":"writing",' '"evidence_basis":["kansho_sources"],' '"changes":[{"layer":"trait","key":"dry_humor","slug":"dry_humor","facet_key":"autobiographical_journal",' '"action":"add","label":"Trockener Humor",' '"proposed_value":"gelegentlich trocken, nie aufgesetzt",' '"evidence_basis":["kansho_sources"],' '"rationale":"Fake-Review: Journal-Evidenz trägt Humor in autobiographical_journal, nicht als globalen Core.",' '"evidence_ids":[],"exemplars":[{"excerpt":"haha das war irgendwie lustig","role":"exemplar","evidence_basis":["kansho_sources"]}]}]}' ) if purpose == "journal_reconstruct": return fake_reconstruction(rendered) if purpose == "journal_generate": match = re.search(r"\{.*\}", rendered, re.DOTALL) if match: try: import json data = json.loads(match.group(0)) parts = claim_texts(data) body = " ".join(str(part) for part in parts if part) if body: return f"Ein Tag\n\n{body}" except (ValueError, TypeError): pass lines = [] for line in rendered.splitlines(): if line.startswith("user:"): lines.append(line[5:].strip()) body = " ".join(part for part in lines if part) or "Ein stiller Tag." return f"Ein Tag\n\n{body}" if "Keine Interviewfrage" in rendered: return '{"operation":"fortfuehren","impulse":"Der letzte Faden bleibt offen."}' impulse = ( "Was davon möchtest du vertiefen, ohne Unerwähntes als nicht geschehen zu behandeln?" if ("nicht geschehen" in rendered or "nicht erwähnt" in rendered) else "Was davon möchtest du festhalten?" ) return '{"operation":"erleben_vertiefen","impulse":"' + impulse + '"}' def complete_model(messages: list[dict], policy: dict[str, Any]) -> ChatResult: config = generate_provider() if not config: raise PrivacyGatewayError("no_egress_provider_configured", "Es ist kein Egress-Provider konfiguriert.") if config.mode == "fake": rendered = "\n".join(item.get("content") or "" for item in messages) return ChatResult( content=_fake_complete(policy.get("purpose") or "", rendered), model=config.model, usage={}, context_compression="disabled" if policy.get("disable_context_compression") else "not_applicable", ) try: return complete_chat( config, messages, timeout=90 if (policy.get("purpose") in JOURNAL_PURPOSES) else 60, max_tokens=policy.get("max_tokens"), disable_context_compression=bool(policy.get("disable_context_compression")), ) except ProviderError as exc: if exc.code == "provider_context_length_rejected": raise PrivacyGatewayError( ERROR_PROVIDER_CONTEXT_LENGTH, USER_MESSAGES[ERROR_PROVIDER_CONTEXT_LENGTH], exc.status_code, diagnostics=exc.diagnostics, ) from exc raise PrivacyGatewayError(exc.code, exc.message, exc.status_code, getattr(exc, "diagnostics", None)) from exc def complete(request: GatewayRequest) -> GatewayResult: global last_compact, debug_calls result = inspect(request) if not result.allowed: raise PrivacyGatewayError( result.reason, "Persönlicher KI-Aufruf wurde vom Privacy Gateway blockiert. " "Es ist kein Egress-Provider konfiguriert." if result.reason == "no_egress_provider_configured" else "Persönlicher KI-Aufruf wurde vom Privacy Gateway blockiert.", ) rendered = _minimize(str((request.payload or {}).get("rendered") or ""), request.purpose) source_text = str((request.payload or {}).get("source_text") or rendered) detect_name = None try: mappings, detect_name, detect_note = detect_and_remember(request.profile_id, source_text) except ProviderError as exc: raise PrivacyGatewayError(exc.code, exc.message, exc.status_code) from exc if not mappings and request.profile_id: mappings = list_mappings(request.profile_id) masked = mask_for_egress(rendered, mappings, request.purpose) budget = (request.payload or {}).get("budget") diagnostics = dict((request.payload or {}).get("diagnostics") or {}) if request.purpose in JOURNAL_PURPOSES: if budget is None: raise PrivacyGatewayError( "model_metadata_unknown", USER_MESSAGES["model_metadata_unknown"], 503, diagnostics={"reason": "journal_budget_missing"}, ) try: assert_input_fits(budget, masked) except JournalBudgetError as exc: raise PrivacyGatewayError(exc.code, exc.message, exc.status_code, exc.diagnostics) from exc diagnostics.update(budget.as_diagnostics()) diagnostics["estimated_input_tokens"] = budget.estimated_input_tokens debug_calls += 1 config = generate_provider() layer = { "journal_generate": "journalentwurf", "journal_reconstruct": "journalrekonstruktion", "profile_review": "profilreview", }.get(request.purpose, "dialogzug") max_tokens = (request.payload or {}).get("max_tokens") if max_tokens is not None: diagnostics["max_tokens"] = max_tokens diagnostics["provider"] = result.provider diagnostics["purpose"] = request.purpose diagnostics["prompt_slug"] = (request.payload or {}).get("prompt_slug") diagnostics["model"] = config.model if config else None request_trace = { "purpose": request.purpose, "layer": layer, "data_class": request.data_class, "prompt_slug": (request.payload or {}).get("prompt_slug"), "rendered": rendered, "masked": masked, "mask_input": source_text, "provider": result.provider, "model": config.model if config else None, "detect_provider": detect_name, "detect_note": detect_note, "mapping_count": len(mappings), "budget": compact_diagnostics(diagnostics), } model_policy = { "purpose": request.purpose, "zdr": True, "no_train": True, "max_tokens": max_tokens, "disable_context_compression": bool( (request.payload or {}).get("disable_context_compression") ), } try: chat = complete_model([{"role": "user", "content": masked}], model_policy) raw = chat.content try: validated = _validate_response(raw, mappings) except PrivacyGatewayError as exc: if exc.code != "response_validation_failed": raise diagnostics["response_validation_retry"] = 1 chat = complete_model( [{"role": "user", "content": masked + "\n\n" + IDENTITY_LEAK_RETRY}], model_policy, ) raw = chat.content validated = _validate_response(raw, mappings) except PrivacyGatewayError as exc: failed = merge_usage( {**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error"}, None, config.model if config else None, ) last_compact = compact_diagnostics(failed) _record_test({"purpose": request.purpose, "ok": False, "code": exc.code}) raise diagnostics = merge_usage( { **diagnostics, "context_compression": chat.context_compression, "budget_ok": True, "status": "ok", }, chat.usage, chat.model or (config.model if config else None), ) result.content = _demask(validated, mappings) result.allowed = True result.diagnostics = compact_diagnostics(diagnostics) request_trace["raw"] = validated request_trace["reply"] = result.content request_trace["model"] = chat.model or request_trace.get("model") request_trace["budget"] = compact_diagnostics(diagnostics) result.trace = public_trace(request_trace) last_compact = compact_diagnostics(diagnostics) _record_test({"purpose": request.purpose, "ok": True, "prompt_slug": request_trace.get("prompt_slug")}) return result def last_trace() -> dict | None: """Compact diagnostics only. Full prompts live on the current GatewayResult.""" return last_compact def public_trace(trace: dict | None) -> dict | None: """Admin test view for the current response. No mapping table, no secrets.""" if not trace: return None budget = dict(trace.get("budget") or {}) for key in ("rendered", "masked", "intern", "raw", "reply", "prompt", "messages"): budget.pop(key, None) return { "purpose": trace.get("purpose"), "layer": trace.get("layer"), "data_class": trace.get("data_class"), "prompt_slug": trace.get("prompt_slug"), "provider": trace.get("provider"), "model": trace.get("model"), "detect_provider": trace.get("detect_provider"), "detect_note": trace.get("detect_note"), "mapping_count": trace.get("mapping_count"), "intern": trace.get("rendered") or trace.get("intern"), "egress": trace.get("masked") or trace.get("egress"), "mask_input": trace.get("mask_input"), "raw": trace.get("raw"), "reply": trace.get("reply"), "budget": budget or None, "stages": trace.get("stages"), }