497 lines
18 KiB
Python
497 lines
18 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 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"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
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 = _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"),
|
|
}
|