mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 14:22:19 +02:00
fix(history): defer full transcript hydration to model sends (#5929)
* fix(history): defer full hydration to model sends * fix(session): key hydration on real rows, fork through get_session Two regressions from the display/model-context split, both reproducible against dev. The hydration gate compared the cached transcript against the denormalized sessions.message_count column. That column drifts in normal operation — _persist_message swallows a failed insert while add_message has already appended in memory, so the next successful persist writes rows+1 — and _db_to_session re-read the same column after each reload, so the shortfall never closed. Every send, edit, delete and truncate on a warm session re-selected the whole message table: the cost this change set out to remove, relocated onto the hot path. The other direction was just as bad — a persist for an uncached session writes message_count = 0, and a stale-low counter with a partly filled cache meant no hydration at all and a silently truncated transcript for the model. sync_session_metadata now reconciles message_count against COUNT(*) on chat_messages (one indexed count inside the connection it already opens), and _db_to_session trusts the rows it just loaded. A hydrate always closes the gap, so the next read is a cache hit. fork_session read session_manager.sessions directly and never hydrated. keep_count indexes into source.history, and display pagination no longer fills that cache, so forking after a restart returned HTTP 200 with an empty conversation and no error surfaced. It goes through get_session now. _hydrate_session_history_from_db is gone with its helper: get_session is the hydration seam, and rebuilding session.history from raw rows in the display fallback overwrote the parsed multimodal content and the _db_id edit/delete keys that had just been set. Tests drive a real SessionManager over a temp DB instead of a stub that only proved the stub hydrates — both drift directions, the send path warm and cold, and a fork taken after a restart. All five fail without this change. The brittle SQL-text assertions are dropped; the page bounds are already proven by the response body. * fix(history): route pagination through canonical handler --------- Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
@@ -137,44 +137,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
entry["metadata"] = meta
|
||||
return entry
|
||||
|
||||
def _db_message_metadata(m: DbChatMessage) -> Dict[str, Any]:
|
||||
meta = {}
|
||||
if m.meta_data:
|
||||
try:
|
||||
meta = json.loads(m.meta_data) or {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
meta = {}
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
return meta
|
||||
|
||||
def _hydrate_session_history_from_db(session_id: str, rows: list[DbChatMessage]) -> None:
|
||||
"""Rebuild in-memory context from raw DB rows after a history load.
|
||||
|
||||
The browser history endpoint can return paged/display-trimmed messages,
|
||||
but the next model call reads ``session.history``. After a restart or a
|
||||
stale in-memory session, selecting an old chat through the paged endpoint
|
||||
used to show the transcript while the model only saw fresh context.
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
return
|
||||
session.history = [
|
||||
ChatMessage(role=m.role, content=m.content, metadata=_db_message_metadata(m) or None)
|
||||
for m in rows
|
||||
]
|
||||
session.message_count = len(session.history)
|
||||
|
||||
def _session_needs_db_history_hydration(session_id: str, total: int) -> bool:
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
return False
|
||||
return len(session.history or []) < int(total or 0)
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def get_session_history(
|
||||
request: Request,
|
||||
@@ -198,6 +160,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
)
|
||||
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||
page_offset = max(0, min(page_offset, total))
|
||||
# Keep display pagination page-scoped. ``get_session`` is the
|
||||
# full model-context hydration seam and must not be entered here.
|
||||
rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
@@ -206,14 +170,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.limit(page_limit)
|
||||
.all()
|
||||
)
|
||||
if _session_needs_db_history_hydration(session_id, total):
|
||||
full_rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
_hydrate_session_history_from_db(session_id, full_rows)
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in rows)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
@@ -258,7 +214,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
entry["metadata"] = msg["metadata"]
|
||||
history_dict.append(entry)
|
||||
|
||||
# Fallback: load from DB if in-memory is empty
|
||||
# Fallback: load from DB if in-memory renders empty. Display only —
|
||||
# get_session above is the hydration seam, so nothing here writes back
|
||||
# into session.history — rebuilding it from raw rows would overwrite
|
||||
# parsed multimodal content and the _db_id edit/delete keys it just set.
|
||||
if not history_dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -268,17 +227,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
db_history = []
|
||||
for m in db_messages:
|
||||
db_history.append(_db_history_entry(m))
|
||||
if db_history:
|
||||
# Rebuild in-memory history from the full set so hidden
|
||||
# messages (e.g. compaction summaries) are kept for AI context.
|
||||
_hydrate_session_history_from_db(session_id, db_messages)
|
||||
# Response excludes hidden messages, matching the in-memory path.
|
||||
history_dict = [
|
||||
m for m in db_history
|
||||
if not (m.get("metadata") or {}).get("hidden")
|
||||
entry for entry in (_db_history_entry(m) for m in db_messages)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||
@@ -645,8 +597,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
|
||||
# Get the source session
|
||||
source = session_manager.sessions.get(session_id)
|
||||
# Get the source session. keep_count indexes into source.history,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
# forks an empty transcript out of a metadata-only session after a
|
||||
# restart (display pagination no longer hydrates it).
|
||||
try:
|
||||
source = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
if not source:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
|
||||
@@ -801,15 +801,6 @@ def setup_session_routes(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/history/{sid}")
|
||||
def get_history(request: Request, sid: str):
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
session = session_manager.get_session(sid)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session {sid} not found")
|
||||
return {"history": [msg.to_dict() for msg in session.history]}
|
||||
|
||||
@router.get("/session/{sid}/export")
|
||||
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
||||
"""Export conversation history as a downloadable file.
|
||||
|
||||
Reference in New Issue
Block a user