feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
@@ -10,7 +10,11 @@ fallback), the list order (``updated_at desc, id desc``), the
|
||||
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
|
||||
``sources``/``thinking``/``tools``/``stopped`` survives losslessly),
|
||||
the PUT upsert semantics (replacement + title-keep + title-set +
|
||||
``updated_at`` bump), and the delete 404/204.
|
||||
``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the
|
||||
sources-version stamp + ``stale`` flag: create and re-Save stamp the
|
||||
row's ``sources_version``, list/detail expose ``stale`` (true iff the
|
||||
stamp is behind the current generation — computed server-side), and
|
||||
share/unshare stay version-immune.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
@@ -31,7 +35,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import SavedChat
|
||||
from app.models import SavedChat, SourcesMeta
|
||||
from app.rag.sources_meta import bump_sources_version
|
||||
|
||||
FIRST_QUESTION = "How did I install gitlab?"
|
||||
EXPLICIT_TITLE = "My backup notes"
|
||||
@@ -62,8 +67,16 @@ FULL_BRAIN: dict[str, Any] = {
|
||||
"stopped": False,
|
||||
}
|
||||
|
||||
OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"}
|
||||
ROW_KEYS = {"id", "title", "updated_at", "message_count"}
|
||||
OUT_KEYS = {
|
||||
"id",
|
||||
"title",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"message_count",
|
||||
"messages",
|
||||
"stale", # phase 53: server-computed staleness flag
|
||||
}
|
||||
ROW_KEYS = {"id", "title", "updated_at", "message_count", "stale"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -76,6 +89,45 @@ def clean_chats(db: Session) -> Iterator[None]:
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def seeded_sources_meta(db: Session) -> Iterator[None]:
|
||||
"""The ``sources_meta`` counter (phase 53) is global state: reset
|
||||
to the migration-0010 seed (id 1, version 0 — "the pre-counter
|
||||
KB") around every test, so each test starts from a known
|
||||
generation and the dev DB is left exactly as the migration left
|
||||
it."""
|
||||
db.execute(text("DELETE FROM sources_meta"))
|
||||
db.add(SourcesMeta(id=1, version=0))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM sources_meta"))
|
||||
db.add(SourcesMeta(id=1, version=0))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _bump(db: Session) -> int:
|
||||
"""One simulated KB-changing sync: a single committed bump
|
||||
(task 02's change-gate lives in the sync paths themselves; the API
|
||||
tests only need the counter's contract — caller commits).
|
||||
"""
|
||||
version = bump_sources_version(db)
|
||||
db.commit() # the helper only flushes
|
||||
return version
|
||||
|
||||
|
||||
def _stored_sources_version(db: Session, chat_id: str) -> int:
|
||||
"""The row's ``sources_version`` via raw SQL — deliberately
|
||||
bypassing the session's identity map, because the API's commits
|
||||
land in the app's own sessions (a cached ORM object could be
|
||||
stale)."""
|
||||
return int(
|
||||
db.execute(
|
||||
text("SELECT sources_version FROM saved_chats WHERE id = :id"),
|
||||
{"id": chat_id},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _user(text: str) -> dict[str, Any]:
|
||||
return {"who": "user", "text": text}
|
||||
|
||||
@@ -716,6 +768,141 @@ def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> N
|
||||
assert set(got2) == OUT_KEYS
|
||||
|
||||
|
||||
# ---------- sources-version stamp + stale flag (phase 53, task 03) ----------
|
||||
|
||||
|
||||
def test_create_stamps_current_sources_version(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""``POST`` stamps the PENDING row with the current generation —
|
||||
it ships in the same INSERT (the ``share_token`` precedent), and
|
||||
the 201 body reports ``stale: false`` (a fresh save is by
|
||||
definition current)."""
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
assert created["stale"] is False, "a fresh save is never stale"
|
||||
assert _stored_sources_version(db, created["id"]) == 0 # seed generation
|
||||
|
||||
# After a KB-changing sync (one bump), the NEXT save stamps the
|
||||
# new generation and is still fresh.
|
||||
assert _bump(db) == 1
|
||||
created2 = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user("another question")]}
|
||||
).json()
|
||||
assert _stored_sources_version(db, created2["id"]) == 1
|
||||
assert created2["stale"] is False
|
||||
|
||||
|
||||
def test_pre_counter_row_goes_stale_on_the_first_bump(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Recorded assumption: pre-existing rows (saved before the counter
|
||||
existed) stamp 0 — "the pre-counter KB" — and go stale on the
|
||||
first bump (0 < 1). A row inserted directly with the column's
|
||||
server default mirrors such a legacy row."""
|
||||
db.add(SavedChat(title="legacy", messages=[_user("old question")]))
|
||||
db.commit() # no sources_version supplied → server default 0
|
||||
|
||||
listing = admin_client.get("/api/chats").json()["chats"][0]
|
||||
assert listing["stale"] is False, "0 == 0: current at the pre-counter KB"
|
||||
|
||||
assert _bump(db) == 1
|
||||
listing = admin_client.get("/api/chats").json()["chats"][0]
|
||||
assert listing["stale"] is True, "0 < 1: the first bump stale-s it"
|
||||
|
||||
|
||||
def test_list_and_detail_report_stale_after_bump(admin_client: TestClient, db: Session) -> None:
|
||||
"""The staleness flag is computed server-side in BOTH admin read
|
||||
shapes: after a bump, the list row and the detail payload of a
|
||||
row saved at the older generation report ``stale: true`` (and a
|
||||
second bump — still behind — stays stale)."""
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
assert created["stale"] is False
|
||||
|
||||
assert _bump(db) == 1
|
||||
row = admin_client.get("/api/chats").json()["chats"][0]
|
||||
assert row["stale"] is True
|
||||
assert set(row) == ROW_KEYS # stale sits in the standard row shape
|
||||
|
||||
detail = admin_client.get(f"/api/chats/{created['id']}").json()
|
||||
assert detail["stale"] is True
|
||||
assert set(detail) == OUT_KEYS
|
||||
|
||||
assert _bump(db) == 2 # still behind (stamp 0 < 2) → still stale
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
|
||||
|
||||
|
||||
def test_resave_restamps_and_clears_stale(admin_client: TestClient, db: Session) -> None:
|
||||
"""A Re-Save (``PUT``) re-stamps the row to the CURRENT generation
|
||||
unconditionally — the owner is affirming this content against the
|
||||
current KB — so the 200 body reports ``stale: false`` again and
|
||||
the stored stamp advances (the manual escape hatch for a
|
||||
false-positive stale row)."""
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
assert _bump(db) == 1
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
|
||||
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}", json={"messages": _simple_conversation()}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stale"] is False, "the re-Save affirms against generation 1"
|
||||
assert _stored_sources_version(db, created["id"]) == 1
|
||||
|
||||
# A later bump stale-s it again — the stamp is a point in time, not
|
||||
# a sticky flag.
|
||||
assert _bump(db) == 2
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
|
||||
|
||||
|
||||
def test_share_and_unshare_leave_sources_version_untouched(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Share/unshare write ONLY ``share_token`` (raw SQL, the
|
||||
phase-51 contract) — the version stamp, like ``updated_at``, is
|
||||
immune: neither action can (un-)stale a chat, and staleness stays
|
||||
a pure function of the saved generation vs the current one."""
|
||||
assert _bump(db) == 1 # a non-zero stamp makes the assert observable
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
assert _stored_sources_version(db, created["id"]) == 1
|
||||
|
||||
_share(admin_client, created["id"])
|
||||
assert _stored_sources_version(db, created["id"]) == 1
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False
|
||||
|
||||
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
|
||||
assert _stored_sources_version(db, created["id"]) == 1
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False
|
||||
|
||||
|
||||
def test_public_read_snapshot_has_no_stale_surface(
|
||||
admin_client: TestClient, db: Session,
|
||||
) -> None:
|
||||
"""``/api/shared/<token>`` is a FROZEN snapshot by design (phase
|
||||
51): even after a bump, the anonymous body keeps exactly its
|
||||
title+messages key set — no ``stale`` flag, no staleness surface
|
||||
(an owner who regenerates can re-share afterwards)."""
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation(), "share": True}
|
||||
).json()
|
||||
assert _bump(db) == 1 # the saved chat is now stale (admin surface)
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
r = anon.get(f"/api{created['share_url']}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == SHARED_OUT_KEYS # no stale key — frozen snapshot
|
||||
assert "stale" not in body
|
||||
|
||||
|
||||
# ---------- the /shared/<token> page route (phase 51, task 01) ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user