diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 0e690d8..8881639 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -58,8 +58,8 @@ Rootserver: - 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, Autostart und Windows-Dienste blieben unverändert. -- CLI-Befehle: `/new`, `/sessions`, `/load `, `/clear`, `/provider`, - `/privacy`, `/status`, `/help`, `/exit`. +- CLI-Befehle: `/new`, `/sessions`, `/load `, `/search `, + `/rename `, `/clear`, `/provider`, `/privacy`, `/status`, `/help`, `/exit`. - Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`. - Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert. @@ -189,8 +189,8 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 85 bestanden; zusätzlich Titelbildung und verlustfreie, - idempotente Migration alter SQLite-Datenbanken abgedeckt +- Unit-Tests: 91 bestanden; zusätzlich Navigation, eindeutige/mehrdeutige + ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen abgedeckt - PowerShell-Syntax des Startskripts: erfolgreich geparst - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -236,6 +236,7 @@ Abnahmestatus: - stabiler Ausgangsstand: `main` bei `77b510b` - medizinischer Datenschutz-Fix: `9148193` - Provider- und CLI-Streaming: `00a5a6c` +- SQLite-Titel und Migration: `112ae6d` - Feature-Branch ist als `7a14fe8` zu `origin/feat/gemini-privacy-router` gepusht - konfliktfreier Merge nach `main`: `564a3fd` @@ -289,8 +290,8 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Auf `feat/chat-comfort` als Nächstes `/rename`, nummerierte `/sessions`, -`/load` per Nummer/ID-Präfix und `/search` umsetzen. +Auf `feat/chat-comfort` als Nächstes Gesamtsicherheitsprüfungen sowie lokale +Ollama-/Neustart-Smokes und kleinen echten Gemini-Streaming-Test ausführen. Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. Noch keine Obsidian-Integration oder Tools beginnen. diff --git a/src/javis/core/chat_service.py b/src/javis/core/chat_service.py index d014e25..6c2513d 100644 --- a/src/javis/core/chat_service.py +++ b/src/javis/core/chat_service.py @@ -72,6 +72,24 @@ class ChatService: def list_sessions(self) -> list[ChatSession]: 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: session = self.store.get_session(session_id) supports_session = getattr(self.provider, "supports_session", None) diff --git a/src/javis/interface/cli.py b/src/javis/interface/cli.py index 1542a7d..9e70a64 100644 --- a/src/javis/interface/cli.py +++ b/src/javis/interface/cli.py @@ -14,6 +14,7 @@ from javis.config.settings import ConfigurationError, Settings from javis.core.chat_service import ChatService, SessionProviderMismatchError from javis.core.provider_router import HybridProvider from javis.memory.sqlite_store import ( + AmbiguousSessionReferenceError, ChatSession, SessionNotFoundError, SessionStoreError, @@ -34,7 +35,9 @@ StatusFunction = Callable[[], list[str]] HELP_TEXT = """Befehle: /new neue Sitzung beginnen /sessions gespeicherte Sitzungen auflisten - /load frühere Sitzung laden und fortsetzen + /load Sitzung per Listennummer oder eindeutigem ID-Anfang laden + /search Titel und eigene Nachrichten durchsuchen + /rename aktive Sitzung umbenennen /clear Nachrichten der aktiven Sitzung leeren /provider aktiven Providermodus anzeigen /provider Modus auto, local oder gemini setzen @@ -139,10 +142,11 @@ def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None: if not sessions: output("Keine gespeicherten Sitzungen vorhanden.") return - for session in sessions: + for number, session in enumerate(sessions, start=1): + date = session.updated_at[:10] output( - f"{session.id} | {session.updated_at} | " - f"{session.provider}/{session.model} | {session.message_count} Nachrichten" + f"{number}. {session.id[:8]} | {session.title} | {date} | " + f"{session.message_count} Nachrichten | {session.last_provider}" ) @@ -207,6 +211,7 @@ def run_chat( return 2 output("Lokaler Javis-Chat. /help zeigt die Befehle.") + shown_sessions: list[ChatSession] = [] while True: try: @@ -232,10 +237,33 @@ def run_chat( continue if entered == "/sessions": try: - _show_sessions(service.list_sessions(), output) + shown_sessions = service.list_sessions() + _show_sessions(shown_sessions, output) except SessionStoreError as exc: output(f"Fehler: {exc}") continue + if entered.startswith("/search"): + parts = entered.split(maxsplit=1) + if len(parts) != 2 or not parts[1].strip(): + output("Verwendung: /search ") + 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 ") + 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": try: service.clear_session(active_id) @@ -285,12 +313,24 @@ def run_chat( output("Verwendung: /load ") continue 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 output(f"Sitzung geladen: {active_id}") _show_history(service, active_id, output) except ( SessionNotFoundError, + AmbiguousSessionReferenceError, SessionProviderMismatchError, SessionStoreError, ) as exc: diff --git a/src/javis/memory/sqlite_store.py b/src/javis/memory/sqlite_store.py index 9f0adf2..58e3566 100644 --- a/src/javis/memory/sqlite_store.py +++ b/src/javis/memory/sqlite_store.py @@ -21,6 +21,10 @@ class SessionNotFoundError(SessionStoreError): """A requested session ID does not exist.""" +class AmbiguousSessionReferenceError(SessionStoreError): + """A short session reference matches multiple sessions.""" + + @dataclass(frozen=True, slots=True) class ChatSession: id: str @@ -188,6 +192,84 @@ class SQLiteSessionStore: raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc 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]: self.get_session(session_id) try: diff --git a/tests/unit/test_chat_service.py b/tests/unit/test_chat_service.py index b0813a5..89987a2 100644 --- a/tests/unit/test_chat_service.py +++ b/tests/unit/test_chat_service.py @@ -81,6 +81,16 @@ class ChatServiceTests(unittest.TestCase): "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: provider = StreamingProvider() service = ChatService(self.service.store, provider) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 07ed36d..2d3b312 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,3 +1,4 @@ +import sqlite3 import tempfile import unittest from pathlib import Path @@ -167,6 +168,76 @@ class CliTests(unittest.TestCase): ) 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: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "javis.toml" diff --git a/tests/unit/test_sqlite_store.py b/tests/unit/test_sqlite_store.py index 139e723..c2463b2 100644 --- a/tests/unit/test_sqlite_store.py +++ b/tests/unit/test_sqlite_store.py @@ -3,14 +3,18 @@ import tempfile import unittest 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): def setUp(self) -> None: self.temporary_directory = tempfile.TemporaryDirectory() - database = Path(self.temporary_directory.name) / "sessions.sqlite3" - self.store = SQLiteSessionStore(database) + self.database = Path(self.temporary_directory.name) / "sessions.sqlite3" + self.store = SQLiteSessionStore(self.database) def tearDown(self) -> None: self.temporary_directory.cleanup() @@ -119,6 +123,49 @@ class SQLiteSessionStoreTests(unittest.TestCase): ["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__": unittest.main()