feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
# 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
|
||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from backend.agent.loop import run_agent
|
||||
from backend.ai_chat import chat_completion, stream_completion
|
||||
from backend.ai_history import delete_session, get_session, list_sessions, upsert_session
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.bookslm import (
|
||||
build_general_system_prompt,
|
||||
@@ -605,3 +606,86 @@ async def api_bookslm_agent(
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Persistent conversation history (#95) ─────────────────────────────
|
||||
|
||||
|
||||
class BookslmSession(BaseModel):
|
||||
"""Full assistant conversation persisted server-side (#95).
|
||||
|
||||
The shape mirrors the client session object so round-tripping is lossless
|
||||
(transient fields such as ``confirmation``/``payload`` are stripped
|
||||
client-side before upload).
|
||||
"""
|
||||
|
||||
id: str = Field(description="Stable session id (s-<ts36>-<rand>)")
|
||||
title: str = Field(default="", description="Derived human title")
|
||||
mode: str = Field(default="general", description="'directory', 'documents' or 'general'")
|
||||
vault: str | None = Field(default=None, description="Vault name for the context")
|
||||
directory: str = Field(default="", description="Relative directory path")
|
||||
documents: list[dict[str, str]] = Field(
|
||||
default_factory=list,
|
||||
description="Open documents for the 'documents' mode",
|
||||
)
|
||||
context: str = Field(default="", description="Client context key of the assistant")
|
||||
createdAt: int | None = Field(default=None, description="Creation ISO ms timestamp")
|
||||
updatedAt: int | None = Field(default=None, description="Last update ISO ms timestamp")
|
||||
messages: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Conversation turns [{role, content}]",
|
||||
)
|
||||
|
||||
|
||||
def _session_user(current_user) -> str:
|
||||
return current_user.get("username") if isinstance(current_user, dict) else "" # type: ignore[return-value]
|
||||
|
||||
|
||||
@router.get("/history", response_model=dict[str, list[dict[str, Any]]])
|
||||
async def api_bookslm_history_list(current_user=Depends(require_auth)):
|
||||
"""List the user's assistant conversations (summaries, most recent first).
|
||||
|
||||
Messages are not included to keep the list light; fetch the full
|
||||
conversation with ``GET /history/{id}`` when a session is opened.
|
||||
"""
|
||||
return {"sessions": list_sessions(_session_user(current_user))}
|
||||
|
||||
|
||||
@router.get("/history/{session_id}", response_model=dict[str, Any] | None)
|
||||
async def api_bookslm_history_get(
|
||||
session_id: str,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a single full conversation, or 404 when unknown."""
|
||||
session = get_session(_session_user(current_user), session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
return session
|
||||
|
||||
|
||||
@router.put("/history/{session_id}", response_model=dict[str, Any])
|
||||
async def api_bookslm_history_upsert(
|
||||
session_id: str,
|
||||
session: BookslmSession,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create or update a conversation for the current user.
|
||||
|
||||
The body id is forced to the path id so a client never writes under a
|
||||
different key by mistake.
|
||||
"""
|
||||
payload = session.model_dump()
|
||||
payload["id"] = session_id
|
||||
stored = upsert_session(_session_user(current_user), payload)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid session id")
|
||||
return stored
|
||||
|
||||
|
||||
@router.delete("/history/{session_id}", response_model=dict[str, bool])
|
||||
async def api_bookslm_history_delete(
|
||||
session_id: str,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete a conversation for the current user."""
|
||||
return {"ok": delete_session(_session_user(current_user), session_id)}
|
||||
|
||||
Reference in New Issue
Block a user