feat: add privacy router and zero-cost safeguards

This commit is contained in:
2026-07-30 17:54:06 +02:00
parent 570e7fa153
commit 286d96da33
8 changed files with 531 additions and 13 deletions
+151
View File
@@ -0,0 +1,151 @@
"""Conservative local privacy classification for cloud routing."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import StrEnum
from javis.providers.base import ChatMessage
class CloudPolicy(StrEnum):
ALLOWED = "allowed"
ASK = "ask"
NEVER = "never"
@dataclass(frozen=True, slots=True)
class PrivacyDecision:
policy: CloudPolicy
reason: str
_NEVER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(
r"(?i)\b(api[-_ ]?key|passwort|password|access[-_ ]?token|refresh[-_ ]?token|"
r"session[-_ ]?cookie|private[rn]? ssh[-_ ]?schl[üu]ssel)\b"
),
"mögliches Secret",
),
(
re.compile(r"(?i)(AIza[\w-]{20,}|sk-[\w-]{16,}|BEGIN [A-Z ]*PRIVATE KEY)"),
"Schlüsselmuster",
),
(
re.compile(
r"(?i)\b(iban|bic|kreditkart|bankkonto|bankdaten|zahlungsdaten|"
r"kontonummer|pin[- ]?code)\b"
),
"Finanz- oder Zahlungsdaten",
),
(
re.compile(
r"(?i)\b(diagnose|gesundheit|krankheit|medikament|patient|arztbericht|"
r"psychotherapie|blutwert)\b"
),
"Gesundheitsinformation",
),
(
re.compile(
r"(?i)\b(genaue anschrift|wohnanschrift|private[rs]? dokument|"
r"vollständige[rs]? personenprofil|rohe? obsidian|nur lokal|"
r"ausschließlich lokal)\b"
),
"ausdrücklich lokal oder besonders sensibel",
),
)
_ASK_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(
r"(?i)\b(meine frau|mein mann|meine familie|mein kind|meine mutter|"
r"mein vater|meine beziehung|über mich|über pascal)\b"
),
"persönliche oder familiäre Information",
),
(
re.compile(r"(?i)\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b"),
"E-Mail-Adresse",
),
(
re.compile(r"(?<!\w)(?:\+?\d[\d ()/-]{7,}\d)(?!\w)"),
"mögliche Telefonnummer",
),
(
re.compile(
r"(?i)\b(intern(?:e[rsn]?|er)? hostname|interne? domain|rootserver|"
r"privates? projekt|nicht öffentlich|systeminformation|"
r"private[rs]? termin)\b"
),
"nicht öffentliche Infrastruktur oder Planung",
),
(
re.compile(r"(?i)\b(ich|mein(?:e[rsn]?|em)?|mir|mich)\b"),
"möglicher persönlicher Bezug",
),
)
_ALLOWED_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
r"(?i)\b(python|programmier|quellcode|algorithmus|sqlite|linux|windows|"
r"öffentliche dokumentation|allgemeine wissensfrage|was ist|wie funktioniert)\b"
),
)
class PrivacyRouter:
"""Rule-based, entirely local and intentionally conservative."""
def classify(self, text: str, *, cloud_requested: bool = False) -> PrivacyDecision:
normalized = text.strip()
for pattern, reason in _NEVER_PATTERNS:
if pattern.search(normalized):
return PrivacyDecision(CloudPolicy.NEVER, reason)
for pattern, reason in _ASK_PATTERNS:
if pattern.search(normalized):
return PrivacyDecision(CloudPolicy.ASK, reason)
if any(pattern.search(normalized) for pattern in _ALLOWED_PATTERNS):
return PrivacyDecision(CloudPolicy.ALLOWED, "allgemeiner oder technischer Inhalt")
if cloud_requested:
return PrivacyDecision(
CloudPolicy.ASK,
"expliziter Cloudwunsch bei uneindeutigem Inhalt",
)
return PrivacyDecision(CloudPolicy.ASK, "Inhalt nicht eindeutig klassifizierbar")
def minimal_context(
self,
messages: list[ChatMessage],
*,
current_policy: CloudPolicy,
approved: bool,
max_chars: int,
max_messages: int,
) -> list[ChatMessage]:
if current_policy is CloudPolicy.NEVER:
return []
if current_policy is CloudPolicy.ASK and not approved:
return []
selected: list[ChatMessage] = []
remaining = max_chars
for index in range(len(messages) - 1, max(-1, len(messages) - max_messages - 1), -1):
message = messages[index]
decision = self.classify(message.content)
is_current = index == len(messages) - 1
may_include = decision.policy is CloudPolicy.ALLOWED or (
is_current and current_policy is CloudPolicy.ASK and approved
)
if not may_include:
continue
content = message.content[:remaining]
if not content:
break
selected.append(ChatMessage(message.role, content))
remaining -= len(content)
if remaining <= 0:
break
selected.reverse()
return selected