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