feat: add local chat provider and CLI
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
"""Allow ``python -m javis`` to invoke the CLI."""
|
||||||
|
|
||||||
|
from javis.interface.cli import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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.")
|
||||||
@@ -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)
|
||||||
@@ -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 <ID> 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 <Sitzungs-ID>")
|
||||||
|
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())
|
||||||
@@ -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"],
|
||||||
|
)
|
||||||
@@ -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."""
|
||||||
|
...
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user