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)
|
||||
|
||||
+32
-1
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user