Kansho/backend/privacy_gateway.py
2026-08-25 17:16:35 +02:00

313 lines
11 KiB
Python

"""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 re
from entity_detect import detect_and_remember
from identity_store import KINSHIP, is_maskable_label, list_mappings
from pronoun_bind import bind_user_lines
from providers import ProviderError, complete_chat, generate_provider
MAX_EGRESS_CHARS = 24000
debug_last: dict[str, Any] | None = None
debug_calls = 0
class PrivacyGatewayError(Exception):
def __init__(self, code: str, message: str, status_code: int = 503):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
@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
checked_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
ALLOWED_CLASSES = {"A", "B", "C"}
LOCAL_ONLY_CLASS = "A"
def reset_debug() -> None:
global debug_last, debug_calls
debug_last = None
debug_calls = 0
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) -> str:
cleaned = (text or "").strip()
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ß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 _mask_body(text: str, mappings: list[dict]) -> 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 = re.compile(
rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])",
re.IGNORECASE,
)
person = _is_person_token(token)
def repl(match: re.Match, *, _person=person, _ph=placeholder) -> str:
if _person and not _mask_person_hit(masked, match.start(), match.end()):
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 raw.startswith("user:") or raw.startswith("assistant:"):
parts.append(_mask_body(raw, mappings) + newline)
else:
parts.append(line)
return "".join(parts)
def mask_for_egress(rendered: str, mappings: list[dict], purpose: str) -> str:
"""Mask names. Bind pronouns to person tokens only in the dialogue turn.
Journal generation keeps sie/er/ihr so demask does not turn every reference into the name.
"""
masked = _mask(rendered, mappings, personal_lines_only=purpose == "dialogue_turn")
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()
if not label or not is_maskable_label(label):
continue
if re.search(
rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])",
text,
re.IGNORECASE,
):
leaked.append(label)
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_generate":
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]) -> str:
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 _fake_complete(policy.get("purpose") or "", rendered)
try:
return complete_chat(config, messages, timeout=60)
except ProviderError as exc:
raise PrivacyGatewayError(exc.code, exc.message, exc.status_code) from exc
def complete(request: GatewayRequest) -> GatewayResult:
global debug_last, 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 ""))
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)
debug_calls += 1
config = generate_provider()
debug_last = {
"purpose": request.purpose,
"layer": {
"journal_generate": "journalentwurf",
"profile_review": "profilreview",
}.get(request.purpose, "dialogzug"),
"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),
}
raw = complete_model(
[{"role": "user", "content": masked}],
{"purpose": request.purpose, "zdr": True, "no_train": True},
)
validated = _validate_response(raw, mappings)
result.content = _demask(validated, mappings)
result.allowed = True
debug_last["raw"] = validated
debug_last["reply"] = result.content
return result
def last_trace() -> dict | None:
return debug_last
def public_trace(trace: dict | None) -> dict | None:
"""Admin test view. No mapping table, no secrets."""
if not trace:
return 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"),
"egress": trace.get("masked"),
"mask_input": trace.get("mask_input"),
"raw": trace.get("raw"),
"reply": trace.get("reply"),
}