Compare commits
5 Commits
e6eb99556a
...
5d567e5301
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d567e5301 | |||
| b153daa989 | |||
| ec9b2f94ad | |||
| 10d7cd7662 | |||
| fa90a5c5de |
+6
-4
@@ -1,4 +1,6 @@
|
||||
# Non-secret process overrides only.
|
||||
JAVIS_DISPLAY_NAME=Javis
|
||||
JAVIS_CONFIG_PATH=config/javis.toml
|
||||
JAVIS_SECRETS_PROVIDER=none
|
||||
# Optionale, nicht geheime Prozesswerte. Diese Datei wird nicht automatisch geladen.
|
||||
JAVIS_DATA_DIR=D:\Javis-Data\runtime
|
||||
JAVIS_PROVIDER=ollama
|
||||
JAVIS_MODEL=qwen3:8b
|
||||
JAVIS_OLLAMA_URL=http://127.0.0.1:11434
|
||||
JAVIS_MODEL_TIMEOUT=180
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -12,6 +12,12 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
|
||||
- Eng begrenzte projektinterne Codex-Agentenrollen.
|
||||
- Abhängigkeitsfreier Strukturprüfer.
|
||||
- Phasenbezogene Empfehlung für Entwicklungswerkzeuge, VS-Code-Erweiterungen, Obsidian und lokale Modelllaufzeiten.
|
||||
- Dokumentierte, portable Ollama-Laufzeit 0.32.5 und genau ein lokales Modell
|
||||
`qwen3:8b` außerhalb des Repositories.
|
||||
- Lokaler Ollama-Provider und ausführbarer `javis chat`-Einstiegspunkt.
|
||||
- SQLite-Sitzungen mit geordneter Nachrichtenhistorie außerhalb des Repositories.
|
||||
- CLI-Befehle für neue, gelistete, geladene, geleerte und beendete Sitzungen.
|
||||
- Unit-Tests für Konfiguration, Speicher, Chatkern, Provider und CLI.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -19,6 +25,10 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
|
||||
- Tokenbewusste Arbeitsweise als dauerhafte Regel ergänzt; Qualität und Sicherheit bleiben vorrangig.
|
||||
- Laufende Handoff-Aktualisierung nach jedem abgeschlossenen logischen Arbeitspaket festgelegt.
|
||||
- Vollständigen Erst-Handoff einmalig archiviert und die laufende Übergabe auf einen kompakten aktuellen Stand umgestellt.
|
||||
- Isolierte Python-3.12-Entwicklungsumgebung mit uv-Lockfile und Ruff vorbereitet.
|
||||
- Offizielle VS-Code-Erweiterungen für Python, Pylance und Python Environments eingerichtet.
|
||||
- `qwen3:8b` mit echten deutschen Antworten und GPU-/Zeitmessungen auf der RTX 3060 geprüft.
|
||||
- Projektstatus, Architektur und Datenmodell auf den funktionierenden Textchat aktualisiert.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -4,16 +4,33 @@ Javis ist ein langfristiges, lokal-first ausgerichtetes Projekt für einen pers
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
Dieses Repository enthält ausschließlich das sichere Projektgrundgerüst und die Architektur-Dokumentation der Phase 0. Es gibt noch:
|
||||
Der erste lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`.
|
||||
Er bietet echte lokale Modellantworten ohne Cloud-Fallback, neue und fortsetzbare
|
||||
Sitzungen sowie SQLite-Persistenz außerhalb des Repositories.
|
||||
|
||||
- keinen KI-Chat,
|
||||
- keine Modellanbindung,
|
||||
- keinen Obsidian-Schreibzugriff,
|
||||
- keinen Serverzugriff,
|
||||
- keine Sprachpipeline,
|
||||
- keine Living-Mind-Implementierung.
|
||||
Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile
|
||||
Client und Living Mind.
|
||||
|
||||
Der nächste Entwicklungsschritt beginnt erst nach Pascals Prüfung auf einem eigenen Feature-Branch.
|
||||
## Lokaler Start
|
||||
|
||||
Ollama läuft in einem eigenen Terminal ausschließlich auf localhost:
|
||||
|
||||
```powershell
|
||||
$env:OLLAMA_MODELS = 'D:\Javis-Data\ollama-models'
|
||||
$env:OLLAMA_HOST = '127.0.0.1:11434'
|
||||
& 'D:\Javis-Tools\ollama\ollama.exe' serve
|
||||
```
|
||||
|
||||
Der Chat startet aus dem Repository in einem zweiten Terminal:
|
||||
|
||||
```powershell
|
||||
$env:UV_CACHE_DIR = 'D:\Javis-Tools\uv-cache'
|
||||
$env:JAVIS_DATA_DIR = 'D:\Javis-Data\runtime'
|
||||
& 'D:\Javis-Tools\uv\uv.exe' run javis chat
|
||||
```
|
||||
|
||||
Im Chat zeigt `/help` alle Befehle. Ohne `JAVIS_DATA_DIR` nutzt Javis einen
|
||||
sicheren benutzerspezifischen Plattformstandard.
|
||||
|
||||
## Grundregeln
|
||||
|
||||
@@ -29,9 +46,11 @@ Der nächste Entwicklungsschritt beginnt erst nach Pascals Prüfung auf einem ei
|
||||
- [Projektstatus](docs/PROJECT_STATUS.md)
|
||||
- [Roadmap](docs/ROADMAP.md)
|
||||
- [Architektur](docs/ARCHITECTURE.md)
|
||||
- [Lokales Modell](docs/LOCAL_MODEL.md)
|
||||
- [Entwicklungsumgebung](docs/DEVELOPMENT_SETUP.md)
|
||||
- [Sicherheit](docs/SECURITY.md)
|
||||
- [Nächste Sitzung](docs/NEXT_SESSION.md)
|
||||
- [Vollständiger Handoff](docs/CHATGPT_HANDOFF.md)
|
||||
- [Kompakter Handoff](docs/CHATGPT_HANDOFF.md)
|
||||
|
||||
## Historischer Verbindungstest
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
# Entwurf für eine spätere TOML-Konfiguration.
|
||||
# Der erste Chat liest die in .env.example genannten Umgebungsvariablen.
|
||||
|
||||
[app]
|
||||
display_name = "Javis"
|
||||
language = "de"
|
||||
|
||||
[paths]
|
||||
runtime_dir = "runtime"
|
||||
data_dir = ""
|
||||
|
||||
[providers]
|
||||
model = "disabled"
|
||||
active = "ollama"
|
||||
model = "qwen3:8b"
|
||||
ollama_base_url = "http://127.0.0.1:11434"
|
||||
timeout_seconds = 180
|
||||
cloud_fallback = false
|
||||
|
||||
[obsidian]
|
||||
|
||||
+14
-8
@@ -4,12 +4,12 @@
|
||||
|
||||
Javis beginnt als modularer Monolith. Module besitzen klare Zuständigkeiten und kommunizieren über kleine, typisierte Schnittstellen. Erst reale Betriebsgründe rechtfertigen später separate Dienste.
|
||||
|
||||
## Vorgesehene Ebenen
|
||||
## Ebenen
|
||||
|
||||
1. Interface: CLI, später Sprache, Mobilzugriff und Living Mind.
|
||||
2. Core: Gesprächsablauf, Sitzungszustand und Orchestrierung.
|
||||
3. Providers: austauschbare lokale und optionale Cloud-Modellanbindungen.
|
||||
4. Memory: strukturierte Laufzeitdaten und regenerierbarer semantischer Index.
|
||||
1. Interface: implementierte CLI; später möglicherweise Sprache, Mobilzugriff und Living Mind.
|
||||
2. Core: implementierter Gesprächsablauf und Sitzungslogik.
|
||||
3. Providers: kleine Schnittstelle und implementierter lokaler Ollama-Adapter.
|
||||
4. Memory: implementierte SQLite-Sitzungen; ein semantischer Index ist nur geplant.
|
||||
5. Obsidian: zunächst lesender Adapter, später eng begrenzte Entwürfe.
|
||||
6. Tools und Status: feste Schemas, Allowlists, Timeouts und bereinigte Ausgaben.
|
||||
7. Security: Freigaben, Secret-Provider und Berechtigungsprüfung.
|
||||
@@ -17,11 +17,17 @@ Javis beginnt als modularer Monolith. Module besitzen klare Zuständigkeiten und
|
||||
|
||||
## Plattformgrenzen
|
||||
|
||||
- Pfade werden später per Konfiguration und `pathlib` aufgelöst.
|
||||
- Pfade werden zentral in `Settings` und mit `pathlib` aufgelöst.
|
||||
- Betriebssystemspezifische Funktionen liegen hinter Adaptern.
|
||||
- Produktname, Datenpfade und Provider werden zentral konfiguriert.
|
||||
- Laufzeitdaten liegen außerhalb von Git.
|
||||
|
||||
## Noch nicht implementiert
|
||||
## Implementierter Chatfluss
|
||||
|
||||
Es existieren nur Paketgrenzen und Dokumentation. Es gibt keinen Chat, Provider, Speicher, Toolaufruf oder Netzwerkdienst.
|
||||
`interface.cli` nimmt Text an, `core.chat_service` verbindet Sitzungsverlauf und
|
||||
Provider, `providers.ollama` spricht nur die lokale Ollama-HTTP-API an und
|
||||
`memory.sqlite_store` speichert erfolgreiche Benutzer-/Assistentenpaare atomar.
|
||||
Scheitert das Modell, wird keine erfundene Antwort gespeichert.
|
||||
|
||||
Es existiert kein Cloudadapter, Toolaufruf, Obsidian-Zugriff oder eigener
|
||||
öffentlicher Netzwerkdienst. Der Ollama-Endpunkt muss eine Loopback-Adresse sein.
|
||||
|
||||
+67
-20
@@ -45,21 +45,32 @@ Rootserver:
|
||||
|
||||
## Aktueller technischer Stand
|
||||
|
||||
- Projektgrundgerüst ist auf `main` vorhanden und nach Gitea gepusht.
|
||||
- Paketstruktur unter `src/javis` ist angelegt, enthält aber noch keinen Chatkern.
|
||||
- Der erste lokale Textchat ist auf `feat/core-chat` implementiert und abgenommen.
|
||||
- `uv run javis chat` liefert echte lokale Antworten und speichert Sitzungen in SQLite.
|
||||
- Konfigurationsbeispiel, Sicherheitsdokumente, ADRs und Strukturtest sind vorhanden.
|
||||
- Living-Mind-Verzeichnis ist ausschließlich ein inaktiver Vertrag ohne Frontend oder Fantasiedaten.
|
||||
- Projektbezogene Codex-Rollen Scout, Architect, Builder, Verifier und Security Reviewer sind konfiguriert; maximal zwei Unteragenten, nur Builder schreibend.
|
||||
- Im normalen Windows-`PATH` sind Git und VS Code verfügbar. Eine Projektinstallation von Python, `uv`, Ruff, Ollama oder llama.cpp ist noch nicht bestätigt.
|
||||
- Toolchain eingerichtet: uv 0.11.32 und CPython 3.12.13 isoliert unter `D:\Javis-Tools`; Ruff 0.16.0 in der lokalen `.venv`.
|
||||
- Offizielle VS-Code-Erweiterungen installiert: Python 2026.4.0, Pylance 2026.3.1, Python Environments 1.36.0 und Debugpy 2026.6.0.
|
||||
- Windows-PATH und vorhandene Python-Installationen wurden nicht verändert.
|
||||
- Ollama 0.32.5 ist portabel unter `D:\Javis-Tools\ollama` installiert.
|
||||
- Genau ein Modell ist installiert: `qwen3:8b`, GGUF `Q4_K_M`, 5,2 GB, Apache-2.0.
|
||||
- Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`.
|
||||
- Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry,
|
||||
Autostart und Windows-Dienste blieben unverändert.
|
||||
- CLI-Befehle: `/new`, `/sessions`, `/load <ID>`, `/clear`, `/help`, `/exit`.
|
||||
- Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte
|
||||
Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`.
|
||||
- Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert.
|
||||
- Keine KI-, Sprach-, Mobil- oder Statusfunktion ist implementiert.
|
||||
- Keine Tool-, Sprach-, Mobil-, Status- oder Living-Mind-Funktion ist implementiert.
|
||||
|
||||
## Aktuelle Architektur
|
||||
|
||||
- modularer Monolith, bevorzugt Python
|
||||
- plattformunabhängige Pfade und zentrale Konfiguration
|
||||
- Modell-Provider als kleine austauschbare Schnittstelle
|
||||
- SQLite für ersten Sitzungsspeicher
|
||||
- Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen
|
||||
- SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren
|
||||
- Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard
|
||||
- lokale Modelle zuerst; keine kostenpflichtige API und kein stiller Cloud-Fallback
|
||||
- Secret-Werte außerhalb von Code, Git, Prompts und Logs
|
||||
@@ -77,11 +88,23 @@ Rootserver:
|
||||
- Tokenbewusster Arbeitsablauf festgelegt: gezielte Reads, kurze Ergebnisse, keine unnötigen Agenten oder Plugins.
|
||||
- Werkzeugempfehlung auf `uv`, Microsoft Python/Pylance/Python Environments und Ruff begrenzt.
|
||||
- Langer Handoff einmalig unverändert archiviert und durch diese kompakte aktuelle Zusammenfassung ersetzt.
|
||||
- Handoff-Verdichtung als Commit `e6eb995` nach `origin/main` gepusht.
|
||||
- Branch `feat/core-chat` von diesem Stand erstellt.
|
||||
- uv, CPython 3.12, lokale `.venv`, Ruff, Lockfile und offizielle VS-Code-Python-Erweiterungen eingerichtet.
|
||||
- Ollama und llama.cpp anhand offizieller Quellen verglichen; Ollama als einfachere
|
||||
erste Windows-Laufzeit gewählt.
|
||||
- Ollama-Archiv gegen die offizielle SHA-256-Prüfsumme verifiziert und portabel installiert.
|
||||
- `qwen3:8b` außerhalb des Repositories geladen und mit zwei echten deutschen
|
||||
Modellantworten auf der RTX 3060 geprüft.
|
||||
- Chatkern, Ollama-Provider, zentrale Konfiguration, SQLite-Speicher und CLI implementiert.
|
||||
- 13 Unit-Tests für Konfiguration, Speicher, Verlauf, Providervertrag und CLI ergänzt.
|
||||
- Echten CLI-Chat ausgeführt und dieselbe Sitzung in einem neuen Prozess geladen,
|
||||
aufgelistet und erfolgreich mit einer weiteren Modellantwort fortgesetzt.
|
||||
- Obsidian, Ubuntu-Laptop und Rootserver blieben unverändert.
|
||||
|
||||
## Aktuelle Tests
|
||||
|
||||
Letzter bestätigter Grundgerüststand:
|
||||
Letzter bestätigter Projektstand:
|
||||
|
||||
- Strukturprüfung: bestanden
|
||||
- Unit-Test des Paketgerüsts: bestanden
|
||||
@@ -89,15 +112,40 @@ Letzter bestätigter Grundgerüststand:
|
||||
- UTF-8- und interne Markdown-Linkprüfung: bestanden
|
||||
- Secret-Musterprüfung: keine Treffer
|
||||
- Gitignore-Prüfung für lokale Secret-Dateien: bestanden
|
||||
- uv-Lock und `uv sync --dev`: bestanden
|
||||
- Python in `.venv`: 3.12.13
|
||||
- Ruff in `.venv`: 0.16.0
|
||||
- Unit-Tests: 13 bestanden
|
||||
- Ruff Lint: bestanden
|
||||
- Ruff Formatprüfung: bestanden
|
||||
|
||||
Für den Textchat existieren noch keine Funktions- oder Modell-Smoke-Tests.
|
||||
Lokaler Modell-Smoke-Test:
|
||||
|
||||
- Ollama-Version/API: 0.32.5, localhost erreichbar
|
||||
- Modell: 5.225.388.164 Byte, 8,2B, Q4_K_M, Apache-2.0
|
||||
- kalter Aufruf: 36,19 s Wandzeit, davon 17,53 s Laden, ungefähr 7,22 Token/s
|
||||
- warmer kurzer Aufruf: 0,56 s Wandzeit, ungefähr 55,51 Token/s
|
||||
- GPU-Belegung: 1.418 MiB vorher, 6.842 MiB mit geladenem Modell
|
||||
- Ollama meldet 100 % GPU und 5,6 GB Modellbelegung
|
||||
|
||||
CLI-/Persistenz-Smoke:
|
||||
|
||||
- neuer CLI-Prozess erzeugte eine Sitzung und erhielt eine echte Modellantwort
|
||||
- zweiter CLI-Prozess lud dieselbe UUID und zeigte zwei gespeicherte Nachrichten
|
||||
- `/sessions` listete Provider, Modell und Nachrichtenanzahl korrekt
|
||||
- fortgesetzte Frage erhielt die echte lokale Antwort `4`
|
||||
|
||||
## Git-Stand
|
||||
|
||||
- aktueller Branch: `feat/core-chat`
|
||||
- stabiler Branch: `main`
|
||||
- Grundgerüstcommit: `6d04171`
|
||||
- letzter Commit vor der Handoff-Verdichtung: `0e82748`
|
||||
- der Commit mit dieser Verdichtung ist über `git log -1 --oneline` zu bestimmen
|
||||
- Handoff-Verdichtung auf `main`: `e6eb995`
|
||||
- Python-Entwicklungsumgebung auf dem Feature-Branch: `fa90a5c`
|
||||
- Laufzeit-/Modelldokumentation: `10d7cd7`
|
||||
- Chatkern und CLI: `ec9b2f9`
|
||||
- Unit-Tests: `b153daa`
|
||||
- `origin` verwendet HTTPS
|
||||
- kein Force-Push und keine umgeschriebene Historie
|
||||
|
||||
@@ -106,13 +154,16 @@ Für den Textchat existieren noch keine Funktions- oder Modell-Smoke-Tests.
|
||||
- `49a1dd3`: erster Gitea-Verbindungstest
|
||||
- `6d04171`: sicheres Javis-Grundgerüst
|
||||
- `0e82748`: Werkzeug-, Token- und Handoff-Regeln
|
||||
- aktuell: einmalige Archivierung und Verdichtung des Handoffs
|
||||
- `e6eb995`: einmalige Archivierung und Verdichtung des Handoffs
|
||||
- aktuell: isolierte Python-Toolchain und reproduzierbare Entwicklungsumgebung
|
||||
- aktuell: portable Ollama-Laufzeit und `qwen3:8b` außerhalb von Git verifiziert
|
||||
- aktuell: lokaler CLI-Chat und persistente SQLite-Sitzungen vollständig abgenommen
|
||||
|
||||
## Offene Entscheidungen und Fehler
|
||||
|
||||
- endgültiger Produkt-/Repositoryname bleibt offen
|
||||
- konkrete Python-Version und Installation sind noch festzulegen
|
||||
- lokale Modelllaufzeit und erstes Modell sind noch nicht installiert oder benchmarked
|
||||
- Startmechanismus für Ollama ist noch bewusst manuell und nur pro Prozess konfiguriert
|
||||
- Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche
|
||||
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben
|
||||
- endgültiger Secret-Provider ist offen; lokale Klartext-XML wäre nur restriktiv geschützt und Git-ignoriert zulässig
|
||||
- Gaming-PC zeigt derzeit nur 8 statt 16 logische CPU-Prozessoren; Ursache ungeklärt
|
||||
@@ -130,14 +181,10 @@ Für den Textchat existieren noch keine Funktions- oder Modell-Smoke-Tests.
|
||||
|
||||
## Nächster sinnvoller Auftrag
|
||||
|
||||
Der genehmigte nächste Meilenstein ist `feat/core-chat`:
|
||||
|
||||
1. von sauberem `main` einen Feature-Branch erstellen
|
||||
2. minimale Python-Toolchain mit `uv`, lokaler `.venv`, Ruff und offiziellen VS-Code-Erweiterungen einrichten
|
||||
3. Ollama und llama.cpp anhand aktueller offizieller Quellen vergleichen und nur eine Laufzeit installieren
|
||||
4. genau ein frei lokal nutzbares Modell unter ungefähr 10 GB herunterladen und auf der RTX 3060 messen
|
||||
5. echten lokalen CLI-Chat mit Provider-Schnittstelle und SQLite-Sitzungen implementieren
|
||||
6. Unit-, Ruff-, Persistenz-, Secret- und echten Modell-Smoke-Test ausführen
|
||||
7. nur bei vollständig erfüllter Abnahme nach `main` mergen
|
||||
Nach Merge von `feat/core-chat` den Chat gemeinsam im Alltag benutzen und zuerst
|
||||
konkrete Bedienungs- oder Stabilitätsprobleme sammeln. Mögliche kleine Folgepakete
|
||||
sind ein klarer Start-/Stoppablauf, Streaming, Sitzungstitel oder eine explizite
|
||||
lokale TOML-Konfiguration. Das spätere Laptop-Modell wird erst auf dessen Hardware
|
||||
gemessen und separat entschieden.
|
||||
|
||||
Nicht Teil dieses Meilensteins: Obsidian-Integration, Serverzugriff, Sprache, Mobile Client oder Living-Mind-Code.
|
||||
|
||||
+15
-10
@@ -1,18 +1,23 @@
|
||||
# Datenmodell – Entwurf
|
||||
# Datenmodell
|
||||
|
||||
## Laufzeitdaten
|
||||
|
||||
Eine spätere strukturierte Datenbank kann mindestens enthalten:
|
||||
Der erste Chat verwendet SQLite mit zwei Tabellen:
|
||||
|
||||
- `sessions`: Beginn, Ende und aktiver Kontext
|
||||
- `messages`: Rolle, Inhalt, Zeit und Providerreferenz
|
||||
- `tasks`: Status, Projekt, Fälligkeit und Quelle
|
||||
- `approvals`: angefragte Aktion, Entscheidung und Zeitpunkt
|
||||
- `tool_calls`: Toolname, bereinigte Parameter, Ergebnisstatus und Dauer
|
||||
- `memories`: Aussage, Quelle, Gültigkeit und Bestätigung
|
||||
- `source_documents`: Pfad, Änderungszeit und Indexstatus
|
||||
- `sessions`: UUID, Erstellungs-/Änderungszeit, Provider und Modell
|
||||
- `messages`: Sitzungsbezug, feste Reihenfolge, Rolle, Inhalt und Zeitstempel
|
||||
|
||||
SQLite ist der bevorzugte erste Kandidat, aber noch nicht implementiert.
|
||||
Nachrichtenrollen sind auf `user` und `assistant` begrenzt. Ein Benutzer-/
|
||||
Assistentenpaar wird erst nach erfolgreicher Modellantwort in einer Transaktion
|
||||
gespeichert. Fremdschlüssel und eine eindeutige Nachrichtenposition sichern die
|
||||
Reihenfolge.
|
||||
|
||||
Der Pfad ist über `JAVIS_DATA_DIR` konfigurierbar. Ohne Override wird unter Windows
|
||||
`%LOCALAPPDATA%\Javis` und unter Linux `$XDG_DATA_HOME/javis` beziehungsweise
|
||||
`~/.local/share/javis` verwendet. Die Datenbank liegt nie im Repository.
|
||||
|
||||
Aufgaben, Freigaben, Toolaufrufe, Erinnerungen und Quelldokumente sind noch nicht
|
||||
implementiert und werden erst bei einem konkreten Arbeitspaket modelliert.
|
||||
|
||||
## Obsidian
|
||||
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
| ADR-0003 | Obsidian-Links benötigen echte inhaltliche Beziehungen | angenommen |
|
||||
| ADR-0004 | Tokenbewusst arbeiten, ohne Qualität oder Sicherheit zu senken | angenommen |
|
||||
| ADR-0005 | Laufenden Handoff kompakt halten und Vollstände nur zu Meilensteinen archivieren | angenommen |
|
||||
| ADR-0006 | Ollama und qwen3:8b für den ersten Windows-Chat verwenden | angenommen |
|
||||
|
||||
Offen bleiben insbesondere die konkrete lokale Modelllaufzeit, das endgültige Runtime-Secret-System, der genaue Obsidian-Schreibbereich und der Produktname.
|
||||
Offen bleiben insbesondere das endgültige Runtime-Secret-System, der genaue
|
||||
Obsidian-Schreibbereich, das spätere Laptop-Modell und der Produktname.
|
||||
|
||||
Ausführliche Begründungen stehen unter [`docs/adr`](adr/README.md).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Entwicklungsumgebung
|
||||
|
||||
Stand: 30.07.2026, Branch `feat/core-chat`.
|
||||
|
||||
## Installierte Werkzeuge
|
||||
|
||||
| Werkzeug | Version | Speicherort |
|
||||
|---|---:|---|
|
||||
| uv | 0.11.32 | `D:\Javis-Tools\uv` |
|
||||
| CPython | 3.12.13 | `D:\Javis-Tools\python` |
|
||||
| Ruff | 0.16.0 | lokale Projektumgebung `.venv` |
|
||||
| VS Code Python | 2026.4.0 | Benutzerprofil von VS Code |
|
||||
| VS Code Pylance | 2026.3.1 | Benutzerprofil von VS Code |
|
||||
| VS Code Python Environments | 1.36.0 | Benutzerprofil von VS Code |
|
||||
| VS Code Debugpy | 2026.6.0 | Abhängigkeit der Python-Erweiterung |
|
||||
|
||||
`uv` und Python wurden isoliert auf `D:` installiert. Eine vorhandene Python-Version wurde nicht ersetzt und der Windows-Benutzer- oder System-`PATH` wurde nicht verändert.
|
||||
|
||||
## Projektumgebung
|
||||
|
||||
- gewünschte Python-Linie: `3.12`, festgehalten in `.python-version`
|
||||
- lokale Umgebung: `.venv`, durch Git ignoriert
|
||||
- Abhängigkeiten: `pyproject.toml`
|
||||
- reproduzierbare Auflösung: `uv.lock`
|
||||
- einzige Entwicklungsabhängigkeit in dieser Phase: Ruff
|
||||
|
||||
Typische Befehle aus dem Repository:
|
||||
|
||||
```powershell
|
||||
$env:UV_PYTHON_INSTALL_DIR = 'D:\Javis-Tools\python'
|
||||
$env:UV_CACHE_DIR = 'D:\Javis-Tools\uv-cache'
|
||||
& 'D:\Javis-Tools\uv\uv.exe' sync --dev
|
||||
& 'D:\Javis-Tools\uv\uv.exe' run python -m unittest discover -s tests/unit -v
|
||||
& 'D:\Javis-Tools\uv\uv.exe' run ruff check .
|
||||
& 'D:\Javis-Tools\uv\uv.exe' run ruff format --check .
|
||||
```
|
||||
|
||||
Vor dem Chat wird Ollama in einem eigenen Terminal gestartet:
|
||||
|
||||
```powershell
|
||||
$env:OLLAMA_MODELS = 'D:\Javis-Data\ollama-models'
|
||||
$env:OLLAMA_HOST = '127.0.0.1:11434'
|
||||
& 'D:\Javis-Tools\ollama\ollama.exe' serve
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
```powershell
|
||||
$env:JAVIS_DATA_DIR = 'D:\Javis-Data\runtime'
|
||||
& 'D:\Javis-Tools\uv\uv.exe' run javis chat
|
||||
```
|
||||
|
||||
Die absoluten Pfade sind ausschließlich lokale Entwicklungsdokumentation. Der Anwendungscode darf keine festen Windows-Pfade enthalten.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Lokales Modell
|
||||
|
||||
Stand: 30.07.2026, erster Windows-Test auf dem Gaming-PC.
|
||||
|
||||
## Auswahl
|
||||
|
||||
- Laufzeit: Ollama `0.32.5`, portable Windows-Version
|
||||
- Modell: `qwen3:8b`
|
||||
- Format: GGUF
|
||||
- Quantisierung: `Q4_K_M`
|
||||
- Parameter: 8,2 Milliarden
|
||||
- Kontext laut Modellmetadaten: 40.960 Token; Ollama-Testkontext: 4.096
|
||||
- Downloadgröße laut lokaler Ollama-API: 5.225.388.164 Byte, ungefähr 5,2 GB
|
||||
- Lizenz: Apache License 2.0
|
||||
|
||||
Ollama wurde gegenüber llama.cpp für diesen ersten Windows-Test gewählt, weil die
|
||||
offizielle portable Laufzeit ohne Build, Installer, PATH-Änderung oder Systemdienst
|
||||
eine stabile lokale HTTP-Schnittstelle bereitstellt. llama.cpp bleibt eine mögliche
|
||||
spätere Alternative, falls auf schwächerer Hardware feinere Kontrolle über Kontext,
|
||||
Offloading oder Quantisierung benötigt wird.
|
||||
|
||||
`qwen3:8b` wurde gewählt, weil die Variante unter 10 GB bleibt, vollständig in den
|
||||
12-GB-VRAM der RTX 3060 passt, deutschsprachige Antworten liefert und nach dem
|
||||
Download offline über localhost nutzbar ist.
|
||||
|
||||
## Speicherorte
|
||||
|
||||
- portable Laufzeit: `D:\Javis-Tools\ollama`
|
||||
- Modellablage: `D:\Javis-Data\ollama-models`
|
||||
- Repository: enthält weder Laufzeit noch Modell
|
||||
|
||||
Die Ablage wird beim Start des Dienstes über `OLLAMA_MODELS` nur für diesen Prozess
|
||||
gesetzt. PATH, Registry, Autostart und Windows-Dienste wurden nicht verändert.
|
||||
|
||||
## Gemessener Test
|
||||
|
||||
Hardware: AMD Ryzen 7 9700X, 64 GB RAM, Nvidia RTX 3060 12 GB, Windows 11.
|
||||
|
||||
- Laufzeit-Archiv: 1.457.824.795 Byte
|
||||
- verifizierte SHA-256:
|
||||
`7c941ae084569d298062d29f8139163a3187c76dbca0479c70d085e78fd8c7bb`
|
||||
- kalter deutscher Zweitsatz-Test: 36,19 Sekunden Wandzeit
|
||||
- davon Modell-Ladezeit: 17,53 Sekunden
|
||||
- Ausgabe: 49 Token mit ungefähr 7,22 Token/Sekunde
|
||||
- warmer kurzer Test: 0,56 Sekunden Wandzeit
|
||||
- Ausgabe: 19 Token mit ungefähr 55,51 Token/Sekunde
|
||||
- GPU-Grundbelegung vor Laden: 1.418 MiB
|
||||
- GPU-Belegung bei geladenem Modell/Test: 6.842 MiB
|
||||
- beobachtete zusätzliche VRAM-Belegung: ungefähr 5.424 MiB
|
||||
- `ollama ps`: 5,6 GB, 100 % GPU, Kontext 4.096
|
||||
|
||||
Die Messwerte sind Momentaufnahmen und hängen von Prompt, Kontext, bereits belegtem
|
||||
VRAM und Warmzustand ab.
|
||||
|
||||
## Grenze
|
||||
|
||||
Dieses Modell ist der bestätigte erste Gaming-PC-Test, nicht automatisch das
|
||||
endgültige Modell für den ASUS-Laptop mit GTX 1050 4 GB. Dort müssen später
|
||||
kleineres Modell, stärkere Quantisierung, teilweises CPU-Offloading und tatsächliche
|
||||
Antwortqualität separat gemessen werden. Ein Laptop-Deployment gehört nicht zu
|
||||
diesem Arbeitspaket.
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## Ziel
|
||||
|
||||
Phase 1 auf `feat/core-chat`: minimale Python-Toolchain, genau eine lokale Modelllaufzeit und ein echter lokaler CLI-Textchat mit SQLite-Sitzungen.
|
||||
Nach Abschluss und Merge von `feat/core-chat`: den ersten Textchat gemeinsam
|
||||
benutzen und nur konkrete Bedienungs- oder Stabilitätsprobleme priorisieren.
|
||||
|
||||
## Vor Änderungen erneut prüfen
|
||||
|
||||
@@ -13,12 +14,11 @@ Phase 1 auf `feat/core-chat`: minimale Python-Toolchain, genau eine lokale Model
|
||||
|
||||
## Vorgeschlagener Umfang
|
||||
|
||||
- `uv`, unterstützte stabile Python-Version und offizielle VS-Code-Erweiterungen einrichten
|
||||
- Microsoft Python/Pylance/Python Environments sowie Ruff als minimale Entwicklungswerkzeuge prüfen
|
||||
- Ollama und llama.cpp offiziell vergleichen; nur eine Laufzeit und ein Modell unter ungefähr 10 GB installieren
|
||||
- lokale Konfiguration, Provider-Schnittstelle und SQLite-Sitzungen implementieren
|
||||
- CLI mit neuen, gelisteten und fortsetzbaren Sitzungen
|
||||
- Unit-, Ruff-, Persistenz-, Secret- und echter Modell-Smoke-Test
|
||||
- Start-/Stoppablauf von Ollama auf Alltagstauglichkeit prüfen
|
||||
- reale mehrteilige Gespräche und verständliche Fehlermeldungen beobachten
|
||||
- bei Bedarf Streaming, Sitzungstitel oder eine explizite lokale TOML-Konfiguration
|
||||
als eigenes kleines Feature planen
|
||||
- erst nach Messung auf der Zielhardware ein kleineres Laptop-Modell auswählen
|
||||
|
||||
## Nicht beginnen
|
||||
|
||||
|
||||
+24
-5
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: Dokumentationsverdichtung vor `feat/core-chat`, 30.07.2026.
|
||||
Stand: lokaler Textchat auf `feat/core-chat`, 30.07.2026.
|
||||
|
||||
## Erreicht
|
||||
|
||||
@@ -12,17 +12,36 @@ Stand: Dokumentationsverdichtung vor `feat/core-chat`, 30.07.2026.
|
||||
- projektinterne Codex-Agentensyntax anhand der aktuellen offiziellen Codex-Dokumentation geprüft
|
||||
- phasenbezogene Werkzeug- und Plugin-Empfehlungen sowie tokenbewusste Arbeitsregeln dokumentiert
|
||||
- vollständigen Erst-Handoff archiviert und kompakte laufende Übergabe eingeführt
|
||||
- uv 0.11.32 und CPython 3.12.13 isoliert unter `D:\Javis-Tools` installiert
|
||||
- lokale `.venv`, reproduzierbares `uv.lock` und Ruff 0.16.0 eingerichtet
|
||||
- offizielle VS-Code-Erweiterungen Python, Pylance und Python Environments installiert
|
||||
- Ollama 0.32.5 portabel und ohne Systemänderungen unter `D:\Javis-Tools` installiert
|
||||
- `qwen3:8b` (Q4_K_M, 5,2 GB, Apache-2.0) unter `D:\Javis-Data` geladen
|
||||
- zwei echte deutsche Modellantworten erfolgreich vollständig auf der RTX 3060 erzeugt
|
||||
- kalter Test 36,19 s, warmer Kurztest 0,56 s; geladene GPU-Belegung 6.842 MiB
|
||||
- lokaler Ollama-Provider, zentrale Konfiguration und SQLite-Sitzungsspeicher implementiert
|
||||
- CLI mit `/new`, `/sessions`, `/load`, `/clear`, `/help` und `/exit` implementiert
|
||||
- echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem
|
||||
neuen Prozess bestätigt
|
||||
- 13 Unit-Tests, Ruff und Strukturprüfung erfolgreich
|
||||
|
||||
## Nicht implementiert
|
||||
|
||||
KI, Chat, Provider, Gedächtnis, Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client und Living-Mind-Frontend.
|
||||
Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client,
|
||||
Living-Mind-Frontend und automatische Ollama-Prozessverwaltung.
|
||||
|
||||
## Git
|
||||
|
||||
- Branch: `main`
|
||||
- Branch: `feat/core-chat`
|
||||
- Grundgerüstcommit: `6d04171`
|
||||
- Der aktuelle Dokumentationscommit ist mit `git log -1` zu ermitteln.
|
||||
- Basis des Feature-Branches: `e6eb995`
|
||||
- Toolchain: `fa90a5c`
|
||||
- Laufzeit/Modell: `10d7cd7`
|
||||
- Chatkern: `ec9b2f9`
|
||||
- Tests: `b153daa`
|
||||
|
||||
## Bekannte Einschränkung
|
||||
|
||||
Im normalen Windows-`PATH` sind Python, `uv`, Ruff und eine lokale Modelllaufzeit noch nicht bestätigt. Der Textchat ist noch nicht implementiert.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-0006: Erste lokale Modelllaufzeit
|
||||
|
||||
Status: angenommen am 30.07.2026.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der erste Windows-Textchat verwendet die portable Ollama-Laufzeit 0.32.5 und genau
|
||||
das Modell `qwen3:8b` in der GGUF-Quantisierung Q4_K_M. Laufzeit und Modell liegen
|
||||
außerhalb des Repositories. Javis akzeptiert für diesen Provider ausschließlich
|
||||
eine lokale HTTP-Loopback-Adresse.
|
||||
|
||||
## Begründung
|
||||
|
||||
Ollama stellt ohne Build, Installer oder Systemdienst eine kleine stabile lokale
|
||||
API bereit. Das Modell bleibt unter 10 GB, passt beim bestätigten Test vollständig
|
||||
in die RTX 3060 mit 12 GB und liefert deutsche Antworten.
|
||||
|
||||
## Folgen
|
||||
|
||||
Die Entscheidung gilt für den ersten Gaming-PC-Meilenstein. Sie legt weder die
|
||||
endgültige Modelllaufzeit noch das Modell für den ASUS-Laptop mit GTX 1050 4 GB
|
||||
fest. Ein späterer Vergleich mit llama.cpp bleibt möglich, wenn messbare
|
||||
Anforderungen an Offloading, Quantisierung oder Ressourcensteuerung entstehen.
|
||||
@@ -5,3 +5,4 @@
|
||||
- [ADR-0003: Sinnvolle Obsidian-Verknüpfungen](0003-meaningful-obsidian-links.md)
|
||||
- [ADR-0004: Tokenbewusster Arbeitsablauf](0004-token-efficient-workflow.md)
|
||||
- [ADR-0005: Kompakter Handoff](0005-compact-handoff.md)
|
||||
- [ADR-0006: Erste lokale Modelllaufzeit](0006-first-local-runtime.md)
|
||||
|
||||
@@ -10,8 +10,23 @@ readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
javis = "javis.interface.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.12,<1",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["B", "E", "F", "I", "UP"]
|
||||
|
||||
@@ -6,7 +6,6 @@ import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REQUIRED = (
|
||||
"README.md",
|
||||
@@ -46,8 +45,7 @@ def main() -> int:
|
||||
forbidden = [
|
||||
path
|
||||
for path in ROOT.rglob("*")
|
||||
if path.is_file()
|
||||
and path.name.lower() in {"key-pw.xml", "secrets.local.xml", ".env"}
|
||||
if path.is_file() and path.name.lower() in {"key-pw.xml", "secrets.local.xml", ".env"}
|
||||
]
|
||||
if forbidden:
|
||||
print("Forbidden local secret files are present:")
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,57 @@
|
||||
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, LocalModelProvider
|
||||
|
||||
|
||||
class RecordingProvider:
|
||||
name = "ollama"
|
||||
model = "test-model"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[list[ChatMessage]] = []
|
||||
|
||||
def chat(self, messages: list[ChatMessage]) -> str:
|
||||
self.calls.append(messages)
|
||||
return f"Antwort {len(self.calls)}"
|
||||
|
||||
|
||||
class ChatServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
database = Path(self.temporary_directory.name) / "sessions.sqlite3"
|
||||
self.provider = RecordingProvider()
|
||||
self.service = ChatService(SQLiteSessionStore(database), self.provider)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_test_double_implements_provider_contract(self) -> None:
|
||||
self.assertIsInstance(self.provider, LocalModelProvider)
|
||||
|
||||
def test_send_passes_and_persists_conversation_history(self) -> None:
|
||||
session = self.service.new_session()
|
||||
|
||||
first = self.service.send(session.id, "Hallo")
|
||||
second = self.service.send(session.id, "Und jetzt?")
|
||||
|
||||
self.assertEqual(first, "Antwort 1")
|
||||
self.assertEqual(second, "Antwort 2")
|
||||
self.assertEqual(len(self.provider.calls[1]), 3)
|
||||
loaded = self.service.load_session(session.id)
|
||||
self.assertEqual(
|
||||
[message.content for message in loaded.messages],
|
||||
[
|
||||
"Hallo",
|
||||
"Antwort 1",
|
||||
"Und jetzt?",
|
||||
"Antwort 2",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,40 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from javis.core.chat_service import ChatService
|
||||
from javis.interface.cli import run_chat
|
||||
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||
from javis.providers.base import ChatMessage
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
name = "ollama"
|
||||
model = "test-model"
|
||||
|
||||
def chat(self, messages: list[ChatMessage]) -> str:
|
||||
return f"Echo: {messages[-1].content}"
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def test_basic_chat_commands(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||
service = ChatService(store, _FakeProvider())
|
||||
inputs = iter(["Hallo", "/sessions", "/new", "/clear", "/exit"])
|
||||
output: list[str] = []
|
||||
|
||||
result = run_chat(
|
||||
service,
|
||||
input_fn=lambda _prompt: next(inputs),
|
||||
output=output.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertTrue(any(line == "Javis: Echo: Hallo" for line in output))
|
||||
self.assertTrue(any("Nachrichten" in line for line in output))
|
||||
self.assertEqual(output[-1], "Chat beendet.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import ClassVar
|
||||
|
||||
from javis.providers.base import ChatMessage
|
||||
from javis.providers.ollama import OllamaProvider
|
||||
|
||||
|
||||
class _OllamaHandler(BaseHTTPRequestHandler):
|
||||
request_payload: ClassVar[dict[str, object]] = {}
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers["Content-Length"])
|
||||
type(self).request_payload = json.loads(self.rfile.read(length))
|
||||
body = json.dumps(
|
||||
{
|
||||
"message": {"role": "assistant", "content": "Lokale Antwort"},
|
||||
"done": True,
|
||||
}
|
||||
).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
|
||||
|
||||
|
||||
class OllamaProviderTests(unittest.TestCase):
|
||||
def test_provider_uses_local_chat_endpoint(self) -> None:
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
provider = OllamaProvider(
|
||||
"test-model",
|
||||
f"http://127.0.0.1:{server.server_port}",
|
||||
2,
|
||||
)
|
||||
response = provider.chat([ChatMessage("user", "Hallo")])
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
self.assertEqual(response, "Lokale Antwort")
|
||||
self.assertEqual(_OllamaHandler.request_payload["model"], "test-model")
|
||||
self.assertFalse(_OllamaHandler.request_payload["stream"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from javis.config.settings import ConfigurationError, Settings, default_data_dir
|
||||
|
||||
|
||||
class SettingsTests(unittest.TestCase):
|
||||
def test_windows_default_uses_local_app_data(self) -> None:
|
||||
result = default_data_dir(
|
||||
{"LOCALAPPDATA": "C:\\Local"},
|
||||
platform_name="nt",
|
||||
home=Path("C:\\Users\\test"),
|
||||
)
|
||||
self.assertEqual(result, Path("C:\\Local") / "Javis")
|
||||
|
||||
def test_posix_default_uses_xdg_data_home(self) -> None:
|
||||
result = default_data_dir(
|
||||
{"XDG_DATA_HOME": "/tmp/data"},
|
||||
platform_name="posix",
|
||||
home=Path("/home/test"),
|
||||
)
|
||||
self.assertEqual(result, Path("/tmp/data/javis"))
|
||||
|
||||
def test_explicit_data_dir_must_be_absolute(self) -> None:
|
||||
with self.assertRaises(ConfigurationError):
|
||||
Settings.from_env({"JAVIS_DATA_DIR": "relative"})
|
||||
|
||||
def test_provider_url_must_remain_local(self) -> None:
|
||||
with self.assertRaises(ConfigurationError):
|
||||
Settings.from_env(
|
||||
{
|
||||
"JAVIS_DATA_DIR": str(Path.cwd()),
|
||||
"JAVIS_OLLAMA_URL": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from javis.memory.sqlite_store import SessionNotFoundError, SQLiteSessionStore
|
||||
|
||||
|
||||
class SQLiteSessionStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
database = Path(self.temporary_directory.name) / "sessions.sqlite3"
|
||||
self.store = SQLiteSessionStore(database)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_create_list_and_load_session(self) -> None:
|
||||
created = self.store.create_session("ollama", "test-model")
|
||||
loaded = self.store.get_session(created.id)
|
||||
|
||||
self.assertEqual(loaded.id, created.id)
|
||||
self.assertEqual(loaded.provider, "ollama")
|
||||
self.assertEqual(loaded.model, "test-model")
|
||||
self.assertEqual(self.store.list_sessions()[0].id, created.id)
|
||||
|
||||
def test_messages_keep_exchange_order(self) -> None:
|
||||
session = self.store.create_session("ollama", "test-model")
|
||||
self.store.append_exchange(session.id, "eins", "zwei")
|
||||
self.store.append_exchange(session.id, "drei", "vier")
|
||||
|
||||
messages = self.store.get_messages(session.id)
|
||||
|
||||
self.assertEqual(
|
||||
[(message.role, message.content) for message in messages],
|
||||
[
|
||||
("user", "eins"),
|
||||
("assistant", "zwei"),
|
||||
("user", "drei"),
|
||||
("assistant", "vier"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_unknown_session_raises_clear_error(self) -> None:
|
||||
with self.assertRaises(SessionNotFoundError):
|
||||
self.store.get_session("missing")
|
||||
|
||||
def test_clear_keeps_session_and_removes_messages(self) -> None:
|
||||
session = self.store.create_session("ollama", "test-model")
|
||||
self.store.append_exchange(session.id, "eins", "zwei")
|
||||
|
||||
self.store.clear_messages(session.id)
|
||||
|
||||
self.assertEqual(self.store.get_messages(session.id), [])
|
||||
self.assertEqual(self.store.get_session(session.id).message_count, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "javis-ai"
|
||||
version = "0.0.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.12,<1" }]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user