From e57186f678b4bfdf0c69d59fc10d543a46d262b5 Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Thu, 30 Jul 2026 18:00:20 +0200 Subject: [PATCH] feat: add privacy-aware hybrid routing --- docs/CHATGPT_HANDOFF.md | 16 +- docs/PROJECT_STATUS.md | 13 +- src/javis/core/chat_service.py | 8 +- src/javis/core/provider_router.py | 232 +++++++++++++++++++++++ tests/unit/test_chat_service_hybrid.py | 40 ++++ tests/unit/test_provider_router.py | 243 +++++++++++++++++++++++++ 6 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 src/javis/core/provider_router.py create mode 100644 tests/unit/test_chat_service_hybrid.py create mode 100644 tests/unit/test_provider_router.py diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 3c3d290..cbd91c0 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -81,7 +81,11 @@ Rootserver: - Isolierter Gemini-Provider nutzt das offizielle `google-genai` 2.16.0 mit API `v1`, begrenztem Timeout und maximal einem Retry; 429 wird nicht wiederholt. - Fehlender/ungültiger Schlüssel, 429, Netzwerkfehler und ungültige Antworten - werden als getrennte, bereinigte Fehler signalisiert. Noch kein Routing aktiv. + werden als getrennte, bereinigte Fehler signalisiert. +- Hybridrouter erzwingt lokal Datenschutz, Freigabe, Billing-Bestätigung, + Tages-/Größenlimits und sichtbares Ollama-Fallback vor jedem Cloudaufruf. +- Ein abgelehnter Schlüssel deaktiviert Gemini für den restlichen CLI-Prozess; + alte reine Ollama-Sitzungen bleiben mit dem Hybridprovider ladbar. ## Aktuelle Architektur @@ -131,6 +135,8 @@ Rootserver: lokale Tages-/Größenlimits und metadatenbasiertes Provider-Audit ergänzt. - Offiziellen Gemini-SDK-Client isoliert angebunden und ausschließlich mit Test-Doubles gegen Erfolg, Key-, Quota-, Netzwerk- und Antwortfehler geprüft. +- Hybridrouting und Audit mit Test-Doubles integriert: `never` konstruiert keinen + Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück. ## Aktuelle Tests @@ -145,7 +151,7 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 37 bestanden; zusätzlich 7 für den isolierten Gemini-Provider +- Unit-Tests: 51 bestanden; zusätzlich 13 Router- und 1 Kompatibilitätstest - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -231,9 +237,9 @@ Abnahmestatus: Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket: -1. Hybridrouter vor den Chatkern setzen -2. `allowed`/`ask`/`never`, lokale Tagesgrenze und sichtbares Ollama-Fallback - für Key-, 429-, Netzwerk- und Gemini-Ausfälle vollständig mit Doubles testen +1. lokale TOML-Konfiguration und sichere Aktivierungsbestätigung ergänzen +2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status` + mit standardmäßig abgelehnter `ask`-Freigabe implementieren 3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 4846ffa..c0ef173 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -49,7 +49,11 @@ Stand: lokaler Textchat auf `main`, 30.07.2026. begrenztem Timeout, höchstens einem Retry und keinem 429-Retry - bereinigte, getrennte Fehler für fehlenden/ungültigen Key, Quote, Netzwerk, Modellverfügbarkeit, Timeout und ungültige Antwort -- 37 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber +- Hybridrouter erzwingt vor Cloudaufrufen lokal `allowed`/`ask`/`never`, + Aktivierung, Billing-Bestätigung, Tages-/Größenlimit und minimierten Kontext +- sichtbares lokales Fallback für fehlenden/ungültigen Key, 429, Netzwerk, + Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar +- 51 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber ## Nicht implementiert @@ -74,7 +78,6 @@ Living-Mind-Frontend und automatische Ollama-Prozessverwaltung. Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden 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. -Der Gemini-Netzwerkprovider ist isoliert implementiert, aber noch nicht in den -Chatkern geroutet. Das automatische Ollama-Fallback fehlt noch. Es wurde kein -echter API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage -ausgeführt. +Der Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet. +Lokale TOML-Aktivierung, Statusbefehle und Startskript fehlen. Es wurde kein echter +API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt. diff --git a/src/javis/core/chat_service.py b/src/javis/core/chat_service.py index 094db1b..5e790e2 100644 --- a/src/javis/core/chat_service.py +++ b/src/javis/core/chat_service.py @@ -35,7 +35,13 @@ class ChatService: def load_session(self, session_id: str) -> LoadedSession: session = self.store.get_session(session_id) - if session.provider != self.provider.name or session.model != self.provider.model: + supports_session = getattr(self.provider, "supports_session", None) + compatible = ( + supports_session(session.provider, session.model) + if callable(supports_session) + else session.provider == self.provider.name and session.model == self.provider.model + ) + if not compatible: raise SessionProviderMismatchError( "Die Sitzung verwendet " f"{session.provider}/{session.model}, aktiv ist " diff --git a/src/javis/core/provider_router.py b/src/javis/core/provider_router.py new file mode 100644 index 0000000..b4ad77e --- /dev/null +++ b/src/javis/core/provider_router.py @@ -0,0 +1,232 @@ +"""Local privacy and zero-cost routing between Gemini and Ollama.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError +from javis.providers.base import ( + ChatMessage, + InvalidApiKeyError, + LocalModelProvider, + MissingApiKeyError, + ProviderError, + ProviderUsage, +) +from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter + +ApprovalCallback = Callable[[PrivacyDecision], bool] +NoticeCallback = Callable[[str], None] +CloudProviderFactory = Callable[[], LocalModelProvider] + + +@dataclass(frozen=True, slots=True) +class RouteStatus: + provider: str + privacy_policy: CloudPolicy + fallback: bool + reason: str + + +class HybridProvider: + """Provider contract that enforces privacy before any cloud construction.""" + + name = "hybrid" + model = "auto" + + def __init__( + self, + *, + local_provider: LocalModelProvider, + cloud_provider_factory: CloudProviderFactory, + cloud_model: str, + privacy_router: PrivacyRouter, + usage_store: SQLiteUsageStore, + approval_callback: ApprovalCallback, + notice_callback: NoticeCallback, + mode: str, + cloud_enabled: bool, + billing_confirmed_disabled: bool, + free_only: bool, + max_cloud_requests_per_day: int, + max_cloud_input_chars: int, + max_cloud_context_messages: int, + ) -> None: + if mode not in {"auto", "local", "gemini"}: + raise ValueError("Unbekannter Provider-Modus.") + if not free_only: + raise ValueError("Der Hybridprovider unterstützt ausschließlich den Nullkostenmodus.") + + self.local_provider = local_provider + self._cloud_provider_factory = cloud_provider_factory + self._cloud_model = cloud_model + self._privacy_router = privacy_router + self._usage_store = usage_store + self._approval_callback = approval_callback + self._notice_callback = notice_callback + self.mode = mode + self._cloud_enabled = cloud_enabled + self._billing_confirmed_disabled = billing_confirmed_disabled + self._max_cloud_requests_per_day = max_cloud_requests_per_day + self._max_cloud_input_chars = max_cloud_input_chars + self._max_cloud_context_messages = max_cloud_context_messages + self._cloud_session_disabled = False + self.last_route: RouteStatus | None = None + + def supports_session(self, provider: str, model: str) -> bool: + return (provider, model) in { + (self.name, self.model), + (self.local_provider.name, self.local_provider.model), + } + + def set_mode(self, mode: str) -> None: + if mode not in {"auto", "local", "gemini"}: + raise ValueError("Provider-Modus muss auto, local oder gemini sein.") + self.mode = mode + + def chat(self, messages: list[ChatMessage]) -> str: + current_text = messages[-1].content if messages else "" + cloud_requested = self.mode == "gemini" + decision = self._privacy_router.classify( + current_text, + cloud_requested=cloud_requested, + ) + + if self.mode == "local": + return self._local(messages, decision, "lokaler Modus", fallback=False) + if decision.policy is CloudPolicy.NEVER: + return self._local(messages, decision, decision.reason, fallback=True) + + approved = decision.policy is CloudPolicy.ALLOWED + if decision.policy is CloudPolicy.ASK: + approved = self._approval_callback(decision) + if not approved: + return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True) + + unavailable_reason = self._cloud_unavailable_reason() + if unavailable_reason: + return self._local(messages, decision, unavailable_reason, fallback=True) + if len(current_text) > self._max_cloud_input_chars: + return self._local( + messages, + decision, + "Nachricht überschreitet das lokale Cloud-Größenlimit", + fallback=True, + ) + + cloud_messages = self._privacy_router.minimal_context( + messages, + current_policy=decision.policy, + approved=approved, + max_chars=self._max_cloud_input_chars, + max_messages=self._max_cloud_context_messages, + ) + if not cloud_messages: + return self._local(messages, decision, "kein freigegebener Cloudkontext", fallback=True) + + try: + cloud_provider = self._cloud_provider_factory() + response = cloud_provider.chat(cloud_messages) + except InvalidApiKeyError as exc: + self._cloud_session_disabled = True + return self._cloud_failure(messages, decision, exc, disable_notice=True) + except (MissingApiKeyError, ProviderError) as exc: + return self._cloud_failure(messages, decision, exc) + + usage = getattr(cloud_provider, "last_usage", ProviderUsage()) + self._record( + ProviderEvent( + provider=cloud_provider.name, + model=cloud_provider.model, + success=True, + error_category=None, + fallback=False, + privacy_policy=decision.policy.value, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + ) + ) + self.last_route = RouteStatus( + cloud_provider.name, + decision.policy, + False, + "Cloudaufruf erfolgreich", + ) + return response + + def _cloud_unavailable_reason(self) -> str | None: + if not self._cloud_enabled: + return "Gemini ist lokal nicht aktiviert" + if not self._billing_confirmed_disabled: + return "fehlende lokale Bestätigung, dass Billing deaktiviert ist" + if self._cloud_session_disabled: + return "Gemini ist nach einem Schlüssel-Fehler für diese Sitzung deaktiviert" + try: + cloud_requests_today = self._usage_store.cloud_requests_on() + except UsageStoreError: + return "lokale Cloudlimit-Prüfung nicht verfügbar" + if cloud_requests_today >= self._max_cloud_requests_per_day: + return "lokales tägliches Cloudlimit erreicht" + return None + + def _cloud_failure( + self, + messages: list[ChatMessage], + decision: PrivacyDecision, + error: ProviderError, + *, + disable_notice: bool = False, + ) -> str: + error_category = type(error).__name__ + self._record( + ProviderEvent( + provider="gemini", + model=self._cloud_model, + success=False, + error_category=error_category, + fallback=False, + privacy_policy=decision.policy.value, + ) + ) + reason = "Gemini nicht verfügbar" + if disable_notice: + reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert" + return self._local(messages, decision, reason, fallback=True) + + def _local( + self, + messages: list[ChatMessage], + decision: PrivacyDecision, + reason: str, + *, + fallback: bool, + ) -> str: + if fallback: + self._notice_callback(f"{reason} – lokale Antwort mit Ollama.") + response = self.local_provider.chat(messages) + self._record( + ProviderEvent( + provider=self.local_provider.name, + model=self.local_provider.model, + success=True, + error_category=None, + fallback=fallback, + privacy_policy=decision.policy.value, + ) + ) + self.last_route = RouteStatus( + self.local_provider.name, + decision.policy, + fallback, + reason, + ) + return response + + def _record(self, event: ProviderEvent) -> None: + try: + self._usage_store.record(event) + except UsageStoreError: + self._notice_callback( + "Warnung: Provider-Nutzungsmetadaten konnten nicht gespeichert werden." + ) diff --git a/tests/unit/test_chat_service_hybrid.py b/tests/unit/test_chat_service_hybrid.py new file mode 100644 index 0000000..bb961db --- /dev/null +++ b/tests/unit/test_chat_service_hybrid.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from javis.core.chat_service import ChatService +from javis.memory.sqlite_store import SQLiteSessionStore +from javis.providers.base import ChatMessage + + +class _CompatibleHybrid: + name = "hybrid" + model = "auto" + + def chat(self, messages: list[ChatMessage]) -> str: + return "Antwort" + + def supports_session(self, provider: str, model: str) -> bool: + return (provider, model) in { + ("hybrid", "auto"), + ("ollama", "qwen3:8b"), + } + + +class HybridChatServiceTests(unittest.TestCase): + def test_old_local_session_remains_loadable_after_hybrid_upgrade(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + store = SQLiteSessionStore(Path(temporary_directory) / "sessions.sqlite3") + old_session = store.create_session("ollama", "qwen3:8b") + store.append_exchange(old_session.id, "Alt", "Verlauf") + service = ChatService(store, _CompatibleHybrid()) + + loaded = service.load_session(old_session.id) + + self.assertEqual([message.content for message in loaded.messages], ["Alt", "Verlauf"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_provider_router.py b/tests/unit/test_provider_router.py new file mode 100644 index 0000000..fba8d47 --- /dev/null +++ b/tests/unit/test_provider_router.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from javis.core.provider_router import HybridProvider +from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore +from javis.providers.base import ( + ChatMessage, + CloudNetworkError, + InvalidApiKeyError, + LocalModelProvider, + MissingApiKeyError, + ProviderRateLimitError, + ProviderUsage, +) +from javis.security.privacy import CloudPolicy, PrivacyRouter + + +class _RecordingProvider: + def __init__( + self, + name: str, + model: str, + *, + answer: str = "Antwort", + error: Exception | None = None, + ) -> None: + self.name = name + self.model = model + self.answer = answer + self.error = error + self.calls: list[list[ChatMessage]] = [] + self.last_usage = ProviderUsage(11, 5) + + def chat(self, messages: list[ChatMessage]) -> str: + self.calls.append(messages) + if self.error: + raise self.error + return self.answer + + +class HybridProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.usage_store = SQLiteUsageStore( + Path(self.temporary_directory.name) / "provider-usage.sqlite3" + ) + self.local = _RecordingProvider("ollama", "qwen3:8b", answer="Lokal") + self.cloud = _RecordingProvider( + "gemini", + "gemini-3.6-flash", + answer="Cloud", + ) + self.notices: list[str] = [] + self.approvals: list[object] = [] + self.approval_result = False + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def _router(self, **overrides: object) -> HybridProvider: + arguments: dict[str, object] = { + "local_provider": self.local, + "cloud_provider_factory": lambda: self.cloud, + "cloud_model": "gemini-3.6-flash", + "privacy_router": PrivacyRouter(), + "usage_store": self.usage_store, + "approval_callback": self._approve, + "notice_callback": self.notices.append, + "mode": "auto", + "cloud_enabled": True, + "billing_confirmed_disabled": True, + "free_only": True, + "max_cloud_requests_per_day": 25, + "max_cloud_input_chars": 12_000, + "max_cloud_context_messages": 6, + } + arguments.update(overrides) + return HybridProvider(**arguments) + + def _approve(self, decision: object) -> bool: + self.approvals.append(decision) + return self.approval_result + + def test_contract_and_existing_local_session_are_supported(self) -> None: + router = self._router() + + self.assertIsInstance(router, LocalModelProvider) + self.assertTrue(router.supports_session("hybrid", "auto")) + self.assertTrue(router.supports_session("ollama", "qwen3:8b")) + self.assertFalse(router.supports_session("ollama", "other-model")) + + def test_allowed_technical_content_uses_cloud_with_minimal_context(self) -> None: + router = self._router() + + answer = router.chat( + [ + ChatMessage("user", "Meine IBAN ist vertraulich"), + ChatMessage("assistant", "Verstanden"), + ChatMessage("user", "Wie funktioniert SQLite?"), + ] + ) + + self.assertEqual(answer, "Cloud") + self.assertEqual(len(self.cloud.calls), 1) + self.assertEqual( + [message.content for message in self.cloud.calls[0]], + ["Wie funktioniert SQLite?"], + ) + self.assertEqual(router.last_route.provider, "gemini") + self.assertEqual(router.last_route.privacy_policy, CloudPolicy.ALLOWED) + self.assertFalse(router.last_route.fallback) + + def test_never_policy_cannot_construct_cloud_provider(self) -> None: + constructed = False + + def cloud_factory() -> _RecordingProvider: + nonlocal constructed + constructed = True + return self.cloud + + router = self._router(cloud_provider_factory=cloud_factory, mode="gemini") + answer = router.chat([ChatMessage("user", "Mein API-Key ist geheim")]) + + self.assertEqual(answer, "Lokal") + self.assertFalse(constructed) + self.assertTrue(router.last_route.fallback) + self.assertIn("lokale Antwort", self.notices[-1]) + + def test_ask_defaults_to_local_when_approval_is_denied(self) -> None: + router = self._router() + + answer = router.chat([ChatMessage("user", "Meine Familie plant Urlaub")]) + + self.assertEqual(answer, "Lokal") + self.assertEqual(len(self.approvals), 1) + self.assertFalse(self.cloud.calls) + + def test_ask_uses_cloud_after_explicit_approval(self) -> None: + self.approval_result = True + router = self._router() + + answer = router.chat([ChatMessage("user", "Meine Familie plant Urlaub")]) + + self.assertEqual(answer, "Cloud") + self.assertEqual(len(self.cloud.calls), 1) + + def test_429_falls_back_without_losing_local_answer(self) -> None: + self.cloud.error = ProviderRateLimitError("quota") + router = self._router() + + answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + + self.assertEqual(answer, "Lokal") + self.assertEqual(self.usage_store.cloud_requests_on(), 1) + self.assertEqual(self.usage_store.local_fallbacks_on(), 1) + self.assertIn("Gemini nicht verfügbar", self.notices[-1]) + + def test_network_failure_falls_back(self) -> None: + self.cloud.error = CloudNetworkError("offline") + router = self._router() + + answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + + self.assertEqual(answer, "Lokal") + self.assertTrue(router.last_route.fallback) + + def test_missing_key_falls_back(self) -> None: + def missing_key() -> _RecordingProvider: + raise MissingApiKeyError("missing") + + router = self._router(cloud_provider_factory=missing_key) + + answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + + self.assertEqual(answer, "Lokal") + self.assertTrue(router.last_route.fallback) + + def test_invalid_key_disables_cloud_for_remaining_process(self) -> None: + self.cloud.error = InvalidApiKeyError("invalid") + router = self._router() + + router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + self.cloud.error = None + router.chat([ChatMessage("user", "Wie funktioniert SQLite?")]) + + self.assertEqual(len(self.cloud.calls), 1) + self.assertEqual(len(self.local.calls), 2) + self.assertIn("deaktiviert", self.notices[-1]) + + def test_local_daily_limit_stops_cloud_before_construction(self) -> None: + self.usage_store.record( + ProviderEvent( + "gemini", + "gemini-3.6-flash", + True, + None, + False, + "allowed", + ) + ) + router = self._router(max_cloud_requests_per_day=1) + + answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + + self.assertEqual(answer, "Lokal") + self.assertFalse(self.cloud.calls) + self.assertIn("tägliches Cloudlimit", self.notices[-1]) + + def test_cloud_requires_local_activation_and_no_billing_confirmation(self) -> None: + for override in ( + {"cloud_enabled": False}, + {"billing_confirmed_disabled": False}, + ): + with self.subTest(override=override): + router = self._router(**override) + answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + self.assertEqual(answer, "Lokal") + + self.assertFalse(self.cloud.calls) + + def test_oversized_current_message_remains_local(self) -> None: + router = self._router(max_cloud_input_chars=10) + + answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")]) + + self.assertEqual(answer, "Lokal") + self.assertFalse(self.cloud.calls) + + def test_local_mode_never_asks_or_calls_cloud(self) -> None: + router = self._router(mode="local") + + answer = router.chat([ChatMessage("user", "Meine Familie plant Urlaub")]) + + self.assertEqual(answer, "Lokal") + self.assertFalse(self.approvals) + self.assertFalse(self.cloud.calls) + + +if __name__ == "__main__": + unittest.main()