# backend/ai_history.py """Persistent assistant conversation history (#95). Each authenticated user owns a flat list of conversation sessions tagged with their context (mode / vault / directory / documents). Sessions survive page reloads and are the source of truth for the panel history menu (#96 sidebar "Historique IA" reads the same store). Format of a session (JS/JSON shape kept identical to the client, minus transient fields): { "id": "s-…", "title": "…", "mode": "directory" | "documents" | "general", "vault": "…" | None, "directory": "…" | "", "documents": [{"vault": "…", "path": "…"}], "context": "directory-…", # _contextKey() of the assistant "createdAt": 1234567890, "updatedAt": 1234567890, "messages": [{"role": "user|assistant", "content": "…"}] } Sessions are capped per user (see MAX_SESSIONS); the oldest ones are dropped when the cap is reached. """ import json import logging import shutil from pathlib import Path from typing import Any logger = logging.getLogger("obsigate.ai_history") AI_HISTORY_DIR = Path("data/ai_history") MAX_SESSIONS = 200 def _get_user_file(username: str) -> Path: AI_HISTORY_DIR.mkdir(parents=True, exist_ok=True) return AI_HISTORY_DIR / f"{username}.json" def _read_sessions(username: str) -> list[dict[str, Any]]: path = _get_user_file(username) if not path.exists(): return [] try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as e: # pragma: no cover - defensive I/O guard logger.error(f"Failed to read AI history for {username}: {e}") return [] if not isinstance(data, list): return [] return [s for s in data if isinstance(s, dict)] def _write_sessions(username: str, sessions: list[dict[str, Any]]) -> None: path = _get_user_file(username) try: tmp = path.with_suffix(".tmp") tmp.write_text( json.dumps(sessions, indent=2, ensure_ascii=False), encoding="utf-8", ) shutil.move(str(tmp), str(path)) except Exception as e: # pragma: no cover - defensive I/O guard logger.error(f"Failed to write AI history for {username}: {e}") def _summary(session: dict[str, Any]) -> dict[str, Any]: """Compact representation (no messages) used by the list endpoint.""" messages = session.get("messages") or [] preview = "" for msg in reversed(messages): content = (msg.get("content") or "").strip() if isinstance(msg, dict) else "" if content: preview = content[:120] break return { "id": session.get("id", ""), "title": session.get("title", "") or "", "mode": session.get("mode", "general"), "vault": session.get("vault"), "directory": session.get("directory", ""), "context": session.get("context", ""), "createdAt": session.get("createdAt", 0), "updatedAt": session.get("updatedAt", session.get("createdAt", 0)), "message_count": len(messages), "preview": preview, } def list_sessions(username: str, *, include_messages: bool = False) -> list[dict[str, Any]]: """Return the user's sessions, most recently updated first. With ``include_messages=False`` (default) a compact summary is returned; the full conversation is fetched per id via :func:`get_session`. """ if not username: return [] sessions = sorted( _read_sessions(username), key=lambda s: s.get("updatedAt") or s.get("createdAt") or 0, reverse=True, ) if include_messages: return sessions return [_summary(s) for s in sessions] def get_session(username: str, session_id: str) -> dict[str, Any] | None: if not username or not session_id: return None for session in _read_sessions(username): if session.get("id") == session_id: return session return None def upsert_session(username: str, session: dict[str, Any]) -> dict[str, Any] | None: """Create or update a conversation for the user. Returns the stored session, or None when there is no valid id. """ if not username: return None session_id = (session.get("id") or "").strip() if not session_id: return None now = session.get("updatedAt") or session.get("createdAt") or 0 stored = { "id": session_id, "title": session.get("title", "") or "", "mode": session.get("mode") or "general", "vault": session.get("vault"), "directory": session.get("directory", ""), "documents": session.get("documents") or [], "context": session.get("context", ""), "createdAt": session.get("createdAt") or now, "updatedAt": now, "messages": session.get("messages") or [], } sessions = _read_sessions(username) replaced = False for i, existing in enumerate(sessions): if existing.get("id") == session_id: sessions[i] = stored replaced = True break if not replaced: sessions.append(stored) sessions.sort(key=lambda s: s.get("updatedAt") or s.get("createdAt") or 0, reverse=True) if len(sessions) > MAX_SESSIONS: logger.info(f"AI history cap reached for {username}: trimming to {MAX_SESSIONS}") sessions = sessions[:MAX_SESSIONS] _write_sessions(username, sessions) return stored def delete_session(username: str, session_id: str) -> bool: if not username or not session_id: return False sessions = _read_sessions(username) remaining = [s for s in sessions if s.get("id") != session_id] if len(remaining) == len(sessions): return False _write_sessions(username, remaining) return True