From af9426edc49e63c8ee2ac0cc70aa2d26ec1eb652 Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Thu, 30 Jul 2026 18:06:46 +0200 Subject: [PATCH] feat: wire hybrid provider into CLI --- docs/CHATGPT_HANDOFF.md | 16 ++- docs/PROJECT_STATUS.md | 11 +- src/javis/interface/cli.py | 188 +++++++++++++++++++++++++++-- src/javis/providers/ollama.py | 29 ++++- tests/unit/test_cli.py | 117 +++++++++++++++++- tests/unit/test_ollama_provider.py | 11 ++ 6 files changed, 354 insertions(+), 18 deletions(-) diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 5586d91..1b7b43a 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -90,6 +90,11 @@ Rootserver: `JAVIS_DATA_DIR\javis.toml` geladen; Umgebungsvariablen können sie sicher überschreiben. Die Aktivierungsvorlage enthält `free_only = true` und die lokale Billing-Bestätigung, aber niemals einen API-Key. +- `javis configure gemini` verlangt zwei ausdrückliche Bestätigungen und lässt + Gemini bei leerer oder negativer Antwort deaktiviert. +- Der echte CLI-Chat verwendet nun `hybrid/auto`; der Key wird erst vor einem + zulässigen Cloudaufruf gelesen. `/provider`, `/privacy` und `/status` zeigen + ausschließlich bereinigte Modi, Entscheidungen und Zähler. ## Aktuelle Architektur @@ -143,6 +148,9 @@ Rootserver: Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück. - Lokalen TOML-Loader und sicheren Erzeuger ergänzt; vorhandene Konfigurationen werden nicht ungefragt überschrieben und fehlerhafte TOML wird abgelehnt. +- Hybridprovider in die CLI verdrahtet, sicheren Aktivierungsdialog und + bereinigte Provider-/Datenschutz-/Statusbefehle ergänzt. +- Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste. ## Aktuelle Tests @@ -157,7 +165,8 @@ 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: 55 bestanden; zusätzlich 4 lokale Konfigurationstests +- Unit-Tests: 59 bestanden; CLI-Befehle, doppelte Bestätigung, verzögertes + Key-Laden und Ollama-Status sind abgedeckt - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -243,9 +252,8 @@ Abnahmestatus: Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket: -1. sichere Aktivierungsbestätigung als CLI-Einrichtungsschritt ergänzen -2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status` - mit standardmäßig abgelehnter `ask`-Freigabe implementieren +1. sicheres `scripts/start-javis.ps1` ohne Systemänderungen ergänzen +2. Start- und Einrichtungsablauf kompakt dokumentieren und automatisiert prüfen 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 008fe3f..3d9a8d7 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -55,7 +55,10 @@ Stand: lokaler Textchat auf `main`, 30.07.2026. Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar - lokale nicht geheime TOML-Konfiguration außerhalb von Git; konservativer Erzeuger überschreibt keine vorhandene Datei -- 55 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber +- `javis configure gemini` mit doppelter ausdrücklicher Bestätigung +- echter Chat auf Hybridprovider verdrahtet; Key wird verzögert geladen +- `/provider`, `/privacy` und `/status` geben keine Inhalte oder Keywerte aus +- 59 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber ## Nicht implementiert @@ -80,6 +83,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 Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet. -CLI-Aktivierungsdialog, Statusbefehle und Startskript fehlen. Es wurde kein echter -API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt. +Das sichere Startskript und die abschließende Bedienungsdokumentation fehlen noch. +Es wurde kein echter API-Key angefordert, angezeigt oder konfiguriert und keine +Cloudanfrage ausgeführt. diff --git a/src/javis/interface/cli.py b/src/javis/interface/cli.py index 9e30e90..4165620 100644 --- a/src/javis/interface/cli.py +++ b/src/javis/interface/cli.py @@ -6,27 +6,38 @@ import argparse import getpass import sys from collections.abc import Callable, Sequence +from pathlib import Path +from javis.config.local_config import LocalConfigError, write_gemini_activation from javis.config.settings import ConfigurationError, Settings from javis.core.chat_service import ChatService, SessionProviderMismatchError +from javis.core.provider_router import HybridProvider from javis.memory.sqlite_store import ( ChatSession, SessionNotFoundError, SessionStoreError, SQLiteSessionStore, ) -from javis.providers.base import ProviderError +from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError +from javis.providers.base import MissingApiKeyError, ProviderError +from javis.providers.gemini import GeminiProvider from javis.providers.ollama import OllamaProvider +from javis.security.privacy import PrivacyDecision, PrivacyRouter from javis.security.secrets import SecretProvider, SecretStoreError InputFunction = Callable[[str], str] OutputFunction = Callable[[str], None] +StatusFunction = Callable[[], list[str]] HELP_TEXT = """Befehle: /new neue Sitzung beginnen /sessions gespeicherte Sitzungen auflisten /load frühere Sitzung laden und fortsetzen /clear Nachrichten der aktiven Sitzung leeren + /provider aktiven Providermodus anzeigen + /provider Modus auto, local oder gemini setzen + /privacy letzte lokale Datenschutzentscheidung anzeigen + /status bereinigten Providerstatus anzeigen /help diese Hilfe anzeigen /exit Chat ordentlich beenden""" @@ -36,6 +47,11 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten") chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden") + configure_parser = subparsers.add_parser( + "configure", + help="nicht geheime lokale Einstellungen einrichten", + ) + configure_parser.add_argument("provider_name", choices=["gemini"]) secrets_parser = subparsers.add_parser("secrets", help="lokale Geheimwerte sicher verwalten") secret_subparsers = secrets_parser.add_subparsers(dest="secret_action", required=True) set_parser = secret_subparsers.add_parser("set", help="Geheimwert verdeckt setzen") @@ -46,6 +62,34 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _confirmed(answer: str) -> bool: + return answer.strip().lower() in {"j", "ja", "y", "yes"} + + +def run_gemini_configuration( + config_path: Path, + *, + input_fn: InputFunction = input, + output: OutputFunction = print, +) -> int: + if not _confirmed(input_fn("Gemini Free Tier lokal aktivieren? [j/N] ")): + output("Gemini bleibt deaktiviert.") + return 1 + if not _confirmed( + input_fn("Bestätigst du, dass für dieses Google-Projekt kein Billing aktiviert ist? [j/N] ") + ): + output("Gemini bleibt deaktiviert; keine Konfiguration wurde geschrieben.") + return 1 + try: + write_gemini_activation(config_path) + except LocalConfigError as exc: + output(f"Konfigurationsfehler: {exc}") + return 2 + output(f"Gemini-Free-Konfiguration gespeichert: {config_path}") + output("Nächster Schritt: javis secrets set gemini") + return 0 + + def run_secrets( action: str, *, @@ -106,6 +150,7 @@ def run_chat( session_id: str | None = None, input_fn: InputFunction = input, output: OutputFunction = print, + status_fn: StatusFunction | None = None, ) -> int: try: if session_id: @@ -160,6 +205,40 @@ def run_chat( except (SessionNotFoundError, SessionStoreError) as exc: output(f"Fehler: {exc}") continue + if entered.startswith("/provider"): + parts = entered.split() + provider = service.provider + if len(parts) == 1: + output(f"Aktiver Providermodus: {getattr(provider, 'mode', 'local')}") + continue + if len(parts) != 2 or parts[1] not in {"auto", "local", "gemini"}: + output("Verwendung: /provider auto|local|gemini") + continue + set_mode = getattr(provider, "set_mode", None) + if not callable(set_mode): + output("Dieser Chat unterstützt keinen wechselbaren Providermodus.") + continue + set_mode(parts[1]) + output(f"Providermodus: {parts[1]}") + continue + if entered == "/privacy": + route = getattr(service.provider, "last_route", None) + if route is None: + output("Noch keine Datenschutzentscheidung in dieser Sitzung.") + else: + output( + "Letzte Datenschutzentscheidung: " + f"{route.privacy_policy.value}; Provider: {route.provider}; " + f"Fallback: {'ja' if route.fallback else 'nein'}" + ) + continue + if entered == "/status": + if status_fn is None: + output("Keine erweiterte Statusprüfung verfügbar.") + else: + for line in status_fn(): + output(line) + continue if entered.startswith("/load"): parts = entered.split(maxsplit=1) if len(parts) != 2 or not parts[1].strip(): @@ -190,6 +269,88 @@ def run_chat( output(f"Fehler: {exc}") +def build_runtime( + settings: Settings, + *, + input_fn: InputFunction, + output: OutputFunction, + secret_provider: SecretProvider | None = None, +) -> tuple[ChatService, StatusFunction]: + store = SQLiteSessionStore(settings.database_path) + usage_store = SQLiteUsageStore(settings.usage_database_path) + secrets = secret_provider or SecretProvider() + local = OllamaProvider( + settings.model, + settings.ollama_base_url, + settings.timeout_seconds, + ) + + def approve(decision: PrivacyDecision) -> bool: + answer = input_fn( + f"Cloudfreigabe erforderlich ({decision.reason}). Einmalig erlauben? [j/N] " + ) + return _confirmed(answer) + + def create_cloud_provider() -> GeminiProvider: + try: + key = secrets.get_gemini_key() + except SecretStoreError as exc: + raise MissingApiKeyError( + "Der Gemini-Schlüssel konnte nicht sicher geladen werden." + ) from exc + return GeminiProvider( + model=settings.gemini_model, + api_key=key, + timeout_seconds=settings.gemini_timeout_seconds, + max_output_tokens=settings.max_cloud_output_tokens, + max_retries=settings.gemini_max_retries, + ) + + hybrid = HybridProvider( + local_provider=local, + cloud_provider_factory=create_cloud_provider, + cloud_model=settings.gemini_model, + privacy_router=PrivacyRouter(), + usage_store=usage_store, + approval_callback=approve, + notice_callback=output, + mode=settings.provider_mode, + cloud_enabled=settings.gemini_enabled, + billing_confirmed_disabled=settings.billing_confirmed_disabled, + free_only=settings.free_only, + max_cloud_requests_per_day=settings.max_cloud_requests_per_day, + max_cloud_input_chars=settings.max_cloud_input_chars, + max_cloud_context_messages=settings.max_cloud_context_messages, + ) + + def status() -> list[str]: + ollama = local.probe() + try: + key_available = secrets.gemini_key_available() + except SecretStoreError: + key_available = False + gemini_configured = ( + settings.gemini_enabled and settings.free_only and settings.billing_confirmed_disabled + ) + try: + cloud_requests = usage_store.cloud_requests_on() + local_fallbacks = usage_store.local_fallbacks_on() + except UsageStoreError: + cloud_requests = local_fallbacks = -1 + return [ + f"Ollama erreichbar: {'ja' if ollama.reachable else 'nein'}", + f"Lokales Modell vorhanden: {'ja' if ollama.model_available else 'nein'}", + f"Gemini-Schlüssel vorhanden: {'ja' if key_available else 'nein'}", + f"Gemini konfiguriert: {'ja' if gemini_configured else 'nein'}", + f"Nullkostenmodus: {'aktiv' if settings.free_only else 'inaktiv'}", + f"Aktiver Providermodus: {hybrid.mode}", + f"Heutige Cloudanfragen: {cloud_requests if cloud_requests >= 0 else 'unbekannt'}", + f"Heutige lokale Fallbacks: {local_fallbacks if local_fallbacks >= 0 else 'unbekannt'}", + ] + + return ChatService(store, hybrid), status + + def main( argv: Sequence[str] | None = None, *, @@ -203,26 +364,37 @@ def main( password_fn=input_fn if input_fn is not input else getpass.getpass, output=output, ) + if args.command == "configure": + try: + settings = Settings.from_env() + except ConfigurationError as exc: + output(f"Konfigurationsfehler: {exc}") + return 2 + return run_gemini_configuration( + settings.config_path, + input_fn=input_fn, + output=output, + ) if args.command != "chat": return 2 try: settings = Settings.from_env() - store = SQLiteSessionStore(settings.database_path) - provider = OllamaProvider( - settings.model, - settings.ollama_base_url, - settings.timeout_seconds, + service, status_fn = build_runtime( + settings, + input_fn=input_fn, + output=output, ) - except (ConfigurationError, SessionStoreError) as exc: + except (ConfigurationError, SessionStoreError, UsageStoreError) as exc: output(f"Konfigurationsfehler: {exc}") return 2 return run_chat( - ChatService(store, provider), + service, session_id=args.session, input_fn=input_fn, output=output, + status_fn=status_fn, ) diff --git a/src/javis/providers/ollama.py b/src/javis/providers/ollama.py index 358b46f..474ae86 100644 --- a/src/javis/providers/ollama.py +++ b/src/javis/providers/ollama.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import socket +from dataclasses import dataclass from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -17,14 +18,40 @@ from javis.providers.base import ( ) +@dataclass(frozen=True, slots=True) +class OllamaStatus: + reachable: bool + model_available: bool + + class OllamaProvider: name = "ollama" def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None: self.model = model - self._endpoint = f"{base_url.rstrip('/')}/api/chat" + self._base_url = base_url.rstrip("/") + self._endpoint = f"{self._base_url}/api/chat" self._timeout_seconds = timeout_seconds + def probe(self) -> OllamaStatus: + request = Request(f"{self._base_url}/api/tags", method="GET") + try: + with urlopen(request, timeout=min(self._timeout_seconds, 3)) as response: + result = json.loads(response.read()) + except (HTTPError, URLError, TimeoutError, OSError, json.JSONDecodeError): + return OllamaStatus(False, False) + models = result.get("models") + if not isinstance(models, list): + return OllamaStatus(True, False) + names = { + value + for item in models + if isinstance(item, dict) + for value in (item.get("name"), item.get("model")) + if isinstance(value, str) + } + return OllamaStatus(True, self.model in names) + def chat(self, messages: list[ChatMessage]) -> str: payload = { "model": self.model, diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index dc65f97..62a4fe4 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,11 +1,15 @@ import tempfile import unittest from pathlib import Path +from types import SimpleNamespace +from javis.config.settings import Settings from javis.core.chat_service import ChatService -from javis.interface.cli import run_chat +from javis.core.provider_router import HybridProvider +from javis.interface.cli import build_runtime, run_chat, run_gemini_configuration from javis.memory.sqlite_store import SQLiteSessionStore from javis.providers.base import ChatMessage +from javis.security.privacy import CloudPolicy class _FakeProvider: @@ -16,6 +20,30 @@ class _FakeProvider: return f"Echo: {messages[-1].content}" +class _FakeHybridProvider(_FakeProvider): + name = "hybrid" + model = "auto" + + def __init__(self) -> None: + self.mode = "auto" + self.last_route = None + + def set_mode(self, mode: str) -> None: + self.mode = mode + + +class _LazySecrets: + def __init__(self) -> None: + self.reads = 0 + + def get_gemini_key(self) -> str | None: + self.reads += 1 + return None + + def gemini_key_available(self) -> bool: + return False + + class CliTests(unittest.TestCase): def test_basic_chat_commands(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -35,6 +63,93 @@ class CliTests(unittest.TestCase): self.assertTrue(any("Nachrichten" in line for line in output)) self.assertEqual(output[-1], "Chat beendet.") + def test_provider_privacy_and_status_commands_are_sanitized(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3") + provider = _FakeHybridProvider() + provider.last_route = SimpleNamespace( + privacy_policy=CloudPolicy.ASK, + provider="ollama", + fallback=True, + ) + service = ChatService(store, provider) + inputs = iter( + [ + "/provider", + "/provider local", + "/privacy", + "/status", + "/exit", + ] + ) + output: list[str] = [] + + result = run_chat( + service, + input_fn=lambda _prompt: next(inputs), + output=output.append, + status_fn=lambda: [ + "Gemini-Schlüssel vorhanden: ja", + "Heutige Cloudanfragen: 0", + ], + ) + + self.assertEqual(result, 0) + self.assertIn("Aktiver Providermodus: auto", output) + self.assertIn("Providermodus: local", output) + self.assertTrue(any("ask; Provider: ollama; Fallback: ja" in line for line in output)) + self.assertIn("Gemini-Schlüssel vorhanden: ja", output) + + def test_gemini_configuration_defaults_to_no(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "javis.toml" + output: list[str] = [] + + result = run_gemini_configuration( + path, + input_fn=lambda _prompt: "", + output=output.append, + ) + + self.assertEqual(result, 1) + self.assertFalse(path.exists()) + self.assertIn("deaktiviert", output[-1]) + + def test_gemini_configuration_requires_both_confirmations(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "javis.toml" + answers = iter(["ja", "ja"]) + + result = run_gemini_configuration( + path, + input_fn=lambda _prompt: next(answers), + output=lambda _line: None, + ) + + self.assertEqual(result, 0) + self.assertTrue(path.exists()) + content = path.read_text(encoding="utf-8") + self.assertIn("billing_confirmed_disabled = true", content) + self.assertNotIn("api_key", content.lower()) + + def test_runtime_wires_hybrid_provider_without_loading_key_at_start(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = Settings(data_dir=Path(directory)) + secrets = _LazySecrets() + + service, _status = build_runtime( + settings, + input_fn=lambda _prompt: "", + output=lambda _line: None, + secret_provider=secrets, + ) + session = service.new_session() + + self.assertIsInstance(service.provider, HybridProvider) + self.assertEqual(session.provider, "hybrid") + self.assertEqual(session.model, "auto") + self.assertEqual(secrets.reads, 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_ollama_provider.py b/tests/unit/test_ollama_provider.py index 71c6170..46b6d09 100644 --- a/tests/unit/test_ollama_provider.py +++ b/tests/unit/test_ollama_provider.py @@ -26,6 +26,14 @@ class _OllamaHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def do_GET(self) -> None: + body = json.dumps({"models": [{"name": "test-model"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, format: str, *args: object) -> None: return @@ -42,6 +50,7 @@ class OllamaProviderTests(unittest.TestCase): 2, ) response = provider.chat([ChatMessage("user", "Hallo")]) + status = provider.probe() finally: server.shutdown() server.server_close() @@ -50,6 +59,8 @@ class OllamaProviderTests(unittest.TestCase): self.assertEqual(response, "Lokale Antwort") self.assertEqual(_OllamaHandler.request_payload["model"], "test-model") self.assertFalse(_OllamaHandler.request_payload["stream"]) + self.assertTrue(status.reachable) + self.assertTrue(status.model_available) if __name__ == "__main__":