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
+53 -2
View File
@@ -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
+82 -13
View File
@@ -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"],
)