feat: improve session navigation
This commit is contained in:
@@ -58,8 +58,8 @@ Rootserver:
|
|||||||
- Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`.
|
- Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`.
|
||||||
- Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry,
|
- Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry,
|
||||||
Autostart und Windows-Dienste blieben unverändert.
|
Autostart und Windows-Dienste blieben unverändert.
|
||||||
- CLI-Befehle: `/new`, `/sessions`, `/load <ID>`, `/clear`, `/provider`,
|
- CLI-Befehle: `/new`, `/sessions`, `/load <NR|ID>`, `/search <Text>`,
|
||||||
`/privacy`, `/status`, `/help`, `/exit`.
|
`/rename <Titel>`, `/clear`, `/provider`, `/privacy`, `/status`, `/help`, `/exit`.
|
||||||
- Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte
|
- Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte
|
||||||
Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`.
|
Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`.
|
||||||
- Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert.
|
- Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert.
|
||||||
@@ -189,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: 85 bestanden; zusätzlich Titelbildung und verlustfreie,
|
- Unit-Tests: 91 bestanden; zusätzlich Navigation, eindeutige/mehrdeutige
|
||||||
idempotente Migration alter SQLite-Datenbanken abgedeckt
|
ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen 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
|
||||||
@@ -236,6 +236,7 @@ Abnahmestatus:
|
|||||||
- stabiler Ausgangsstand: `main` bei `77b510b`
|
- stabiler Ausgangsstand: `main` bei `77b510b`
|
||||||
- medizinischer Datenschutz-Fix: `9148193`
|
- medizinischer Datenschutz-Fix: `9148193`
|
||||||
- Provider- und CLI-Streaming: `00a5a6c`
|
- Provider- und CLI-Streaming: `00a5a6c`
|
||||||
|
- SQLite-Titel und Migration: `112ae6d`
|
||||||
- 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`
|
||||||
@@ -289,8 +290,8 @@ Abnahmestatus:
|
|||||||
|
|
||||||
## Nächster sinnvoller Auftrag
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Auf `feat/chat-comfort` als Nächstes `/rename`, nummerierte `/sessions`,
|
Auf `feat/chat-comfort` als Nächstes Gesamtsicherheitsprüfungen sowie lokale
|
||||||
`/load` per Nummer/ID-Präfix und `/search` umsetzen.
|
Ollama-/Neustart-Smokes und kleinen echten Gemini-Streaming-Test ausführen.
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,24 @@ class ChatService:
|
|||||||
def list_sessions(self) -> list[ChatSession]:
|
def list_sessions(self) -> list[ChatSession]:
|
||||||
return self.store.list_sessions()
|
return self.store.list_sessions()
|
||||||
|
|
||||||
|
def search_sessions(self, text: str) -> list[ChatSession]:
|
||||||
|
normalized = text.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Für die Suche fehlt ein Text.")
|
||||||
|
return self.store.search_sessions(normalized)
|
||||||
|
|
||||||
|
def resolve_session(self, reference: str) -> LoadedSession:
|
||||||
|
session = self.store.resolve_unique_prefix(reference)
|
||||||
|
return self.load_session(session.id)
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> ChatSession:
|
||||||
|
normalized = " ".join(title.split()).strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Der Sitzungstitel darf nicht leer sein.")
|
||||||
|
if len(normalized) > 60:
|
||||||
|
normalized = f"{normalized[:59].rstrip()}…"
|
||||||
|
return self.store.rename_session(session_id, normalized)
|
||||||
|
|
||||||
def load_session(self, session_id: str) -> LoadedSession:
|
def load_session(self, session_id: str) -> LoadedSession:
|
||||||
session = self.store.get_session(session_id)
|
session = self.store.get_session(session_id)
|
||||||
supports_session = getattr(self.provider, "supports_session", None)
|
supports_session = getattr(self.provider, "supports_session", None)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from javis.config.settings import ConfigurationError, Settings
|
|||||||
from javis.core.chat_service import ChatService, SessionProviderMismatchError
|
from javis.core.chat_service import ChatService, SessionProviderMismatchError
|
||||||
from javis.core.provider_router import HybridProvider
|
from javis.core.provider_router import HybridProvider
|
||||||
from javis.memory.sqlite_store import (
|
from javis.memory.sqlite_store import (
|
||||||
|
AmbiguousSessionReferenceError,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
SessionStoreError,
|
SessionStoreError,
|
||||||
@@ -34,7 +35,9 @@ StatusFunction = Callable[[], list[str]]
|
|||||||
HELP_TEXT = """Befehle:
|
HELP_TEXT = """Befehle:
|
||||||
/new neue Sitzung beginnen
|
/new neue Sitzung beginnen
|
||||||
/sessions gespeicherte Sitzungen auflisten
|
/sessions gespeicherte Sitzungen auflisten
|
||||||
/load <ID> frühere Sitzung laden und fortsetzen
|
/load <NR|ID> Sitzung per Listennummer oder eindeutigem ID-Anfang laden
|
||||||
|
/search <TEXT> Titel und eigene Nachrichten durchsuchen
|
||||||
|
/rename <TITEL> aktive Sitzung umbenennen
|
||||||
/clear Nachrichten der aktiven Sitzung leeren
|
/clear Nachrichten der aktiven Sitzung leeren
|
||||||
/provider aktiven Providermodus anzeigen
|
/provider aktiven Providermodus anzeigen
|
||||||
/provider <MODUS> Modus auto, local oder gemini setzen
|
/provider <MODUS> Modus auto, local oder gemini setzen
|
||||||
@@ -139,10 +142,11 @@ def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None:
|
|||||||
if not sessions:
|
if not sessions:
|
||||||
output("Keine gespeicherten Sitzungen vorhanden.")
|
output("Keine gespeicherten Sitzungen vorhanden.")
|
||||||
return
|
return
|
||||||
for session in sessions:
|
for number, session in enumerate(sessions, start=1):
|
||||||
|
date = session.updated_at[:10]
|
||||||
output(
|
output(
|
||||||
f"{session.id} | {session.updated_at} | "
|
f"{number}. {session.id[:8]} | {session.title} | {date} | "
|
||||||
f"{session.provider}/{session.model} | {session.message_count} Nachrichten"
|
f"{session.message_count} Nachrichten | {session.last_provider}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +211,7 @@ def run_chat(
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
output("Lokaler Javis-Chat. /help zeigt die Befehle.")
|
output("Lokaler Javis-Chat. /help zeigt die Befehle.")
|
||||||
|
shown_sessions: list[ChatSession] = []
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -232,10 +237,33 @@ def run_chat(
|
|||||||
continue
|
continue
|
||||||
if entered == "/sessions":
|
if entered == "/sessions":
|
||||||
try:
|
try:
|
||||||
_show_sessions(service.list_sessions(), output)
|
shown_sessions = service.list_sessions()
|
||||||
|
_show_sessions(shown_sessions, output)
|
||||||
except SessionStoreError as exc:
|
except SessionStoreError as exc:
|
||||||
output(f"Fehler: {exc}")
|
output(f"Fehler: {exc}")
|
||||||
continue
|
continue
|
||||||
|
if entered.startswith("/search"):
|
||||||
|
parts = entered.split(maxsplit=1)
|
||||||
|
if len(parts) != 2 or not parts[1].strip():
|
||||||
|
output("Verwendung: /search <Text>")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shown_sessions = service.search_sessions(parts[1])
|
||||||
|
_show_sessions(shown_sessions, output)
|
||||||
|
except (SessionStoreError, ValueError) as exc:
|
||||||
|
output(f"Fehler: {exc}")
|
||||||
|
continue
|
||||||
|
if entered.startswith("/rename"):
|
||||||
|
parts = entered.split(maxsplit=1)
|
||||||
|
if len(parts) != 2 or not parts[1].strip():
|
||||||
|
output("Verwendung: /rename <neuer Titel>")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
renamed = service.rename_session(active_id, parts[1])
|
||||||
|
output(f"Sitzung umbenannt: {renamed.title}")
|
||||||
|
except (SessionNotFoundError, SessionStoreError, ValueError) as exc:
|
||||||
|
output(f"Fehler: {exc}")
|
||||||
|
continue
|
||||||
if entered == "/clear":
|
if entered == "/clear":
|
||||||
try:
|
try:
|
||||||
service.clear_session(active_id)
|
service.clear_session(active_id)
|
||||||
@@ -285,12 +313,24 @@ def run_chat(
|
|||||||
output("Verwendung: /load <Sitzungs-ID>")
|
output("Verwendung: /load <Sitzungs-ID>")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
loaded = service.load_session(parts[1].strip())
|
reference = parts[1].strip()
|
||||||
|
if reference.isdecimal():
|
||||||
|
number = int(reference)
|
||||||
|
if not shown_sessions:
|
||||||
|
output("Zuerst /sessions oder /search anzeigen.")
|
||||||
|
continue
|
||||||
|
if number < 1 or number > len(shown_sessions):
|
||||||
|
output("Diese Listennummer ist nicht vorhanden.")
|
||||||
|
continue
|
||||||
|
loaded = service.load_session(shown_sessions[number - 1].id)
|
||||||
|
else:
|
||||||
|
loaded = service.resolve_session(reference)
|
||||||
active_id = loaded.session.id
|
active_id = loaded.session.id
|
||||||
output(f"Sitzung geladen: {active_id}")
|
output(f"Sitzung geladen: {active_id}")
|
||||||
_show_history(service, active_id, output)
|
_show_history(service, active_id, output)
|
||||||
except (
|
except (
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
|
AmbiguousSessionReferenceError,
|
||||||
SessionProviderMismatchError,
|
SessionProviderMismatchError,
|
||||||
SessionStoreError,
|
SessionStoreError,
|
||||||
) as exc:
|
) as exc:
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class SessionNotFoundError(SessionStoreError):
|
|||||||
"""A requested session ID does not exist."""
|
"""A requested session ID does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiguousSessionReferenceError(SessionStoreError):
|
||||||
|
"""A short session reference matches multiple sessions."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ChatSession:
|
class ChatSession:
|
||||||
id: str
|
id: str
|
||||||
@@ -188,6 +192,84 @@ class SQLiteSessionStore:
|
|||||||
raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc
|
raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc
|
||||||
return [self._session_from_row(row) for row in rows]
|
return [self._session_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def search_sessions(self, text: str, *, limit: int = 20) -> list[ChatSession]:
|
||||||
|
normalized = text.strip()
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
escaped = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
pattern = f"%{escaped}%"
|
||||||
|
try:
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.*, COUNT(m.id) AS message_count
|
||||||
|
FROM sessions AS s
|
||||||
|
LEFT JOIN messages AS m ON m.session_id = s.id
|
||||||
|
WHERE s.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM messages AS searched
|
||||||
|
WHERE searched.session_id = s.id
|
||||||
|
AND searched.role = 'user'
|
||||||
|
AND searched.content LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
|
)
|
||||||
|
GROUP BY s.id
|
||||||
|
ORDER BY s.updated_at DESC, s.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(pattern, pattern, max(1, min(limit, 100))),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise SessionStoreError("Die Sitzungssuche ist fehlgeschlagen.") from exc
|
||||||
|
return [self._session_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def resolve_unique_prefix(self, prefix: str) -> ChatSession:
|
||||||
|
normalized = prefix.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise SessionNotFoundError("Leerer Sitzungsverweis.")
|
||||||
|
escaped = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
try:
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sessions
|
||||||
|
WHERE id LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 2
|
||||||
|
""",
|
||||||
|
(f"{escaped}%",),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise SessionStoreError("Der Sitzungsverweis konnte nicht geprüft werden.") from exc
|
||||||
|
if not rows:
|
||||||
|
raise SessionNotFoundError(f"Keine Sitzung beginnt mit '{normalized}'.")
|
||||||
|
if len(rows) > 1:
|
||||||
|
raise AmbiguousSessionReferenceError(
|
||||||
|
f"Der Sitzungsanfang '{normalized}' ist nicht eindeutig."
|
||||||
|
)
|
||||||
|
return self.get_session(rows[0]["id"])
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> ChatSession:
|
||||||
|
now = _utc_now()
|
||||||
|
try:
|
||||||
|
with self._connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET title = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(title, now, session_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.")
|
||||||
|
except SessionNotFoundError:
|
||||||
|
raise
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise SessionStoreError("Die Sitzung konnte nicht umbenannt werden.") from exc
|
||||||
|
return self.get_session(session_id)
|
||||||
|
|
||||||
def get_messages(self, session_id: str) -> list[ChatMessage]:
|
def get_messages(self, session_id: str) -> list[ChatMessage]:
|
||||||
self.get_session(session_id)
|
self.get_session(session_id)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ class ChatServiceTests(unittest.TestCase):
|
|||||||
"Gesundheitsfrage",
|
"Gesundheitsfrage",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_manual_title_is_normalized_and_limited(self) -> None:
|
||||||
|
session = self.service.new_session()
|
||||||
|
|
||||||
|
renamed = self.service.rename_session(session.id, " Mein Titel ")
|
||||||
|
shortened = self.service.rename_session(session.id, "x" * 80)
|
||||||
|
|
||||||
|
self.assertEqual(renamed.title, "Mein Titel")
|
||||||
|
self.assertEqual(len(shortened.title), 60)
|
||||||
|
self.assertTrue(shortened.title.endswith("…"))
|
||||||
|
|
||||||
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()
|
||||||
service = ChatService(self.service.store, provider)
|
service = ChatService(self.service.store, provider)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -167,6 +168,76 @@ class CliTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("Gemini-Schlüssel vorhanden: ja", output)
|
self.assertIn("Gemini-Schlüssel vorhanden: ja", output)
|
||||||
|
|
||||||
|
def test_numbered_sessions_load_rename_and_search(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||||
|
service = ChatService(store, _FakeProvider())
|
||||||
|
first = service.new_session()
|
||||||
|
service.send(first.id, "Erste Frage")
|
||||||
|
second = service.new_session()
|
||||||
|
service.send(second.id, "Zweite Frage")
|
||||||
|
shown_before = service.list_sessions()
|
||||||
|
target = shown_before[1]
|
||||||
|
inputs = iter(
|
||||||
|
[
|
||||||
|
"/sessions",
|
||||||
|
"/load 2",
|
||||||
|
"/rename Gefundene Sitzung",
|
||||||
|
"/search Gefundene",
|
||||||
|
"/exit",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
output: list[str] = []
|
||||||
|
|
||||||
|
result = run_chat(
|
||||||
|
service,
|
||||||
|
session_id=first.id,
|
||||||
|
input_fn=lambda _prompt: next(inputs),
|
||||||
|
output=output.append,
|
||||||
|
)
|
||||||
|
renamed_title = store.get_session(target.id).title
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertTrue(any(line.startswith("1. ") and "Frage" in line for line in output))
|
||||||
|
self.assertIn(f"Sitzung geladen: {target.id}", output)
|
||||||
|
self.assertEqual(renamed_title, "Gefundene Sitzung")
|
||||||
|
self.assertTrue(any("Gefundene Sitzung" in line for line in output))
|
||||||
|
|
||||||
|
def test_load_accepts_unique_prefix_and_rejects_ambiguous_prefix(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "sessions.sqlite3"
|
||||||
|
store = SQLiteSessionStore(database)
|
||||||
|
service = ChatService(store, _FakeProvider())
|
||||||
|
active = service.new_session()
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions
|
||||||
|
(id, created_at, updated_at, provider, model, title, last_provider)
|
||||||
|
VALUES (?, '2026-01-01', '2026-01-01',
|
||||||
|
'ollama', 'test-model', ?, 'ollama')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
("shared-111", "Eins"),
|
||||||
|
("shared-222", "Zwei"),
|
||||||
|
("unique-333", "Drei"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
inputs = iter(["/load unique", "/load shared", "/exit"])
|
||||||
|
output: list[str] = []
|
||||||
|
|
||||||
|
result = run_chat(
|
||||||
|
service,
|
||||||
|
session_id=active.id,
|
||||||
|
input_fn=lambda _prompt: next(inputs),
|
||||||
|
output=output.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertIn("Sitzung geladen: unique-333", output)
|
||||||
|
self.assertTrue(any("nicht eindeutig" in line for line in output))
|
||||||
|
|
||||||
def test_gemini_configuration_defaults_to_no(self) -> None:
|
def test_gemini_configuration_defaults_to_no(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
path = Path(directory) / "javis.toml"
|
path = Path(directory) / "javis.toml"
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from javis.memory.sqlite_store import SessionNotFoundError, SQLiteSessionStore
|
from javis.memory.sqlite_store import (
|
||||||
|
AmbiguousSessionReferenceError,
|
||||||
|
SessionNotFoundError,
|
||||||
|
SQLiteSessionStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SQLiteSessionStoreTests(unittest.TestCase):
|
class SQLiteSessionStoreTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||||
database = Path(self.temporary_directory.name) / "sessions.sqlite3"
|
self.database = Path(self.temporary_directory.name) / "sessions.sqlite3"
|
||||||
self.store = SQLiteSessionStore(database)
|
self.store = SQLiteSessionStore(self.database)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.temporary_directory.cleanup()
|
self.temporary_directory.cleanup()
|
||||||
@@ -119,6 +123,49 @@ class SQLiteSessionStoreTests(unittest.TestCase):
|
|||||||
["Alte Nachricht"],
|
["Alte Nachricht"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_rename_and_search_only_titles_and_user_messages(self) -> None:
|
||||||
|
first = self.store.create_session("ollama", "test-model")
|
||||||
|
second = self.store.create_session("ollama", "test-model")
|
||||||
|
self.store.rename_session(first.id, "SQLite Hilfe")
|
||||||
|
self.store.append_exchange(first.id, "Transaktion erklären", "Privates Lösungswort")
|
||||||
|
self.store.append_exchange(second.id, "Andere Frage", "Nur SQLite in Antwort")
|
||||||
|
|
||||||
|
self.assertEqual(self.store.get_session(first.id).title, "SQLite Hilfe")
|
||||||
|
self.assertEqual(
|
||||||
|
[session.id for session in self.store.search_sessions("Transaktion")],
|
||||||
|
[first.id],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.store.search_sessions("Lösungswort"), [])
|
||||||
|
self.assertEqual(self.store.search_sessions("SQLite"), [self.store.get_session(first.id)])
|
||||||
|
|
||||||
|
def test_search_treats_sql_wildcards_and_injection_as_plain_text(self) -> None:
|
||||||
|
session = self.store.create_session("ollama", "test-model")
|
||||||
|
self.store.rename_session(session.id, "100% SQLite_Name")
|
||||||
|
|
||||||
|
self.assertEqual(self.store.search_sessions("%")[0].id, session.id)
|
||||||
|
self.assertEqual(self.store.search_sessions("_")[0].id, session.id)
|
||||||
|
self.assertEqual(self.store.search_sessions("' OR 1=1 --"), [])
|
||||||
|
|
||||||
|
def test_unique_and_ambiguous_session_prefixes(self) -> None:
|
||||||
|
with sqlite3.connect(self.database) as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions
|
||||||
|
(id, created_at, updated_at, provider, model, title, last_provider)
|
||||||
|
VALUES (?, '2026-01-01', '2026-01-01', 'ollama', 'model', ?, 'ollama')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
("abc111", "Eins"),
|
||||||
|
("abc222", "Zwei"),
|
||||||
|
("unique333", "Drei"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(self.store.resolve_unique_prefix("unique").id, "unique333")
|
||||||
|
with self.assertRaises(AmbiguousSessionReferenceError):
|
||||||
|
self.store.resolve_unique_prefix("abc")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user