feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
+67
-14
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user