feat: add session titles and migration
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user