From 112ae6d1da6c60ec96160d62497b44312c480e75 Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Thu, 30 Jul 2026 19:42:32 +0200 Subject: [PATCH] feat: add session titles and migration --- docs/CHATGPT_HANDOFF.md | 18 +++--- src/javis/core/chat_service.py | 55 +++++++++++++++++- src/javis/memory/sqlite_store.py | 95 +++++++++++++++++++++++++++----- tests/unit/test_chat_service.py | 18 +++++- tests/unit/test_sqlite_store.py | 66 ++++++++++++++++++++++ 5 files changed, 227 insertions(+), 25 deletions(-) diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 1d0b0d8..0e690d8 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -116,7 +116,8 @@ Rootserver: - plattformunabhängige Pfade und zentrale Konfiguration - Modell-Provider als kleine austauschbare Schnittstelle - Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen -- SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren +- SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren, neutralem + Starttitel, lokaler sicherer Titelerzeugung und zuletzt genutztem Provider - Streaming wird erst nach vollständigem Abschluss atomar gespeichert; bei Abbruch bleibt weder die Benutzerfrage noch eine unvollständige Antwort im Verlauf. - Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard @@ -169,7 +170,8 @@ Rootserver: - Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste. - Sicheres PowerShell-Startskript und kompakte Startdokumentation ergänzt; keine PATH-, Registry-, Autostart-, Dienst- oder Richtlinienänderung. -- Medizinische Inhalte vor Cloudclient-Erstellung strikt auf `never` gesetzt. +- Medizinische Inhalte vor Cloudclient-Erstellung strikt auf `never` gesetzt; + bestehende SQLite-Daten transaktional und wiederholbar um Titel/Provider migriert. - Echtes Ollama-/Gemini-Streaming, providerübergreifendes Fallback vor der ersten Ausgabe und sicheren Streaming-Abbruch nach Teilausgabe implementiert. - CLI-Streaming mit lesbarer Markdown-Bereinigung und sicherem Strg+C ergänzt. @@ -187,8 +189,8 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 82 bestanden; medizinisches `never`, Streaming, Gedankenfilter, - Cloudclient-Sperre, Fallback und atomarer Strg+C-Abbruch abgedeckt +- Unit-Tests: 85 bestanden; zusätzlich Titelbildung und verlustfreie, + idempotente Migration alter SQLite-Datenbanken abgedeckt - PowerShell-Syntax des Startskripts: erfolgreich geparst - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -233,14 +235,12 @@ Abnahmestatus: - aktueller Arbeitsbranch: `feat/chat-comfort` - stabiler Ausgangsstand: `main` bei `77b510b` - medizinischer Datenschutz-Fix: `9148193` +- Provider- und CLI-Streaming: `00a5a6c` - Feature-Branch ist als `7a14fe8` zu `origin/feat/gemini-privacy-router` gepusht - konfliktfreier Merge nach `main`: `564a3fd` - 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - `main` erfolgreich zu `origin/main` gepusht; Remote-Tracking ist synchron -- Grundgerüstcommit: `6d04171` -- letzter Commit vor der Handoff-Verdichtung: `0e82748` -- Handoff-Verdichtung auf `main`: `e6eb995` - Python-Entwicklungsumgebung auf dem Feature-Branch: `fa90a5c` - Laufzeit-/Modelldokumentation: `10d7cd7` - Chatkern und CLI: `ec9b2f9` @@ -289,8 +289,8 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Auf `feat/chat-comfort` als Nächstes SQLite-Titelmigration, lokale automatische -Titel sowie `/rename`, `/sessions`, `/load` und `/search` umsetzen. +Auf `feat/chat-comfort` als Nächstes `/rename`, nummerierte `/sessions`, +`/load` per Nummer/ID-Präfix und `/search` umsetzen. Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. Noch keine Obsidian-Integration oder Tools beginnen. diff --git a/src/javis/core/chat_service.py b/src/javis/core/chat_service.py index 4682dec..d014e25 100644 --- a/src/javis/core/chat_service.py +++ b/src/javis/core/chat_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from collections.abc import Iterator from dataclasses import dataclass @@ -17,6 +18,39 @@ class SessionProviderMismatchError(RuntimeError): """The active provider cannot safely continue the stored session.""" +_SENSITIVE_TITLE_PATTERN = re.compile( + r"(?ix)" + r"\b(?:api[-_ ]?key|passwort|password|token|secret|iban|kreditkarte|" + r"private[-_ ]?key|ssh[-_ ]?key)\b|" + r"-----BEGIN [A-Z ]+PRIVATE KEY-----|" + r"\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b|" + r"\b[\w.+-]+@[\w.-]+\.[A-Z]{2,}\b" +) +_MEDICAL_TITLE_PATTERN = re.compile( + r"(?ix)\b(?:arzt|ärzt|blut(?:e|en|ung)?|diagnos|gesundheit|krank|" + r"medikament|notfall|schmerz|symptom|therap|verletz|wunde)\w*\b" +) + + +def derive_session_title(text: str, *, limit: int = 60) -> str: + """Create a short local title without retaining obvious sensitive values.""" + + normalized = " ".join(text.split()) + if _SENSITIVE_TITLE_PATTERN.search(normalized): + return "Sensible Anfrage" + if _MEDICAL_TITLE_PATTERN.search(normalized): + return "Gesundheitsfrage" + cleaned = re.sub(r"[`*_#~]+", "", normalized).strip(" -:;,.!?") + if not cleaned: + return "Neue Unterhaltung" + if len(cleaned) <= limit: + return cleaned + shortened = cleaned[: limit - 1].rsplit(" ", 1)[0].rstrip(" -:;,.!?") + if not shortened: + shortened = cleaned[: limit - 1].rstrip() + return f"{shortened}…" + + @dataclass(frozen=True, slots=True) class LoadedSession: session: ChatSession @@ -61,7 +95,13 @@ class ChatService: 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) + self.store.append_exchange( + session_id, + normalized, + response, + title_if_first=derive_session_title(normalized), + last_provider=self._last_provider_name(), + ) return response def stream_send(self, session_id: str, text: str) -> Iterator[str]: @@ -100,7 +140,18 @@ class ChatService: raise InvalidProviderResponseError( "Der Provider lieferte keine verwendbare Streaming-Antwort." ) - self.store.append_exchange(session_id, normalized, response) + self.store.append_exchange( + session_id, + normalized, + response, + title_if_first=derive_session_title(normalized), + last_provider=self._last_provider_name(), + ) def clear_session(self, session_id: str) -> None: self.store.clear_messages(session_id) + + def _last_provider_name(self) -> str: + route = getattr(self.provider, "last_route", None) + provider_name = getattr(route, "provider", None) + return provider_name if isinstance(provider_name, str) else self.provider.name diff --git a/src/javis/memory/sqlite_store.py b/src/javis/memory/sqlite_store.py index c0bc173..9f0adf2 100644 --- a/src/javis/memory/sqlite_store.py +++ b/src/javis/memory/sqlite_store.py @@ -28,9 +28,14 @@ class ChatSession: updated_at: str provider: str model: str + title: str + last_provider: str message_count: int = 0 +DEFAULT_SESSION_TITLE = "Neue Sitzung" + + def _utc_now() -> str: return datetime.now(UTC).isoformat(timespec="seconds") @@ -63,16 +68,21 @@ class SQLiteSessionStore: def _initialize(self) -> None: with self._connection() as connection: - connection.executescript( + connection.execute( """ 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 - ); - + model TEXT NOT NULL, + title TEXT NOT NULL DEFAULT 'Neue Sitzung', + last_provider TEXT NOT NULL DEFAULT '' + ) + """ + ) + connection.execute( + """ CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, @@ -82,10 +92,31 @@ class SQLiteSessionStore: created_at TEXT NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, UNIQUE (session_id, position) - ); - + ) + """ + ) + columns = { + row["name"] for row in connection.execute("PRAGMA table_info(sessions)").fetchall() + } + if "title" not in columns: + connection.execute( + "ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT 'Neue Sitzung'" + ) + if "last_provider" not in columns: + connection.execute( + "ALTER TABLE sessions ADD COLUMN last_provider TEXT NOT NULL DEFAULT ''" + ) + connection.execute( + """ + UPDATE sessions + SET last_provider = provider + WHERE last_provider = '' + """ + ) + connection.execute( + """ CREATE INDEX IF NOT EXISTS idx_messages_session_position - ON messages(session_id, position); + ON messages(session_id, position) """ ) @@ -96,14 +127,31 @@ class SQLiteSessionStore: with self._connection() as connection: connection.execute( """ - INSERT INTO sessions (id, created_at, updated_at, provider, model) - VALUES (?, ?, ?, ?, ?) + INSERT INTO sessions + (id, created_at, updated_at, provider, model, title, last_provider) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (session_id, now, now, provider, model), + ( + session_id, + now, + now, + provider, + model, + DEFAULT_SESSION_TITLE, + provider, + ), ) except sqlite3.Error as exc: raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc - return ChatSession(session_id, now, now, provider, model) + return ChatSession( + session_id, + now, + now, + provider, + model, + DEFAULT_SESSION_TITLE, + provider, + ) def get_session(self, session_id: str) -> ChatSession: try: @@ -162,6 +210,9 @@ class SQLiteSessionStore: session_id: str, user_content: str, assistant_content: str, + *, + title_if_first: str | None = None, + last_provider: str | None = None, ) -> None: now = _utc_now() try: @@ -194,8 +245,24 @@ class SQLiteSessionStore: ), ) connection.execute( - "UPDATE sessions SET updated_at = ? WHERE id = ?", - (now, session_id), + """ + UPDATE sessions + SET updated_at = ?, + title = CASE + WHEN ? = 0 AND title = ? THEN COALESCE(?, title) + ELSE title + END, + last_provider = COALESCE(?, last_provider) + WHERE id = ? + """, + ( + now, + next_position, + DEFAULT_SESSION_TITLE, + title_if_first, + last_provider, + session_id, + ), ) except SessionNotFoundError: raise @@ -232,5 +299,7 @@ class SQLiteSessionStore: updated_at=row["updated_at"], provider=row["provider"], model=row["model"], + title=row["title"], + last_provider=row["last_provider"], message_count=row["message_count"], ) diff --git a/tests/unit/test_chat_service.py b/tests/unit/test_chat_service.py index 1e3c570..b0813a5 100644 --- a/tests/unit/test_chat_service.py +++ b/tests/unit/test_chat_service.py @@ -2,7 +2,7 @@ import tempfile import unittest from pathlib import Path -from javis.core.chat_service import ChatService +from javis.core.chat_service import ChatService, derive_session_title from javis.memory.sqlite_store import SQLiteSessionStore from javis.providers.base import ChatMessage, LocalModelProvider, ProviderUnavailableError @@ -64,6 +64,22 @@ class ChatServiceTests(unittest.TestCase): "Antwort 2", ], ) + self.assertEqual(loaded.session.title, "Hallo") + + def test_title_is_local_short_and_redacts_obvious_sensitive_content(self) -> None: + self.assertEqual( + derive_session_title("Erkläre SQLite-Transaktionen für Anfänger"), + "Erkläre SQLite-Transaktionen für Anfänger", + ) + self.assertLessEqual(derive_session_title("Wort " * 30).__len__(), 60) + self.assertEqual( + derive_session_title("Mein API-Key ist ABC123 und funktioniert nicht"), + "Sensible Anfrage", + ) + self.assertEqual( + derive_session_title("Wenn ich blute, sollte ich zum Arzt?"), + "Gesundheitsfrage", + ) def test_streaming_response_is_persisted_exactly_once_after_completion(self) -> None: provider = StreamingProvider() diff --git a/tests/unit/test_sqlite_store.py b/tests/unit/test_sqlite_store.py index 5f312ce..139e723 100644 --- a/tests/unit/test_sqlite_store.py +++ b/tests/unit/test_sqlite_store.py @@ -1,3 +1,4 @@ +import sqlite3 import tempfile import unittest from pathlib import Path @@ -21,6 +22,8 @@ class SQLiteSessionStoreTests(unittest.TestCase): self.assertEqual(loaded.id, created.id) self.assertEqual(loaded.provider, "ollama") self.assertEqual(loaded.model, "test-model") + self.assertEqual(loaded.title, "Neue Sitzung") + self.assertEqual(loaded.last_provider, "ollama") self.assertEqual(self.store.list_sessions()[0].id, created.id) def test_messages_keep_exchange_order(self) -> None: @@ -53,6 +56,69 @@ class SQLiteSessionStoreTests(unittest.TestCase): self.assertEqual(self.store.get_messages(session.id), []) self.assertEqual(self.store.get_session(session.id).message_count, 0) + def test_first_exchange_sets_title_and_last_provider_atomically(self) -> None: + session = self.store.create_session("hybrid", "auto") + + self.store.append_exchange( + session.id, + "Frage", + "Antwort", + title_if_first="Kurzer Titel", + last_provider="gemini", + ) + self.store.append_exchange( + session.id, + "Noch eine Frage", + "Noch eine Antwort", + title_if_first="Darf Titel nicht ersetzen", + last_provider="ollama", + ) + + loaded = self.store.get_session(session.id) + self.assertEqual(loaded.title, "Kurzer Titel") + self.assertEqual(loaded.last_provider, "ollama") + + def test_old_database_is_migrated_idempotently_without_data_loss(self) -> None: + database = Path(self.temporary_directory.name) / "legacy.sqlite3" + with sqlite3.connect(database) as connection: + connection.executescript( + """ + CREATE TABLE 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 messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + position INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (session_id, position) + ); + INSERT INTO sessions VALUES + ('legacy', '2026-01-01', '2026-01-02', 'ollama', 'old-model'); + INSERT INTO messages + (session_id, position, role, content, created_at) + VALUES ('legacy', 0, 'user', 'Alte Nachricht', '2026-01-02'); + """ + ) + connection.close() + + migrated = SQLiteSessionStore(database) + migrated_again = SQLiteSessionStore(database) + + session = migrated_again.get_session("legacy") + self.assertEqual(session.title, "Neue Sitzung") + self.assertEqual(session.last_provider, "ollama") + self.assertEqual( + [message.content for message in migrated.get_messages("legacy")], + ["Alte Nachricht"], + ) + if __name__ == "__main__": unittest.main()