Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d567e5301 | |||
| b153daa989 | |||
| ec9b2f94ad | |||
| 10d7cd7662 | |||
| fa90a5c5de |
+6
-4
@@ -1,4 +1,6 @@
|
|||||||
# Non-secret process overrides only.
|
# Optionale, nicht geheime Prozesswerte. Diese Datei wird nicht automatisch geladen.
|
||||||
JAVIS_DISPLAY_NAME=Javis
|
JAVIS_DATA_DIR=D:\Javis-Data\runtime
|
||||||
JAVIS_CONFIG_PATH=config/javis.toml
|
JAVIS_PROVIDER=ollama
|
||||||
JAVIS_SECRETS_PROVIDER=none
|
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.
|
- Eng begrenzte projektinterne Codex-Agentenrollen.
|
||||||
- Abhängigkeitsfreier Strukturprüfer.
|
- Abhängigkeitsfreier Strukturprüfer.
|
||||||
- Phasenbezogene Empfehlung für Entwicklungswerkzeuge, VS-Code-Erweiterungen, Obsidian und lokale Modelllaufzeiten.
|
- 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
|
### 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.
|
- Tokenbewusste Arbeitsweise als dauerhafte Regel ergänzt; Qualität und Sicherheit bleiben vorrangig.
|
||||||
- Laufende Handoff-Aktualisierung nach jedem abgeschlossenen logischen Arbeitspaket festgelegt.
|
- 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.
|
- 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
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,33 @@ Javis ist ein langfristiges, lokal-first ausgerichtetes Projekt für einen pers
|
|||||||
|
|
||||||
## Aktueller Stand
|
## 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,
|
Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile
|
||||||
- keine Modellanbindung,
|
Client und Living Mind.
|
||||||
- keinen Obsidian-Schreibzugriff,
|
|
||||||
- keinen Serverzugriff,
|
|
||||||
- keine Sprachpipeline,
|
|
||||||
- keine Living-Mind-Implementierung.
|
|
||||||
|
|
||||||
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
|
## Grundregeln
|
||||||
|
|
||||||
@@ -29,9 +46,11 @@ Der nächste Entwicklungsschritt beginnt erst nach Pascals Prüfung auf einem ei
|
|||||||
- [Projektstatus](docs/PROJECT_STATUS.md)
|
- [Projektstatus](docs/PROJECT_STATUS.md)
|
||||||
- [Roadmap](docs/ROADMAP.md)
|
- [Roadmap](docs/ROADMAP.md)
|
||||||
- [Architektur](docs/ARCHITECTURE.md)
|
- [Architektur](docs/ARCHITECTURE.md)
|
||||||
|
- [Lokales Modell](docs/LOCAL_MODEL.md)
|
||||||
|
- [Entwicklungsumgebung](docs/DEVELOPMENT_SETUP.md)
|
||||||
- [Sicherheit](docs/SECURITY.md)
|
- [Sicherheit](docs/SECURITY.md)
|
||||||
- [Nächste Sitzung](docs/NEXT_SESSION.md)
|
- [Nächste Sitzung](docs/NEXT_SESSION.md)
|
||||||
- [Vollständiger Handoff](docs/CHATGPT_HANDOFF.md)
|
- [Kompakter Handoff](docs/CHATGPT_HANDOFF.md)
|
||||||
|
|
||||||
## Historischer Verbindungstest
|
## 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]
|
[app]
|
||||||
display_name = "Javis"
|
display_name = "Javis"
|
||||||
language = "de"
|
language = "de"
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
runtime_dir = "runtime"
|
data_dir = ""
|
||||||
|
|
||||||
[providers]
|
[providers]
|
||||||
model = "disabled"
|
active = "ollama"
|
||||||
|
model = "qwen3:8b"
|
||||||
|
ollama_base_url = "http://127.0.0.1:11434"
|
||||||
|
timeout_seconds = 180
|
||||||
cloud_fallback = false
|
cloud_fallback = false
|
||||||
|
|
||||||
[obsidian]
|
[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.
|
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.
|
1. Interface: implementierte CLI; später möglicherweise Sprache, Mobilzugriff und Living Mind.
|
||||||
2. Core: Gesprächsablauf, Sitzungszustand und Orchestrierung.
|
2. Core: implementierter Gesprächsablauf und Sitzungslogik.
|
||||||
3. Providers: austauschbare lokale und optionale Cloud-Modellanbindungen.
|
3. Providers: kleine Schnittstelle und implementierter lokaler Ollama-Adapter.
|
||||||
4. Memory: strukturierte Laufzeitdaten und regenerierbarer semantischer Index.
|
4. Memory: implementierte SQLite-Sitzungen; ein semantischer Index ist nur geplant.
|
||||||
5. Obsidian: zunächst lesender Adapter, später eng begrenzte Entwürfe.
|
5. Obsidian: zunächst lesender Adapter, später eng begrenzte Entwürfe.
|
||||||
6. Tools und Status: feste Schemas, Allowlists, Timeouts und bereinigte Ausgaben.
|
6. Tools und Status: feste Schemas, Allowlists, Timeouts und bereinigte Ausgaben.
|
||||||
7. Security: Freigaben, Secret-Provider und Berechtigungsprüfung.
|
7. Security: Freigaben, Secret-Provider und Berechtigungsprüfung.
|
||||||
@@ -17,11 +17,17 @@ Javis beginnt als modularer Monolith. Module besitzen klare Zuständigkeiten und
|
|||||||
|
|
||||||
## Plattformgrenzen
|
## 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.
|
- Betriebssystemspezifische Funktionen liegen hinter Adaptern.
|
||||||
- Produktname, Datenpfade und Provider werden zentral konfiguriert.
|
- Produktname, Datenpfade und Provider werden zentral konfiguriert.
|
||||||
- Laufzeitdaten liegen außerhalb von Git.
|
- 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
|
## Aktueller technischer Stand
|
||||||
|
|
||||||
- Projektgrundgerüst ist auf `main` vorhanden und nach Gitea gepusht.
|
- Der erste lokale Textchat ist auf `feat/core-chat` implementiert und abgenommen.
|
||||||
- Paketstruktur unter `src/javis` ist angelegt, enthält aber noch keinen Chatkern.
|
- `uv run javis chat` liefert echte lokale Antworten und speichert Sitzungen in SQLite.
|
||||||
- Konfigurationsbeispiel, Sicherheitsdokumente, ADRs und Strukturtest sind vorhanden.
|
- Konfigurationsbeispiel, Sicherheitsdokumente, ADRs und Strukturtest sind vorhanden.
|
||||||
- Living-Mind-Verzeichnis ist ausschließlich ein inaktiver Vertrag ohne Frontend oder Fantasiedaten.
|
- 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.
|
- 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.
|
- 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
|
## Aktuelle Architektur
|
||||||
|
|
||||||
- modularer Monolith, bevorzugt Python
|
- modularer Monolith, bevorzugt Python
|
||||||
- plattformunabhängige Pfade und zentrale Konfiguration
|
- plattformunabhängige Pfade und zentrale Konfiguration
|
||||||
- Modell-Provider als kleine austauschbare Schnittstelle
|
- 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
|
- Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard
|
||||||
- lokale Modelle zuerst; keine kostenpflichtige API und kein stiller Cloud-Fallback
|
- lokale Modelle zuerst; keine kostenpflichtige API und kein stiller Cloud-Fallback
|
||||||
- Secret-Werte außerhalb von Code, Git, Prompts und Logs
|
- 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.
|
- Tokenbewusster Arbeitsablauf festgelegt: gezielte Reads, kurze Ergebnisse, keine unnötigen Agenten oder Plugins.
|
||||||
- Werkzeugempfehlung auf `uv`, Microsoft Python/Pylance/Python Environments und Ruff begrenzt.
|
- Werkzeugempfehlung auf `uv`, Microsoft Python/Pylance/Python Environments und Ruff begrenzt.
|
||||||
- Langer Handoff einmalig unverändert archiviert und durch diese kompakte aktuelle Zusammenfassung ersetzt.
|
- 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.
|
- Obsidian, Ubuntu-Laptop und Rootserver blieben unverändert.
|
||||||
|
|
||||||
## Aktuelle Tests
|
## Aktuelle Tests
|
||||||
|
|
||||||
Letzter bestätigter Grundgerüststand:
|
Letzter bestätigter Projektstand:
|
||||||
|
|
||||||
- Strukturprüfung: bestanden
|
- Strukturprüfung: bestanden
|
||||||
- Unit-Test des Paketgerüsts: bestanden
|
- Unit-Test des Paketgerüsts: bestanden
|
||||||
@@ -89,15 +112,40 @@ Letzter bestätigter Grundgerüststand:
|
|||||||
- UTF-8- und interne Markdown-Linkprüfung: bestanden
|
- UTF-8- und interne Markdown-Linkprüfung: bestanden
|
||||||
- Secret-Musterprüfung: keine Treffer
|
- Secret-Musterprüfung: keine Treffer
|
||||||
- Gitignore-Prüfung für lokale Secret-Dateien: bestanden
|
- 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
|
## Git-Stand
|
||||||
|
|
||||||
|
- aktueller Branch: `feat/core-chat`
|
||||||
- stabiler Branch: `main`
|
- stabiler Branch: `main`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- Grundgerüstcommit: `6d04171`
|
||||||
- letzter Commit vor der Handoff-Verdichtung: `0e82748`
|
- 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
|
- `origin` verwendet HTTPS
|
||||||
- kein Force-Push und keine umgeschriebene Historie
|
- 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
|
- `49a1dd3`: erster Gitea-Verbindungstest
|
||||||
- `6d04171`: sicheres Javis-Grundgerüst
|
- `6d04171`: sicheres Javis-Grundgerüst
|
||||||
- `0e82748`: Werkzeug-, Token- und Handoff-Regeln
|
- `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
|
## Offene Entscheidungen und Fehler
|
||||||
|
|
||||||
- endgültiger Produkt-/Repositoryname bleibt offen
|
- endgültiger Produkt-/Repositoryname bleibt offen
|
||||||
- konkrete Python-Version und Installation sind noch festzulegen
|
- Startmechanismus für Ollama ist noch bewusst manuell und nur pro Prozess konfiguriert
|
||||||
- lokale Modelllaufzeit und erstes Modell sind noch nicht installiert oder benchmarked
|
- Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche
|
||||||
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben
|
- 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
|
- 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
|
- 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
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Der genehmigte nächste Meilenstein ist `feat/core-chat`:
|
Nach Merge von `feat/core-chat` den Chat gemeinsam im Alltag benutzen und zuerst
|
||||||
|
konkrete Bedienungs- oder Stabilitätsprobleme sammeln. Mögliche kleine Folgepakete
|
||||||
1. von sauberem `main` einen Feature-Branch erstellen
|
sind ein klarer Start-/Stoppablauf, Streaming, Sitzungstitel oder eine explizite
|
||||||
2. minimale Python-Toolchain mit `uv`, lokaler `.venv`, Ruff und offiziellen VS-Code-Erweiterungen einrichten
|
lokale TOML-Konfiguration. Das spätere Laptop-Modell wird erst auf dessen Hardware
|
||||||
3. Ollama und llama.cpp anhand aktueller offizieller Quellen vergleichen und nur eine Laufzeit installieren
|
gemessen und separat entschieden.
|
||||||
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
|
|
||||||
|
|
||||||
Nicht Teil dieses Meilensteins: Obsidian-Integration, Serverzugriff, Sprache, Mobile Client oder Living-Mind-Code.
|
Nicht Teil dieses Meilensteins: Obsidian-Integration, Serverzugriff, Sprache, Mobile Client oder Living-Mind-Code.
|
||||||
|
|||||||
+15
-10
@@ -1,18 +1,23 @@
|
|||||||
# Datenmodell – Entwurf
|
# Datenmodell
|
||||||
|
|
||||||
## Laufzeitdaten
|
## Laufzeitdaten
|
||||||
|
|
||||||
Eine spätere strukturierte Datenbank kann mindestens enthalten:
|
Der erste Chat verwendet SQLite mit zwei Tabellen:
|
||||||
|
|
||||||
- `sessions`: Beginn, Ende und aktiver Kontext
|
- `sessions`: UUID, Erstellungs-/Änderungszeit, Provider und Modell
|
||||||
- `messages`: Rolle, Inhalt, Zeit und Providerreferenz
|
- `messages`: Sitzungsbezug, feste Reihenfolge, Rolle, Inhalt und Zeitstempel
|
||||||
- `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
|
|
||||||
|
|
||||||
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
|
## Obsidian
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,9 @@
|
|||||||
| ADR-0003 | Obsidian-Links benötigen echte inhaltliche Beziehungen | angenommen |
|
| ADR-0003 | Obsidian-Links benötigen echte inhaltliche Beziehungen | angenommen |
|
||||||
| ADR-0004 | Tokenbewusst arbeiten, ohne Qualität oder Sicherheit zu senken | 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-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).
|
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
|
## 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
|
## Vor Änderungen erneut prüfen
|
||||||
|
|
||||||
@@ -13,12 +14,11 @@ Phase 1 auf `feat/core-chat`: minimale Python-Toolchain, genau eine lokale Model
|
|||||||
|
|
||||||
## Vorgeschlagener Umfang
|
## Vorgeschlagener Umfang
|
||||||
|
|
||||||
- `uv`, unterstützte stabile Python-Version und offizielle VS-Code-Erweiterungen einrichten
|
- Start-/Stoppablauf von Ollama auf Alltagstauglichkeit prüfen
|
||||||
- Microsoft Python/Pylance/Python Environments sowie Ruff als minimale Entwicklungswerkzeuge prüfen
|
- reale mehrteilige Gespräche und verständliche Fehlermeldungen beobachten
|
||||||
- Ollama und llama.cpp offiziell vergleichen; nur eine Laufzeit und ein Modell unter ungefähr 10 GB installieren
|
- bei Bedarf Streaming, Sitzungstitel oder eine explizite lokale TOML-Konfiguration
|
||||||
- lokale Konfiguration, Provider-Schnittstelle und SQLite-Sitzungen implementieren
|
als eigenes kleines Feature planen
|
||||||
- CLI mit neuen, gelisteten und fortsetzbaren Sitzungen
|
- erst nach Messung auf der Zielhardware ein kleineres Laptop-Modell auswählen
|
||||||
- Unit-, Ruff-, Persistenz-, Secret- und echter Modell-Smoke-Test
|
|
||||||
|
|
||||||
## Nicht beginnen
|
## Nicht beginnen
|
||||||
|
|
||||||
|
|||||||
+24
-5
@@ -1,6 +1,6 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
Stand: Dokumentationsverdichtung vor `feat/core-chat`, 30.07.2026.
|
Stand: lokaler Textchat auf `feat/core-chat`, 30.07.2026.
|
||||||
|
|
||||||
## Erreicht
|
## 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
|
- projektinterne Codex-Agentensyntax anhand der aktuellen offiziellen Codex-Dokumentation geprüft
|
||||||
- phasenbezogene Werkzeug- und Plugin-Empfehlungen sowie tokenbewusste Arbeitsregeln dokumentiert
|
- phasenbezogene Werkzeug- und Plugin-Empfehlungen sowie tokenbewusste Arbeitsregeln dokumentiert
|
||||||
- vollständigen Erst-Handoff archiviert und kompakte laufende Übergabe eingeführt
|
- 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
|
## 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
|
## Git
|
||||||
|
|
||||||
- Branch: `main`
|
- Branch: `feat/core-chat`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- 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
|
## 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-0003: Sinnvolle Obsidian-Verknüpfungen](0003-meaningful-obsidian-links.md)
|
||||||
- [ADR-0004: Tokenbewusster Arbeitsablauf](0004-token-efficient-workflow.md)
|
- [ADR-0004: Tokenbewusster Arbeitsablauf](0004-token-efficient-workflow.md)
|
||||||
- [ADR-0005: Kompakter Handoff](0005-compact-handoff.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"
|
requires-python = ">=3.11"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
javis = "javis.interface.cli:main"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.12,<1",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
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
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
REQUIRED = (
|
REQUIRED = (
|
||||||
"README.md",
|
"README.md",
|
||||||
@@ -46,8 +45,7 @@ def main() -> int:
|
|||||||
forbidden = [
|
forbidden = [
|
||||||
path
|
path
|
||||||
for path in ROOT.rglob("*")
|
for path in ROOT.rglob("*")
|
||||||
if path.is_file()
|
if path.is_file() and path.name.lower() in {"key-pw.xml", "secrets.local.xml", ".env"}
|
||||||
and path.name.lower() in {"key-pw.xml", "secrets.local.xml", ".env"}
|
|
||||||
]
|
]
|
||||||
if forbidden:
|
if forbidden:
|
||||||
print("Forbidden local secret files are present:")
|
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