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)
+32 -1
View File
@@ -39,7 +39,16 @@ decisions):
CLI's no-prune default is unchanged);
5. when the import changed the KB (added + updated > 0),
``regenerate_overview`` refreshes the single ``kb_overview`` row
(phase 31 trigger, best-effort inside).
(phase 31 trigger, best-effort inside);
6. when the import changed the KB (added + updated + pruned > 0 — the
saved-chat invalidation gate, phase 53 task 02: a pruned document
can invalidate a saved answer that cited it, deliberately broader
than step 5's overview gate), the single-row ``sources_meta``
version counter is bumped exactly once in a short-lived session and
the resulting generation lands in the status detail as
``sources_version`` (an unchanged re-sync reports the current
generation without advancing it). A FAILED sync never bumps — the
run aborts in the ``failed`` state before this step.
Status is in memory: a restart mid-sync loses the running state
(accepted — the next click re-syncs idempotently).
@@ -63,6 +72,7 @@ from app.rag.git_sources import effective_sources
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient, check_models
from app.rag.overview import regenerate_overview
from app.rag.sources_meta import bump_sources_version, current_sources_version
from scripts.git_sync import GitSyncError, clone_or_pull
from scripts.import_docs import repo_name
@@ -202,6 +212,26 @@ async def _run_sync() -> None:
overview = False
if summary.added + summary.updated > 0:
overview = await regenerate_overview(llm)
# Phase 53 (task 02): a sync that changed the KB advances the
# sources version exactly once — the saved-chat invalidation
# marker (task 03 stamps rows against it). The gate is
# deliberately broader than the overview's above: a pruned
# document can invalidate a saved answer that cited it, so
# ``pruned > 0`` bumps too. The bump commits in its own short
# session (the ``effective_sources`` pattern above), so it
# lands even if the best-effort overview then fails — the index
# really did change. An unchanged re-sync never bumps; it
# reports the current generation instead, so the detail always
# carries the generation the KB is now at.
db = SessionLocal()
try:
if summary.added + summary.updated + summary.pruned > 0:
sources_version = bump_sources_version(db)
db.commit()
else:
sources_version = current_sources_version(db)
finally:
db.close()
_status.state = "success"
_status.finished_at = datetime.now(UTC)
_status.detail = {
@@ -215,6 +245,7 @@ async def _run_sync() -> None:
"summaries": summary.summaries,
"summary_errors": summary.summary_errors,
"overview": overview,
"sources_version": sources_version,
}
logger.info("sync: done detail=%s", _status.detail)
except Exception as e: # noqa: BLE001 — a background task dies in state, see above
+42 -1
View File
@@ -23,7 +23,14 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
14 shape) — phase 50; ``/api/chat`` stays
stateless; ``share_token`` (NULL = private,
``uuid4`` = publicly readable at
``/shared/<token>``) — phase 51.
``/shared/<token>``) — phase 51; ``sources_version``
(the KB generation the conversation was saved
against; 0 = the pre-counter KB, stale on the
first bump) — phase 53.
* ``sources_meta`` — single-row sources-version counter: which
generation of the knowledge base is current,
bumped exactly once per KB-changing sync so saved
chats can be marked stale (phase 53).
"""
from __future__ import annotations
@@ -146,6 +153,33 @@ class KbOverview(Base):
)
class SourcesMeta(Base):
"""Single-row sources-version counter (phase 53).
Exactly one row (``id = 1``, seeded by migration 0010 — the
``kb_overview`` id=1 precedent) holds the current **generation** of
the knowledge base. ``version`` is bumped exactly once per sync that
actually changed the KB (phase 53, task 02 — change-gated on
``added + updated + pruned > 0``), so it doubles as the invalidation
marker for saved chats: a ``saved_chats`` row stamped with an older
generation is *stale* — its answers predate the current index and
may be Regenerated against it (phase 53, tasks 03/05). The row is
seeded by the migration (not lazily on first bump), so
:func:`app.rag.sources_meta.current_sources_version` is a plain PK
read.
"""
__tablename__ = "sources_meta"
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="1")
#: The KB generation. 0 = the pre-counter KB (everything indexed
#: before phase 53); incremented by one per KB-changing sync.
version: Mapped[int] = mapped_column(Integer, server_default="0")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class GitSource(Base):
"""One admin-managed source (phase 35; kind discriminator, phase 38).
@@ -203,6 +237,13 @@ class SavedChat(Base):
share_token: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), unique=True, nullable=True
)
#: The sources version (KB generation) the conversation was saved
#: against (phase 53): stamped by the API at save time. Existing
#: rows (saved before the counter existed) stamp ``0`` — "the
#: pre-counter KB" — and become stale on the first KB-changing sync
#: (stale = ``sources_version < current``, computed server-side by
#: the chats API, phase 53 task 03).
sources_version: Mapped[int] = mapped_column(Integer, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+53
View File
@@ -0,0 +1,53 @@
"""Sources-version counter (phase 53, task 01).
The single-row ``sources_meta`` table (migration 0010, the
``kb_overview`` id=1 precedent) records **which generation of the
knowledge base** is current. ``version`` is bumped exactly once per
sync that actually changed the KB (task 02 — change-gated on
``added + updated + pruned > 0``; a failed sync never bumps), so it
doubles as the invalidation marker for saved chats: the chats API
stamps each saved row with the current version at save time (task 03)
and flags rows stamped with an older generation *stale* — their
answers predate the current index and may be Regenerated against it
(task 05).
Session convention (phase 53): :func:`bump_sources_version` only
*flushes* — the **caller** commits. The two sync paths (the admin Sync
button, ``scripts/import_docs``) each own their session, and the bump
must land in the same transaction as the KB changes it records.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models import SourcesMeta
def current_sources_version(db: Session) -> int:
"""The current KB generation (one PK read of the seeded row).
Returns ``0`` when the row is absent — defensive, never raises
(the migration seeds the row, so absence only happens if someone
deleted it out-of-band).
"""
row = db.get(SourcesMeta, 1)
if row is None:
return 0
return row.version
def bump_sources_version(db: Session) -> int:
"""Advance the KB generation by one; return the new version.
Upserts the single row (creating it if it was ever deleted),
increments ``version`` by one, and ``flush``es — the **caller**
commits, because each of the two sync paths owns its session and
the bump must commit together with the KB changes it witnessed.
"""
row = db.get(SourcesMeta, 1)
if row is None:
row = SourcesMeta(id=1, version=0)
db.add(row)
row.version += 1
db.flush()
return row.version
+16 -2
View File
@@ -387,7 +387,7 @@ def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHand
class SavedChatOut(BaseModel):
"""One saved chat, full payload (create/get/put response, phase 50;
``share_url``, phase 51 task 02).
``share_url``, phase 51 task 02; ``stale``, phase 53 task 03).
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction.
@@ -395,6 +395,13 @@ class SavedChatOut(BaseModel):
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
shared, ABSENT from the JSON when unshared (``None`` → dropped by
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
``stale`` (phase 53): true iff the row's ``sources_version`` stamp
is behind the current ``sources_meta`` generation — the answer
predates the latest KB-changing sync. Computed server-side (the
client never does staleness math); ``SharedChatOut`` deliberately
carries no staleness surface (the public snapshot is frozen by
design, phase 51).
"""
id: uuid.UUID
@@ -404,6 +411,9 @@ class SavedChatOut(BaseModel):
message_count: int
messages: list[ChatMessage]
share_url: str | None = None
#: Required (no default): the API must always compute staleness
#: server-side — there is no wire shape without the flag.
stale: bool
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
@@ -418,7 +428,9 @@ class SavedChatRow(BaseModel):
(Title, Messages count, Updated). ``share_url`` is populated here so
the History page's Share column renders straight from ``GET
/api/chats`` — no second fetch per row (``None`` → absent, the same
omission rule as :class:`SavedChatOut`).
omission rule as :class:`SavedChatOut`). ``stale`` (phase 53) feeds
the History page's Stale column the same way: one ``GET`` powers
every column.
"""
id: uuid.UUID
@@ -426,6 +438,8 @@ class SavedChatRow(BaseModel):
updated_at: datetime
message_count: int
share_url: str | None = None
#: Required (no default) — see :attr:`SavedChatOut.stale`.
stale: bool
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: