feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+67 -14
View File
@@ -15,16 +15,31 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` so the History's Share column renders without a
second fetch), ``POST`` (create — auto-title from the first question
phase-51 ``share_url`` and the phase-53 ``stale`` flag so the
History's Share and Stale columns render without a second fetch),
``POST`` (create — auto-title from the first question
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
the token in the SAME commit — the save-then-share contract),
the token in the SAME commit — the save-then-share contract;
(phase 53, task 03) stamps the row's ``sources_version`` on the
PENDING row so it ships in the same INSERT),
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01)
only when supplied; re-stamps ``sources_version`` to the current
generation — a Re-Save is the owner affirming this content against
the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
admin-gated router.
Staleness (phase 53, task 03): every saved row carries the
``sources_meta`` generation it was saved against (``sources_version``,
migration 0010). ``GET``/list/detail expose ``stale`` — true iff the
row's stamp is behind the current generation, computed server-side
from ONE ``current_sources_version`` read per request (the client
never does staleness math). Share/unshare stay version-immune (raw SQL
on ``share_token`` only — the version, like ``updated_at``, is
untouched); ``SharedChatOut`` (the public snapshot) carries no
staleness surface at all — it is frozen by design (phase 51).
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
publicly readable at ``/shared/<token>`` — two more routers in this
@@ -54,6 +69,7 @@ from app.config import get_settings
from app.core.auth import require_admin
from app.db import get_db
from app.models import SavedChat
from app.rag.sources_meta import current_sources_version
from app.schemas import (
ChatMessage,
SavedChatCreate,
@@ -100,8 +116,14 @@ def _share_url(row: SavedChat) -> str | None:
return f"/shared/{row.share_token}" if row.share_token else None
def _to_out(row: SavedChat) -> SavedChatOut:
"""The full-payload response shape (create/get/put)."""
def _to_out(row: SavedChat, current_version: int) -> SavedChatOut:
"""The full-payload response shape (create/get/put).
``current_version`` is the caller's ONE per-request
``current_sources_version`` read — the helpers stay pure (no session
argument, no second query); ``stale`` is true iff the row's stamp
is behind that generation.
"""
return SavedChatOut(
id=row.id,
title=row.title,
@@ -110,18 +132,22 @@ def _to_out(row: SavedChat) -> SavedChatOut:
message_count=len(row.messages),
messages=[ChatMessage.model_validate(m) for m in row.messages],
share_url=_share_url(row),
stale=row.sources_version < current_version,
)
def _to_row(row: SavedChat) -> SavedChatRow:
def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
"""The list-page row shape (no payloads in the list; ``share_url``
is not a payload — it is the History Share column's data)."""
and ``stale`` are not payloads — they are the History Share and
Stale columns' data). ``current_version`` is the caller's ONE
per-request read (see :func:`_to_out`)."""
return SavedChatRow(
id=row.id,
title=row.title,
updated_at=row.updated_at,
message_count=len(row.messages),
share_url=_share_url(row),
stale=row.sources_version < current_version,
)
@@ -130,11 +156,15 @@ def list_chats(
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order."""
desc``) — the History page's table order. Each row carries the
phase-53 ``stale`` flag: the current generation is read ONCE per
request (one PK read of the seeded row) and compared against every
row's stamp in the serializer helpers — no per-row queries."""
current_version = current_sources_version(db)
rows = db.scalars(
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
).all()
return SavedChatList(chats=[_to_row(row) for row in rows])
return SavedChatList(chats=[_to_row(row, current_version) for row in rows])
@router.post("", response_model=SavedChatOut, status_code=201)
@@ -153,8 +183,15 @@ def create_chat(
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
INSERT/commit — one request saves AND shares, and the 201 body
carries ``share_url`` (the chat page copies it in one action).
``sources_version`` (phase 53, task 03): stamped on the PENDING
row with the current generation — it ships in the SAME INSERT (the
``share_token`` precedent), so a save can never be committed
unstamped, and the 201 body's ``stale`` flag (freshly stamped →
always false) is honest by construction.
"""
title = (payload.title or "").strip() or _auto_title(payload.messages)
current_version = current_sources_version(db) # once per request
row = SavedChat(
title=title,
# Plain model_dump (no exclude_none): the stored JSONB keeps
@@ -163,6 +200,9 @@ def create_chat(
# is what makes a real payload round-trip byte-identical (the
# restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages],
# Phase 53: the KB generation this save is made against, set
# before db.add so it rides the same INSERT (see above).
sources_version=current_version,
)
if payload.share:
# Phase 51: set on the PENDING row, so the token ships in the
@@ -174,7 +214,7 @@ def create_chat(
row.title = f"Chat {row.id.hex[:8]}"
db.commit()
db.refresh(row)
return _to_out(row)
return _to_out(row, current_version)
@router.get("/{chat_id}", response_model=SavedChatOut)
@@ -183,11 +223,13 @@ def get_chat(
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when
the id is unknown."""
the id is unknown. The ``stale`` flag (phase 53) tells the chat
page whether to reveal its stale banner (task 05) before the
messages render."""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
return _to_out(row)
return _to_out(row, current_sources_version(db))
@router.put("/{chat_id}", response_model=SavedChatOut)
@@ -203,17 +245,28 @@ def update_chat(
bumps via the model's ``onupdate=func.now()`` — the attribute
assignment above is the ORM change that triggers it, so the History
page's "latest activity first" order follows re-Saves.
``sources_version`` is re-stamped to the CURRENT generation
unconditionally (phase 53, task 03 — ASSUMPTION: a Re-Save ALWAYS
re-stamps, not "only when stale"): re-Saving is the owner
affirming this content against the current KB, which makes the
response's ``stale`` flag false and doubles as the manual escape
hatch for a false-positive stale row (one fewer code path than a
conditional re-stamp).
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
current_version = current_sources_version(db) # once per request
row.messages = [m.model_dump() for m in payload.messages]
title = (payload.title or "").strip()
if title:
row.title = title
# Phase 53: the affirmation stamp — see the docstring above.
row.sources_version = current_version
db.commit()
db.refresh(row)
return _to_out(row)
return _to_out(row, current_version)
@router.delete("/{chat_id}", status_code=204)