feat: add privacy router and zero-cost safeguards
This commit is contained in:
+20
-9
@@ -70,6 +70,14 @@ Rootserver:
|
|||||||
der Windows Credential Locker verwendet.
|
der Windows Credential Locker verwendet.
|
||||||
- Secret-CLI: `javis secrets set gemini`, `status` und `delete gemini`; Setzen
|
- Secret-CLI: `javis secrets set gemini`, `status` und `delete gemini`; Setzen
|
||||||
verwendet verdeckte Eingabe und kein Befehl gibt den Schlüsselwert aus.
|
verwendet verdeckte Eingabe und kein Befehl gibt den Schlüsselwert aus.
|
||||||
|
- Lokaler Datenschutzrouter klassifiziert jeden späteren Cloudaufruf konservativ
|
||||||
|
als `allowed`, `ask` oder `never`; `never` kann auch durch einen ausdrücklichen
|
||||||
|
Cloudwunsch nicht überstimmt werden.
|
||||||
|
- Nullkosten-Vorgaben und lokale Schutzgrenzen sind zentral konfiguriert:
|
||||||
|
Gemini standardmäßig deaktiviert, `free_only = true`, keine bestätigte
|
||||||
|
Billing-Freiheit ohne Pascals lokale Bestätigung, höchstens ein Retry.
|
||||||
|
- Provider-Nutzung wird separat in SQLite ausschließlich als technische Metadaten
|
||||||
|
protokolliert; Prompts, Antworten und Secrets werden dort nicht gespeichert.
|
||||||
|
|
||||||
## Aktuelle Architektur
|
## Aktuelle Architektur
|
||||||
|
|
||||||
@@ -115,6 +123,8 @@ Rootserver:
|
|||||||
Auth-Keys, GA-SDK, Fehlercodes und Datenschutzbedingungen geprüft und unter
|
Auth-Keys, GA-SDK, Fehlercodes und Datenschutzbedingungen geprüft und unter
|
||||||
`docs/GEMINI_PROVIDER.md` kompakt dokumentiert.
|
`docs/GEMINI_PROVIDER.md` kompakt dokumentiert.
|
||||||
- Betriebssystemgebundenen Secret-Provider und isolierte Fake-Backend-Tests ergänzt.
|
- Betriebssystemgebundenen Secret-Provider und isolierte Fake-Backend-Tests ergänzt.
|
||||||
|
- Datenschutzrouter, minimale Cloud-Kontextauswahl, strikte Nullkosten-Defaults,
|
||||||
|
lokale Tages-/Größenlimits und metadatenbasiertes Provider-Audit ergänzt.
|
||||||
|
|
||||||
## Aktuelle Tests
|
## Aktuelle Tests
|
||||||
|
|
||||||
@@ -129,7 +139,8 @@ Letzter bestätigter Projektstand:
|
|||||||
- uv-Lock und `uv sync --dev`: bestanden
|
- uv-Lock und `uv sync --dev`: bestanden
|
||||||
- Python in `.venv`: 3.12.13
|
- Python in `.venv`: 3.12.13
|
||||||
- Ruff in `.venv`: 0.16.0
|
- Ruff in `.venv`: 0.16.0
|
||||||
- Unit-Tests: 18 bestanden, davon 5 für Secret-Speicher und Secret-CLI
|
- Unit-Tests: 30 bestanden, davon 8 für Datenschutzrouting und Kontextminimierung,
|
||||||
|
3 für Nullkosten-/Limitkonfiguration sowie 1 für das metadatenbasierte Audit
|
||||||
- Ruff Lint: bestanden
|
- Ruff Lint: bestanden
|
||||||
- Ruff Formatprüfung: bestanden
|
- Ruff Formatprüfung: bestanden
|
||||||
|
|
||||||
@@ -171,6 +182,7 @@ Abnahmestatus:
|
|||||||
- Feature-Dokumentation: `5d567e5`
|
- Feature-Dokumentation: `5d567e5`
|
||||||
- lokaler Merge nach `main`: `f3d6dfb`
|
- lokaler Merge nach `main`: `f3d6dfb`
|
||||||
- geprüfter und gepushter Abschlussstand vor diesem Handoff-Update: `0a550e6`
|
- geprüfter und gepushter Abschlussstand vor diesem Handoff-Update: `0a550e6`
|
||||||
|
- Secret-Provider: `570e7fa`
|
||||||
- `origin` verwendet HTTPS
|
- `origin` verwendet HTTPS
|
||||||
- kein Force-Push und keine umgeschriebene Historie
|
- kein Force-Push und keine umgeschriebene Historie
|
||||||
|
|
||||||
@@ -190,7 +202,8 @@ Abnahmestatus:
|
|||||||
- Startmechanismus für Ollama ist noch bewusst manuell und nur pro Prozess konfiguriert
|
- Startmechanismus für Ollama ist noch bewusst manuell und nur pro Prozess konfiguriert
|
||||||
- Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche
|
- Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche
|
||||||
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben
|
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben
|
||||||
- endgültiger Secret-Provider ist offen; lokale Klartext-XML wäre nur restriktiv geschützt und Git-ignoriert zulässig
|
- normaler Secret-Provider ist festgelegt: Betriebssystem-Keyring; eine
|
||||||
|
Klartext-XML wird nicht für API-Schlüssel verwendet
|
||||||
- Google-Quoten bleiben projektabhängig und müssen in AI Studio geprüft werden;
|
- Google-Quoten bleiben projektabhängig und müssen in AI Studio geprüft werden;
|
||||||
lokale Limits sind nur zusätzliche Schutzgrenzen
|
lokale Limits sind nur zusätzliche Schutzgrenzen
|
||||||
- Gaming-PC zeigt derzeit nur 8 statt 16 logische CPU-Prozessoren; Ursache ungeklärt
|
- Gaming-PC zeigt derzeit nur 8 statt 16 logische CPU-Prozessoren; Ursache ungeklärt
|
||||||
@@ -211,14 +224,12 @@ Abnahmestatus:
|
|||||||
|
|
||||||
## Nächster sinnvoller Auftrag
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Als separates nächstes Featurepaket auf neuem Branch:
|
Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket:
|
||||||
|
|
||||||
1. offizielle aktuelle Gemini-Free-Tier-Modelle und Kontingente neu prüfen
|
1. offiziellen `google-genai`-Client anbinden
|
||||||
2. Datenschutzrouting `allowed`/`ask`/`never` vor jedem Cloudaufruf erzwingen
|
2. Gemini-Erfolg sowie fehlenden/ungültigen Schlüssel, 429, Netzwerkfehler und
|
||||||
3. Gemini als kostenlosen bevorzugten Provider und Ollama als lokalen
|
höchstens einen Retry vollständig mit Test-Doubles prüfen
|
||||||
Datenschutz-/Offline-/429-Fallback implementieren
|
3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen
|
||||||
4. `free_only = true`, keine Billing-Aktivierung und alle geforderten Erfolgs-,
|
|
||||||
Key-, 429-, Netzwerk-, Fallback- und Datenschutztests nachweisen
|
|
||||||
|
|
||||||
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
|
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -40,7 +40,12 @@ Stand: lokaler Textchat auf `main`, 30.07.2026.
|
|||||||
- offizielle Gemini-Rahmenbedingungen geprüft; Standardmodell `gemini-3.6-flash`
|
- offizielle Gemini-Rahmenbedingungen geprüft; Standardmodell `gemini-3.6-flash`
|
||||||
- `keyring` 25.7.0 mit Windows Credential Locker als normalem Secret-Speicher
|
- `keyring` 25.7.0 mit Windows Credential Locker als normalem Secret-Speicher
|
||||||
- verdeckte Secret-Befehle `set`, `status` und `delete`
|
- verdeckte Secret-Befehle `set`, `status` und `delete`
|
||||||
- 18 Unit-Tests aktuell erfolgreich
|
- lokaler Datenschutzrouter mit `allowed`, `ask` und nicht übersteuerbarem `never`
|
||||||
|
- minimale Cloud-Kontextauswahl schließt sensible Verlaufsteile aus
|
||||||
|
- konservative Nullkosten-Defaults und harte lokale Tages-, Größen-, Timeout-
|
||||||
|
und Retry-Grenzen
|
||||||
|
- separates SQLite-Provider-Audit speichert nur Metadaten, keine Inhalte
|
||||||
|
- 30 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber
|
||||||
|
|
||||||
## Nicht implementiert
|
## Nicht implementiert
|
||||||
|
|
||||||
@@ -49,7 +54,8 @@ Living-Mind-Frontend und automatische Ollama-Prozessverwaltung.
|
|||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- Branch: `main`
|
- stabiler Branch: `main`
|
||||||
|
- aktueller Branch: `feat/gemini-privacy-router`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- Grundgerüstcommit: `6d04171`
|
||||||
- Basis des Feature-Branches: `e6eb995`
|
- Basis des Feature-Branches: `e6eb995`
|
||||||
- Toolchain: `fa90a5c`
|
- Toolchain: `fa90a5c`
|
||||||
@@ -64,5 +70,6 @@ Living-Mind-Frontend und automatische Ollama-Prozessverwaltung.
|
|||||||
Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden
|
Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden
|
||||||
noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion.
|
noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion.
|
||||||
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
|
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
|
||||||
Gemini ist als separates kostenloses Folgepaket mit lokalem Datenschutzrouting
|
Der Gemini-Netzwerkprovider und das automatische Ollama-Fallback sind noch nicht
|
||||||
geplant, aber noch nicht implementiert oder mit einem API-Key konfiguriert.
|
implementiert. Es wurde kein echter API-Key angefordert, angezeigt oder
|
||||||
|
konfiguriert und noch keine Cloudanfrage ausgeführt.
|
||||||
|
|||||||
@@ -13,6 +13,35 @@ class ConfigurationError(ValueError):
|
|||||||
"""Raised when runtime configuration is unsafe or invalid."""
|
"""Raised when runtime configuration is unsafe or invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean(values: Mapping[str, str], name: str, default: bool) -> bool:
|
||||||
|
raw = values.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
normalized = raw.strip().lower()
|
||||||
|
if normalized in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise ConfigurationError(f"{name} muss true oder false sein.")
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_integer(
|
||||||
|
values: Mapping[str, str],
|
||||||
|
name: str,
|
||||||
|
default: int,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
) -> int:
|
||||||
|
raw = values.get(name, str(default))
|
||||||
|
try:
|
||||||
|
result = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigurationError(f"{name} muss eine ganze Zahl sein.") from exc
|
||||||
|
if result <= 0 or result > maximum:
|
||||||
|
raise ConfigurationError(f"{name} muss zwischen 1 und {maximum} liegen.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def default_data_dir(
|
def default_data_dir(
|
||||||
environ: Mapping[str, str] | None = None,
|
environ: Mapping[str, str] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -42,6 +71,18 @@ class Settings:
|
|||||||
model: str = "qwen3:8b"
|
model: str = "qwen3:8b"
|
||||||
ollama_base_url: str = "http://127.0.0.1:11434"
|
ollama_base_url: str = "http://127.0.0.1:11434"
|
||||||
timeout_seconds: float = 180.0
|
timeout_seconds: float = 180.0
|
||||||
|
provider_mode: str = "auto"
|
||||||
|
gemini_enabled: bool = False
|
||||||
|
gemini_model: str = "gemini-3.6-flash"
|
||||||
|
gemini_optional_model: str = "gemini-3.5-flash-lite"
|
||||||
|
free_only: bool = True
|
||||||
|
billing_confirmed_disabled: bool = False
|
||||||
|
max_cloud_requests_per_day: int = 25
|
||||||
|
max_cloud_input_chars: int = 12_000
|
||||||
|
max_cloud_output_tokens: int = 1_024
|
||||||
|
gemini_timeout_seconds: float = 30.0
|
||||||
|
gemini_max_retries: int = 1
|
||||||
|
max_cloud_context_messages: int = 6
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
@@ -59,11 +100,20 @@ class Settings:
|
|||||||
model = values.get("JAVIS_MODEL", "qwen3:8b").strip()
|
model = values.get("JAVIS_MODEL", "qwen3:8b").strip()
|
||||||
base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip()
|
base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip()
|
||||||
timeout_raw = values.get("JAVIS_MODEL_TIMEOUT", "180")
|
timeout_raw = values.get("JAVIS_MODEL_TIMEOUT", "180")
|
||||||
|
provider_mode = values.get("JAVIS_PROVIDER_MODE", "auto").strip().lower()
|
||||||
|
gemini_enabled = _boolean(values, "JAVIS_GEMINI_ENABLED", False)
|
||||||
|
free_only = _boolean(values, "JAVIS_FREE_ONLY", True)
|
||||||
|
billing_disabled = _boolean(values, "JAVIS_GEMINI_BILLING_CONFIRMED_DISABLED", False)
|
||||||
|
gemini_timeout_raw = values.get("JAVIS_GEMINI_TIMEOUT", "30")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
timeout = float(timeout_raw)
|
timeout = float(timeout_raw)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss eine Zahl sein.") from exc
|
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss eine Zahl sein.") from exc
|
||||||
|
try:
|
||||||
|
gemini_timeout = float(gemini_timeout_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigurationError("JAVIS_GEMINI_TIMEOUT muss eine Zahl sein.") from exc
|
||||||
|
|
||||||
settings = cls(
|
settings = cls(
|
||||||
data_dir=data_dir.resolve(),
|
data_dir=data_dir.resolve(),
|
||||||
@@ -71,6 +121,29 @@ class Settings:
|
|||||||
model=model,
|
model=model,
|
||||||
ollama_base_url=base_url,
|
ollama_base_url=base_url,
|
||||||
timeout_seconds=timeout,
|
timeout_seconds=timeout,
|
||||||
|
provider_mode=provider_mode,
|
||||||
|
gemini_enabled=gemini_enabled,
|
||||||
|
gemini_model=values.get("JAVIS_GEMINI_MODEL", "gemini-3.6-flash").strip(),
|
||||||
|
gemini_optional_model=values.get(
|
||||||
|
"JAVIS_GEMINI_OPTIONAL_MODEL", "gemini-3.5-flash-lite"
|
||||||
|
).strip(),
|
||||||
|
free_only=free_only,
|
||||||
|
billing_confirmed_disabled=billing_disabled,
|
||||||
|
max_cloud_requests_per_day=_positive_integer(
|
||||||
|
values, "JAVIS_MAX_CLOUD_REQUESTS_PER_DAY", 25, maximum=1_000
|
||||||
|
),
|
||||||
|
max_cloud_input_chars=_positive_integer(
|
||||||
|
values, "JAVIS_MAX_CLOUD_INPUT_CHARS", 12_000, maximum=1_000_000
|
||||||
|
),
|
||||||
|
max_cloud_output_tokens=_positive_integer(
|
||||||
|
values, "JAVIS_MAX_CLOUD_OUTPUT_TOKENS", 1_024, maximum=65_536
|
||||||
|
),
|
||||||
|
gemini_timeout_seconds=gemini_timeout,
|
||||||
|
gemini_max_retries=_positive_integer(values, "JAVIS_GEMINI_MAX_ATTEMPTS", 2, maximum=2)
|
||||||
|
- 1,
|
||||||
|
max_cloud_context_messages=_positive_integer(
|
||||||
|
values, "JAVIS_MAX_CLOUD_CONTEXT_MESSAGES", 6, maximum=50
|
||||||
|
),
|
||||||
)
|
)
|
||||||
settings.validate()
|
settings.validate()
|
||||||
return settings
|
return settings
|
||||||
@@ -84,6 +157,22 @@ class Settings:
|
|||||||
raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.")
|
raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.")
|
||||||
if self.timeout_seconds <= 0:
|
if self.timeout_seconds <= 0:
|
||||||
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss größer als 0 sein.")
|
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss größer als 0 sein.")
|
||||||
|
if self.provider_mode not in {"auto", "local", "gemini"}:
|
||||||
|
raise ConfigurationError("JAVIS_PROVIDER_MODE muss auto, local oder gemini sein.")
|
||||||
|
if not self.free_only:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Kostenpflichtiger Betrieb wird nicht unterstützt; JAVIS_FREE_ONLY muss true sein."
|
||||||
|
)
|
||||||
|
if self.gemini_model != "gemini-3.6-flash":
|
||||||
|
raise ConfigurationError(
|
||||||
|
"In diesem Paket ist nur das geprüfte Standardmodell gemini-3.6-flash aktiv."
|
||||||
|
)
|
||||||
|
if not self.gemini_optional_model:
|
||||||
|
raise ConfigurationError("JAVIS_GEMINI_OPTIONAL_MODEL darf nicht leer sein.")
|
||||||
|
if self.gemini_timeout_seconds <= 0 or self.gemini_timeout_seconds > 60:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"JAVIS_GEMINI_TIMEOUT muss größer als 0 und höchstens 60 sein."
|
||||||
|
)
|
||||||
|
|
||||||
parsed = urlparse(self.ollama_base_url)
|
parsed = urlparse(self.ollama_base_url)
|
||||||
if parsed.scheme != "http" or parsed.hostname not in {
|
if parsed.scheme != "http" or parsed.hostname not in {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Metadata-only provider audit and local usage limits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class UsageStoreError(RuntimeError):
|
||||||
|
"""Provider metadata could not be stored or read."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProviderEvent:
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
success: bool
|
||||||
|
error_category: str | None
|
||||||
|
fallback: bool
|
||||||
|
privacy_policy: str
|
||||||
|
input_tokens: int | None = None
|
||||||
|
output_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteUsageStore:
|
||||||
|
def __init__(self, database_path: Path) -> None:
|
||||||
|
self.database_path = database_path
|
||||||
|
try:
|
||||||
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with closing(self._connect()) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS provider_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL,
|
||||||
|
error_category TEXT,
|
||||||
|
fallback INTEGER NOT NULL,
|
||||||
|
privacy_policy TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER,
|
||||||
|
output_tokens INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provider_events_time_provider
|
||||||
|
ON provider_events(occurred_at, provider)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise UsageStoreError(
|
||||||
|
f"Die Nutzungsmetadatenbank kann nicht geöffnet werden: {database_path}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(self.database_path, timeout=10)
|
||||||
|
|
||||||
|
def record(self, event: ProviderEvent) -> None:
|
||||||
|
try:
|
||||||
|
with closing(self._connect()) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO provider_events (
|
||||||
|
occurred_at, provider, model, success, error_category,
|
||||||
|
fallback, privacy_policy, input_tokens, output_tokens
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
datetime.now(UTC).isoformat(timespec="seconds"),
|
||||||
|
event.provider,
|
||||||
|
event.model,
|
||||||
|
int(event.success),
|
||||||
|
event.error_category,
|
||||||
|
int(event.fallback),
|
||||||
|
event.privacy_policy,
|
||||||
|
event.input_tokens,
|
||||||
|
event.output_tokens,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise UsageStoreError("Nutzungsmetadaten konnten nicht gespeichert werden.") from exc
|
||||||
|
|
||||||
|
def cloud_requests_on(self, day: date | None = None) -> int:
|
||||||
|
return self._count(day or datetime.now(UTC).date(), "provider = 'gemini'")
|
||||||
|
|
||||||
|
def local_fallbacks_on(self, day: date | None = None) -> int:
|
||||||
|
return self._count(
|
||||||
|
day or datetime.now(UTC).date(),
|
||||||
|
"provider = 'ollama' AND fallback = 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _count(self, day: date, condition: str) -> int:
|
||||||
|
start = f"{day.isoformat()}T00:00:00+00:00"
|
||||||
|
end = f"{day.isoformat()}T23:59:59+00:00"
|
||||||
|
try:
|
||||||
|
with closing(self._connect()) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*) FROM provider_events
|
||||||
|
WHERE occurred_at BETWEEN ? AND ? AND {condition}
|
||||||
|
""",
|
||||||
|
(start, end),
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise UsageStoreError("Nutzungsmetadaten konnten nicht gelesen werden.") from exc
|
||||||
|
return int(row[0])
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from javis.providers.base import ChatMessage
|
||||||
|
from javis.security.privacy import CloudPolicy, PrivacyRouter
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.router = PrivacyRouter()
|
||||||
|
|
||||||
|
def test_general_technical_question_is_allowed(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify("Wie funktioniert SQLite in Python?").policy,
|
||||||
|
CloudPolicy.ALLOWED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_personal_information_requires_approval(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify("Ich möchte meinen Alltag besser planen.").policy,
|
||||||
|
CloudPolicy.ASK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_family_information_requires_approval(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify("Meine Frau hat morgen einen Termin.").policy,
|
||||||
|
CloudPolicy.ASK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_secret_pattern_is_never(self) -> None:
|
||||||
|
decision = self.router.classify("Mein API-Key ist AIza" + "x" * 25)
|
||||||
|
self.assertEqual(decision.policy, CloudPolicy.NEVER)
|
||||||
|
|
||||||
|
def test_financial_and_health_information_are_never(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify("Meine IBAN ist ein Geheimnis.").policy,
|
||||||
|
CloudPolicy.NEVER,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify("Meine Diagnose soll ausgewertet werden.").policy,
|
||||||
|
CloudPolicy.NEVER,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_content_is_conservatively_ask(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify("Erkläre das bitte genauer.").policy,
|
||||||
|
CloudPolicy.ASK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_cloud_request_cannot_override_never(self) -> None:
|
||||||
|
decision = self.router.classify(
|
||||||
|
"Sende mein Passwort an die Cloud.",
|
||||||
|
cloud_requested=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(decision.policy, CloudPolicy.NEVER)
|
||||||
|
|
||||||
|
def test_minimal_context_excludes_sensitive_history(self) -> None:
|
||||||
|
messages = [
|
||||||
|
ChatMessage("user", "Meine Diagnose ist privat."),
|
||||||
|
ChatMessage("assistant", "Das bleibt lokal."),
|
||||||
|
ChatMessage("user", "Wie funktioniert SQLite?"),
|
||||||
|
]
|
||||||
|
selected = self.router.minimal_context(
|
||||||
|
messages,
|
||||||
|
current_policy=CloudPolicy.ALLOWED,
|
||||||
|
approved=False,
|
||||||
|
max_chars=1_000,
|
||||||
|
max_messages=6,
|
||||||
|
)
|
||||||
|
self.assertEqual(selected, [ChatMessage("user", "Wie funktioniert SQLite?")])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -34,6 +34,33 @@ class SettingsTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_zero_cost_defaults_are_conservative(self) -> None:
|
||||||
|
settings = Settings.from_env({"JAVIS_DATA_DIR": str(Path.cwd())})
|
||||||
|
|
||||||
|
self.assertTrue(settings.free_only)
|
||||||
|
self.assertFalse(settings.gemini_enabled)
|
||||||
|
self.assertFalse(settings.billing_confirmed_disabled)
|
||||||
|
self.assertEqual(settings.gemini_model, "gemini-3.6-flash")
|
||||||
|
self.assertEqual(settings.provider_mode, "auto")
|
||||||
|
|
||||||
|
def test_paid_mode_is_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
Settings.from_env(
|
||||||
|
{
|
||||||
|
"JAVIS_DATA_DIR": str(Path.cwd()),
|
||||||
|
"JAVIS_FREE_ONLY": "false",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cloud_limits_are_bounded(self) -> None:
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
Settings.from_env(
|
||||||
|
{
|
||||||
|
"JAVIS_DATA_DIR": str(Path.cwd()),
|
||||||
|
"JAVIS_MAX_CLOUD_REQUESTS_PER_DAY": "1001",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore
|
||||||
|
|
||||||
|
|
||||||
|
class UsageStoreTests(unittest.TestCase):
|
||||||
|
def test_records_only_metadata_and_counts_daily_usage(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "usage.sqlite3"
|
||||||
|
store = SQLiteUsageStore(database)
|
||||||
|
store.record(
|
||||||
|
ProviderEvent(
|
||||||
|
provider="gemini",
|
||||||
|
model="gemini-test",
|
||||||
|
success=False,
|
||||||
|
error_category="quota",
|
||||||
|
fallback=False,
|
||||||
|
privacy_policy="allowed",
|
||||||
|
input_tokens=12,
|
||||||
|
output_tokens=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.record(
|
||||||
|
ProviderEvent(
|
||||||
|
provider="ollama",
|
||||||
|
model="local-test",
|
||||||
|
success=True,
|
||||||
|
error_category=None,
|
||||||
|
fallback=True,
|
||||||
|
privacy_policy="allowed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
self.assertEqual(store.cloud_requests_on(today), 1)
|
||||||
|
self.assertEqual(store.local_fallbacks_on(today), 1)
|
||||||
|
raw_database = database.read_bytes()
|
||||||
|
self.assertNotIn(b"prompt", raw_database)
|
||||||
|
self.assertNotIn(b"answer", raw_database)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user