feat: add session titles and migration

This commit is contained in:
2026-07-30 19:42:32 +02:00
parent 00a5a6ce8c
commit 112ae6d1da
5 changed files with 227 additions and 25 deletions
+9 -9
View File
@@ -116,7 +116,8 @@ Rootserver:
- plattformunabhängige Pfade und zentrale Konfiguration - plattformunabhängige Pfade und zentrale Konfiguration
- Modell-Provider als kleine austauschbare Schnittstelle - Modell-Provider als kleine austauschbare Schnittstelle
- Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen - 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 - Streaming wird erst nach vollständigem Abschluss atomar gespeichert; bei Abbruch
bleibt weder die Benutzerfrage noch eine unvollständige Antwort im Verlauf. bleibt weder die Benutzerfrage noch eine unvollständige Antwort im Verlauf.
- Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard - 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. - Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste.
- Sicheres PowerShell-Startskript und kompakte Startdokumentation ergänzt; keine - Sicheres PowerShell-Startskript und kompakte Startdokumentation ergänzt; keine
PATH-, Registry-, Autostart-, Dienst- oder Richtlinienänderung. 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 - Echtes Ollama-/Gemini-Streaming, providerübergreifendes Fallback vor der ersten
Ausgabe und sicheren Streaming-Abbruch nach Teilausgabe implementiert. Ausgabe und sicheren Streaming-Abbruch nach Teilausgabe implementiert.
- CLI-Streaming mit lesbarer Markdown-Bereinigung und sicherem Strg+C ergänzt. - 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 - uv-Lock und `uv sync --dev`: bestanden
- Python in `.venv`: 3.12.13 - Python in `.venv`: 3.12.13
- Ruff in `.venv`: 0.16.0 - Ruff in `.venv`: 0.16.0
- Unit-Tests: 82 bestanden; medizinisches `never`, Streaming, Gedankenfilter, - Unit-Tests: 85 bestanden; zusätzlich Titelbildung und verlustfreie,
Cloudclient-Sperre, Fallback und atomarer Strg+C-Abbruch abgedeckt idempotente Migration alter SQLite-Datenbanken abgedeckt
- PowerShell-Syntax des Startskripts: erfolgreich geparst - PowerShell-Syntax des Startskripts: erfolgreich geparst
- Ruff Lint: bestanden - Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden - Ruff Formatprüfung: bestanden
@@ -233,14 +235,12 @@ Abnahmestatus:
- aktueller Arbeitsbranch: `feat/chat-comfort` - aktueller Arbeitsbranch: `feat/chat-comfort`
- stabiler Ausgangsstand: `main` bei `77b510b` - stabiler Ausgangsstand: `main` bei `77b510b`
- medizinischer Datenschutz-Fix: `9148193` - medizinischer Datenschutz-Fix: `9148193`
- Provider- und CLI-Streaming: `00a5a6c`
- Feature-Branch ist als `7a14fe8` zu - Feature-Branch ist als `7a14fe8` zu
`origin/feat/gemini-privacy-router` gepusht `origin/feat/gemini-privacy-router` gepusht
- konfliktfreier Merge nach `main`: `564a3fd` - konfliktfreier Merge nach `main`: `564a3fd`
- 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
- `main` erfolgreich zu `origin/main` gepusht; Remote-Tracking ist synchron - `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` - Python-Entwicklungsumgebung auf dem Feature-Branch: `fa90a5c`
- Laufzeit-/Modelldokumentation: `10d7cd7` - Laufzeit-/Modelldokumentation: `10d7cd7`
- Chatkern und CLI: `ec9b2f9` - Chatkern und CLI: `ec9b2f9`
@@ -289,8 +289,8 @@ Abnahmestatus:
## Nächster sinnvoller Auftrag ## Nächster sinnvoller Auftrag
Auf `feat/chat-comfort` als Nächstes SQLite-Titelmigration, lokale automatische Auf `feat/chat-comfort` als Nächstes `/rename`, nummerierte `/sessions`,
Titel sowie `/rename`, `/sessions`, `/load` und `/search` umsetzen. `/load` per Nummer/ID-Präfix und `/search` umsetzen.
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
Noch keine Obsidian-Integration oder Tools beginnen. Noch keine Obsidian-Integration oder Tools beginnen.
+53 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from collections.abc import Iterator from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
@@ -17,6 +18,39 @@ class SessionProviderMismatchError(RuntimeError):
"""The active provider cannot safely continue the stored session.""" """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) @dataclass(frozen=True, slots=True)
class LoadedSession: class LoadedSession:
session: ChatSession session: ChatSession
@@ -61,7 +95,13 @@ class ChatService:
loaded = self.load_session(session_id) loaded = self.load_session(session_id)
messages = [*loaded.messages, ChatMessage("user", normalized)] messages = [*loaded.messages, ChatMessage("user", normalized)]
response = self.provider.chat(messages) 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 return response
def stream_send(self, session_id: str, text: str) -> Iterator[str]: def stream_send(self, session_id: str, text: str) -> Iterator[str]:
@@ -100,7 +140,18 @@ class ChatService:
raise InvalidProviderResponseError( raise InvalidProviderResponseError(
"Der Provider lieferte keine verwendbare Streaming-Antwort." "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: def clear_session(self, session_id: str) -> None:
self.store.clear_messages(session_id) 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
+82 -13
View File
@@ -28,9 +28,14 @@ class ChatSession:
updated_at: str updated_at: str
provider: str provider: str
model: str model: str
title: str
last_provider: str
message_count: int = 0 message_count: int = 0
DEFAULT_SESSION_TITLE = "Neue Sitzung"
def _utc_now() -> str: def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds") return datetime.now(UTC).isoformat(timespec="seconds")
@@ -63,16 +68,21 @@ class SQLiteSessionStore:
def _initialize(self) -> None: def _initialize(self) -> None:
with self._connection() as connection: with self._connection() as connection:
connection.executescript( connection.execute(
""" """
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
provider 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 ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
@@ -82,10 +92,31 @@ class SQLiteSessionStore:
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE (session_id, position) 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 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: with self._connection() as connection:
connection.execute( connection.execute(
""" """
INSERT INTO sessions (id, created_at, updated_at, provider, model) INSERT INTO sessions
VALUES (?, ?, ?, ?, ?) (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: except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from 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: def get_session(self, session_id: str) -> ChatSession:
try: try:
@@ -162,6 +210,9 @@ class SQLiteSessionStore:
session_id: str, session_id: str,
user_content: str, user_content: str,
assistant_content: str, assistant_content: str,
*,
title_if_first: str | None = None,
last_provider: str | None = None,
) -> None: ) -> None:
now = _utc_now() now = _utc_now()
try: try:
@@ -194,8 +245,24 @@ class SQLiteSessionStore:
), ),
) )
connection.execute( 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: except SessionNotFoundError:
raise raise
@@ -232,5 +299,7 @@ class SQLiteSessionStore:
updated_at=row["updated_at"], updated_at=row["updated_at"],
provider=row["provider"], provider=row["provider"],
model=row["model"], model=row["model"],
title=row["title"],
last_provider=row["last_provider"],
message_count=row["message_count"], message_count=row["message_count"],
) )
+17 -1
View File
@@ -2,7 +2,7 @@ import tempfile
import unittest import unittest
from pathlib import Path 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.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage, LocalModelProvider, ProviderUnavailableError from javis.providers.base import ChatMessage, LocalModelProvider, ProviderUnavailableError
@@ -64,6 +64,22 @@ class ChatServiceTests(unittest.TestCase):
"Antwort 2", "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: def test_streaming_response_is_persisted_exactly_once_after_completion(self) -> None:
provider = StreamingProvider() provider = StreamingProvider()
+66
View File
@@ -1,3 +1,4 @@
import sqlite3
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -21,6 +22,8 @@ class SQLiteSessionStoreTests(unittest.TestCase):
self.assertEqual(loaded.id, created.id) self.assertEqual(loaded.id, created.id)
self.assertEqual(loaded.provider, "ollama") self.assertEqual(loaded.provider, "ollama")
self.assertEqual(loaded.model, "test-model") 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) self.assertEqual(self.store.list_sessions()[0].id, created.id)
def test_messages_keep_exchange_order(self) -> None: 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_messages(session.id), [])
self.assertEqual(self.store.get_session(session.id).message_count, 0) 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__": if __name__ == "__main__":
unittest.main() unittest.main()