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