175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
# 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 |