diff --git a/src/javis/__main__.py b/src/javis/__main__.py new file mode 100644 index 0000000..cd79e2b --- /dev/null +++ b/src/javis/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m javis`` to invoke the CLI.""" + +from javis.interface.cli import main + +raise SystemExit(main()) diff --git a/src/javis/config/settings.py b/src/javis/config/settings.py new file mode 100644 index 0000000..cf31186 --- /dev/null +++ b/src/javis/config/settings.py @@ -0,0 +1,96 @@ +"""Central runtime configuration for the local Javis chat.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + + +class ConfigurationError(ValueError): + """Raised when runtime configuration is unsafe or invalid.""" + + +def default_data_dir( + environ: Mapping[str, str] | None = None, + *, + platform_name: str | None = None, + home: Path | None = None, +) -> Path: + """Return a platform-appropriate user data directory.""" + values = os.environ if environ is None else environ + platform = os.name if platform_name is None else platform_name + user_home = Path.home() if home is None else home + + if platform == "nt": + base = Path(values.get("LOCALAPPDATA", user_home / "AppData" / "Local")) + return base / "Javis" + + xdg_data_home = values.get("XDG_DATA_HOME") + base = Path(xdg_data_home) if xdg_data_home else user_home / ".local" / "share" + return base / "javis" + + +@dataclass(frozen=True, slots=True) +class Settings: + """Validated settings shared by CLI, provider, and storage.""" + + data_dir: Path + provider: str = "ollama" + model: str = "qwen3:8b" + ollama_base_url: str = "http://127.0.0.1:11434" + timeout_seconds: float = 180.0 + + @property + def database_path(self) -> Path: + return self.data_dir / "sessions.sqlite3" + + @classmethod + def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings: + values = os.environ if environ is None else environ + configured_dir = values.get("JAVIS_DATA_DIR") + data_dir = Path(configured_dir).expanduser() if configured_dir else default_data_dir(values) + if not data_dir.is_absolute(): + raise ConfigurationError("JAVIS_DATA_DIR muss ein absoluter Pfad sein.") + + provider = values.get("JAVIS_PROVIDER", "ollama").strip() + model = values.get("JAVIS_MODEL", "qwen3:8b").strip() + base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip() + timeout_raw = values.get("JAVIS_MODEL_TIMEOUT", "180") + + try: + timeout = float(timeout_raw) + except ValueError as exc: + raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss eine Zahl sein.") from exc + + settings = cls( + data_dir=data_dir.resolve(), + provider=provider, + model=model, + ollama_base_url=base_url, + timeout_seconds=timeout, + ) + settings.validate() + return settings + + def validate(self) -> None: + if self.provider != "ollama": + raise ConfigurationError( + "Unbekannter Provider. In dieser Phase wird nur 'ollama' unterstützt." + ) + if not self.model: + raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.") + if self.timeout_seconds <= 0: + raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss größer als 0 sein.") + + parsed = urlparse(self.ollama_base_url) + if parsed.scheme != "http" or parsed.hostname not in { + "127.0.0.1", + "::1", + "localhost", + }: + raise ConfigurationError("JAVIS_OLLAMA_URL muss eine lokale HTTP-Adresse sein.") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ConfigurationError("JAVIS_OLLAMA_URL enthält unzulässige Bestandteile.") diff --git a/src/javis/core/chat_service.py b/src/javis/core/chat_service.py new file mode 100644 index 0000000..094db1b --- /dev/null +++ b/src/javis/core/chat_service.py @@ -0,0 +1,57 @@ +"""Chat orchestration without interface or provider-specific details.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore +from javis.providers.base import ChatMessage, LocalModelProvider + + +class SessionProviderMismatchError(RuntimeError): + """The active provider cannot safely continue the stored session.""" + + +@dataclass(frozen=True, slots=True) +class LoadedSession: + session: ChatSession + messages: list[ChatMessage] + + +class ChatService: + def __init__( + self, + store: SQLiteSessionStore, + provider: LocalModelProvider, + ) -> None: + self.store = store + self.provider = provider + + def new_session(self) -> ChatSession: + return self.store.create_session(self.provider.name, self.provider.model) + + def list_sessions(self) -> list[ChatSession]: + return self.store.list_sessions() + + 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: + raise SessionProviderMismatchError( + "Die Sitzung verwendet " + f"{session.provider}/{session.model}, aktiv ist " + f"{self.provider.name}/{self.provider.model}." + ) + return LoadedSession(session, self.store.get_messages(session_id)) + + def send(self, session_id: str, text: str) -> str: + normalized = text.strip() + if not normalized: + raise ValueError("Eine leere Nachricht wird nicht gesendet.") + loaded = self.load_session(session_id) + messages = [*loaded.messages, ChatMessage("user", normalized)] + response = self.provider.chat(messages) + self.store.append_exchange(session_id, normalized, response) + return response + + def clear_session(self, session_id: str) -> None: + self.store.clear_messages(session_id) diff --git a/src/javis/interface/cli.py b/src/javis/interface/cli.py new file mode 100644 index 0000000..8debf4f --- /dev/null +++ b/src/javis/interface/cli.py @@ -0,0 +1,182 @@ +"""Command-line interface for the first local Javis text chat.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Callable, Sequence + +from javis.config.settings import ConfigurationError, Settings +from javis.core.chat_service import ChatService, SessionProviderMismatchError +from javis.memory.sqlite_store import ( + ChatSession, + SessionNotFoundError, + SessionStoreError, + SQLiteSessionStore, +) +from javis.providers.base import ProviderError +from javis.providers.ollama import OllamaProvider + +InputFunction = Callable[[str], str] +OutputFunction = Callable[[str], None] + +HELP_TEXT = """Befehle: + /new neue Sitzung beginnen + /sessions gespeicherte Sitzungen auflisten + /load frühere Sitzung laden und fortsetzen + /clear Nachrichten der aktiven Sitzung leeren + /help diese Hilfe anzeigen + /exit Chat ordentlich beenden""" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="javis", description="Lokaler Javis-Textchat") + 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") + return parser + + +def _show_history(service: ChatService, session_id: str, output: OutputFunction) -> None: + loaded = service.load_session(session_id) + if not loaded.messages: + output("(Sitzung enthält noch keine Nachrichten.)") + return + for message in loaded.messages: + label = "Du" if message.role == "user" else "Javis" + output(f"{label}: {message.content}") + + +def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None: + if not sessions: + output("Keine gespeicherten Sitzungen vorhanden.") + return + for session in sessions: + output( + f"{session.id} | {session.updated_at} | " + f"{session.provider}/{session.model} | {session.message_count} Nachrichten" + ) + + +def run_chat( + service: ChatService, + *, + session_id: str | None = None, + input_fn: InputFunction = input, + output: OutputFunction = print, +) -> int: + try: + if session_id: + active_id = service.load_session(session_id).session.id + output(f"Sitzung geladen: {active_id}") + _show_history(service, active_id, output) + else: + active_id = service.new_session().id + output(f"Neue Sitzung: {active_id}") + except ( + SessionNotFoundError, + SessionProviderMismatchError, + SessionStoreError, + ) as exc: + output(f"Fehler: {exc}") + return 2 + + output("Lokaler Javis-Chat. /help zeigt die Befehle.") + + while True: + try: + entered = input_fn("Du> ").strip() + except (EOFError, KeyboardInterrupt): + output("\nChat beendet.") + return 0 + + if not entered: + continue + if entered == "/exit": + output("Chat beendet.") + return 0 + if entered == "/help": + output(HELP_TEXT) + continue + if entered == "/new": + try: + active_id = service.new_session().id + output(f"Neue Sitzung: {active_id}") + except SessionStoreError as exc: + output(f"Fehler: {exc}") + continue + if entered == "/sessions": + try: + _show_sessions(service.list_sessions(), output) + except SessionStoreError as exc: + output(f"Fehler: {exc}") + continue + if entered == "/clear": + try: + service.clear_session(active_id) + output("Aktive Sitzung wurde geleert.") + except (SessionNotFoundError, SessionStoreError) as exc: + output(f"Fehler: {exc}") + continue + if entered.startswith("/load"): + parts = entered.split(maxsplit=1) + if len(parts) != 2 or not parts[1].strip(): + output("Verwendung: /load ") + continue + try: + loaded = service.load_session(parts[1].strip()) + active_id = loaded.session.id + output(f"Sitzung geladen: {active_id}") + _show_history(service, active_id, output) + except ( + SessionNotFoundError, + SessionProviderMismatchError, + SessionStoreError, + ) as exc: + output(f"Fehler: {exc}") + continue + if entered.startswith("/"): + output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.") + continue + + try: + response = service.send(active_id, entered) + output(f"Javis: {response}") + except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc: + output(f"Fehler: {exc}") + except ValueError as exc: + output(f"Fehler: {exc}") + + +def main( + argv: Sequence[str] | None = None, + *, + input_fn: InputFunction = input, + output: OutputFunction = print, +) -> int: + args = build_parser().parse_args(argv) + 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, + ) + except (ConfigurationError, SessionStoreError) as exc: + output(f"Konfigurationsfehler: {exc}") + return 2 + + return run_chat( + ChatService(store, provider), + session_id=args.session, + input_fn=input_fn, + output=output, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/javis/memory/sqlite_store.py b/src/javis/memory/sqlite_store.py new file mode 100644 index 0000000..c0bc173 --- /dev/null +++ b/src/javis/memory/sqlite_store.py @@ -0,0 +1,236 @@ +"""SQLite persistence for local chat sessions.""" + +from __future__ import annotations + +import sqlite3 +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from javis.providers.base import ChatMessage + + +class SessionStoreError(RuntimeError): + """Base class for expected persistence failures.""" + + +class SessionNotFoundError(SessionStoreError): + """A requested session ID does not exist.""" + + +@dataclass(frozen=True, slots=True) +class ChatSession: + id: str + created_at: str + updated_at: str + provider: str + model: str + message_count: int = 0 + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="seconds") + + +class SQLiteSessionStore: + def __init__(self, database_path: Path) -> None: + self.database_path = database_path + try: + self.database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError( + f"Die Sitzungsdatenbank kann nicht geöffnet werden: {database_path}" + ) from exc + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.database_path, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + return connection + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + with connection: + yield connection + finally: + connection.close() + + def _initialize(self) -> None: + with self._connection() as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + position INTEGER NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, + UNIQUE (session_id, position) + ); + + CREATE INDEX IF NOT EXISTS idx_messages_session_position + ON messages(session_id, position); + """ + ) + + def create_session(self, provider: str, model: str) -> ChatSession: + session_id = str(uuid.uuid4()) + now = _utc_now() + try: + with self._connection() as connection: + connection.execute( + """ + INSERT INTO sessions (id, created_at, updated_at, provider, model) + VALUES (?, ?, ?, ?, ?) + """, + (session_id, now, now, provider, model), + ) + except sqlite3.Error as exc: + raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc + return ChatSession(session_id, now, now, provider, model) + + def get_session(self, session_id: str) -> ChatSession: + try: + with self._connection() as connection: + row = connection.execute( + """ + SELECT s.*, COUNT(m.id) AS message_count + FROM sessions AS s + LEFT JOIN messages AS m ON m.session_id = s.id + WHERE s.id = ? + GROUP BY s.id + """, + (session_id,), + ).fetchone() + except sqlite3.Error as exc: + raise SessionStoreError("Die Sitzung konnte nicht geladen werden.") from exc + if row is None: + raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.") + return self._session_from_row(row) + + def list_sessions(self) -> list[ChatSession]: + try: + with self._connection() as connection: + rows = connection.execute( + """ + SELECT s.*, COUNT(m.id) AS message_count + FROM sessions AS s + LEFT JOIN messages AS m ON m.session_id = s.id + GROUP BY s.id + ORDER BY s.updated_at DESC, s.created_at DESC + """ + ).fetchall() + except sqlite3.Error as exc: + raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc + return [self._session_from_row(row) for row in rows] + + def get_messages(self, session_id: str) -> list[ChatMessage]: + self.get_session(session_id) + try: + with self._connection() as connection: + rows = connection.execute( + """ + SELECT role, content + FROM messages + WHERE session_id = ? + ORDER BY position ASC + """, + (session_id,), + ).fetchall() + except sqlite3.Error as exc: + raise SessionStoreError("Die Nachrichten konnten nicht geladen werden.") from exc + return [ChatMessage(row["role"], row["content"]) for row in rows] + + def append_exchange( + self, + session_id: str, + user_content: str, + assistant_content: str, + ) -> None: + now = _utc_now() + try: + with self._connection() as connection: + exists = connection.execute( + "SELECT 1 FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + if exists is None: + raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.") + row = connection.execute( + "SELECT COALESCE(MAX(position), -1) + 1 FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + next_position = int(row[0]) + connection.executemany( + """ + INSERT INTO messages + (session_id, position, role, content, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + (session_id, next_position, "user", user_content, now), + ( + session_id, + next_position + 1, + "assistant", + assistant_content, + now, + ), + ), + ) + connection.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", + (now, session_id), + ) + except SessionNotFoundError: + raise + except sqlite3.Error as exc: + raise SessionStoreError("Die Nachrichten konnten nicht gespeichert werden.") from exc + + def clear_messages(self, session_id: str) -> None: + now = _utc_now() + try: + with self._connection() as connection: + cursor = connection.execute( + "DELETE FROM messages WHERE session_id = ?", (session_id,) + ) + exists = connection.execute( + "SELECT 1 FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + if exists is None: + raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.") + if cursor.rowcount: + connection.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", + (now, session_id), + ) + except SessionNotFoundError: + raise + except sqlite3.Error as exc: + raise SessionStoreError("Die Sitzung konnte nicht geleert werden.") from exc + + @staticmethod + def _session_from_row(row: sqlite3.Row) -> ChatSession: + return ChatSession( + id=row["id"], + created_at=row["created_at"], + updated_at=row["updated_at"], + provider=row["provider"], + model=row["model"], + message_count=row["message_count"], + ) diff --git a/src/javis/providers/base.py b/src/javis/providers/base.py new file mode 100644 index 0000000..aa3d7fe --- /dev/null +++ b/src/javis/providers/base.py @@ -0,0 +1,46 @@ +"""Small provider contract used by the chat core.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@dataclass(frozen=True, slots=True) +class ChatMessage: + role: str + content: str + + +class ProviderError(RuntimeError): + """Base class for expected local provider failures.""" + + +class ProviderUnavailableError(ProviderError): + """The local model runtime cannot be reached.""" + + +class ModelNotInstalledError(ProviderError): + """The configured local model is not available.""" + + +class ProviderTimeoutError(ProviderError): + """The local model did not respond in time.""" + + +class ResponseAbortedError(ProviderError): + """The model response was interrupted before completion.""" + + +class InvalidProviderResponseError(ProviderError): + """The local runtime returned an unusable response.""" + + +@runtime_checkable +class LocalModelProvider(Protocol): + name: str + model: str + + def chat(self, messages: list[ChatMessage]) -> str: + """Return one complete assistant response.""" + ... diff --git a/src/javis/providers/ollama.py b/src/javis/providers/ollama.py new file mode 100644 index 0000000..358b46f --- /dev/null +++ b/src/javis/providers/ollama.py @@ -0,0 +1,82 @@ +"""Ollama implementation of the local model provider contract.""" + +from __future__ import annotations + +import json +import socket +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from javis.providers.base import ( + ChatMessage, + InvalidProviderResponseError, + ModelNotInstalledError, + ProviderTimeoutError, + ProviderUnavailableError, + ResponseAbortedError, +) + + +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._timeout_seconds = timeout_seconds + + def chat(self, messages: list[ChatMessage]) -> str: + payload = { + "model": self.model, + "messages": [ + {"role": message.role, "content": message.content} for message in messages + ], + "stream": False, + "think": False, + } + request = Request( + self._endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urlopen(request, timeout=self._timeout_seconds) as response: + raw_response = response.read() + except HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + if exc.code == 404 or "not found" in details.lower(): + raise ModelNotInstalledError( + f"Das lokale Modell '{self.model}' ist nicht installiert." + ) from exc + raise ProviderUnavailableError(f"Ollama meldet HTTP-Fehler {exc.code}.") from exc + except TimeoutError as exc: + raise ProviderTimeoutError( + "Die Modellantwort hat das Zeitlimit überschritten." + ) from exc + except URLError as exc: + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + raise ProviderTimeoutError( + "Die Modellantwort hat das Zeitlimit überschritten." + ) from exc + raise ProviderUnavailableError( + "Ollama ist unter der konfigurierten lokalen Adresse nicht erreichbar." + ) from exc + + try: + result = json.loads(raw_response) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise InvalidProviderResponseError( + "Ollama hat keine gültige JSON-Antwort geliefert." + ) from exc + + if result.get("done") is False: + raise ResponseAbortedError("Die Modellantwort wurde vorzeitig abgebrochen.") + + content = result.get("message", {}).get("content") + if not isinstance(content, str) or not content.strip(): + raise InvalidProviderResponseError( + "Ollama hat keine verwendbare Textantwort geliefert." + ) + return content.strip()