feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""sources_meta + saved_chats.sources_version: the KB-generation stamp (phase 53)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-08-30
|
||||
|
||||
Phase 53 (invalidate saved chats on sources sync, TODO-derived
|
||||
2026-08-30): a saved answer can silently predate the current index,
|
||||
because neither sync path (the admin Sync button,
|
||||
``scripts/import_docs.py``) records *when the KB last changed*. ONE
|
||||
migration carries the whole feature (A13 — one feature, one atomic,
|
||||
reversible schema change):
|
||||
|
||||
* ``sources_meta`` — a **single-row** counter (``id INTEGER PK
|
||||
DEFAULT 1``, the ``kb_overview`` phase-31 precedent) holding the
|
||||
current **generation** of the knowledge base. The seed row
|
||||
(id 1, version 0) is inserted here — not lazily on first bump — so
|
||||
``app.rag.sources_meta.current_sources_version`` is a plain PK read.
|
||||
``version`` is bumped exactly once per KB-changing sync (task 02,
|
||||
change-gated on ``added + updated + pruned > 0``); a failed sync
|
||||
never bumps.
|
||||
* ``saved_chats.sources_version`` — ``INTEGER NOT NULL DEFAULT 0``:
|
||||
the generation the conversation was saved against, stamped by the
|
||||
chats API at save time (task 03). Pre-0010 rows come back stamped 0
|
||||
(the pre-counter KB) and go stale on the first bump.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sources_meta",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, server_default=sa.text("1")),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
# Seed the single row (id 1, version 0): "the pre-counter KB".
|
||||
op.execute(
|
||||
"INSERT INTO sources_meta (id, version, updated_at) VALUES (1, 0, now())"
|
||||
)
|
||||
op.add_column(
|
||||
"saved_chats",
|
||||
sa.Column(
|
||||
"sources_version", sa.Integer(), nullable=False, server_default=sa.text("0")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Safe order: drop the stamp column first, then the counter table.
|
||||
op.drop_column("saved_chats", "sources_version")
|
||||
op.drop_table("sources_meta")
|
||||
+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:
|
||||
|
||||
+126
-2
@@ -177,6 +177,32 @@
|
||||
* in hint, like Save); a network failure → the "is the app reachable?"
|
||||
* banner.
|
||||
*
|
||||
* Stale saved chats (phase 53, TODO.md L4): every sync that changes the
|
||||
* knowledge base bumps the sources generation; a row saved against an
|
||||
* older one is STALE (server-computed `stale` on GET /api/chats/<id> —
|
||||
* the client never does staleness math). The /?chat=<id> boot load
|
||||
* reveals the #stale-banner (top of the column, directly below
|
||||
* #kb-banner) when the fetched payload reports `stale: true`. A stale
|
||||
* conversation with NO brain record is revealed text-only — the
|
||||
* #stale-regenerate button is removed (retryLastTurn is never called in
|
||||
* that state). Regenerate = the phase-49 redo-in-place of the LAST
|
||||
* brain bubble ONLY: retryLastTurn(lastBrainWrap) re-asks the last
|
||||
* question against the new index (full conversation context kept; earlier
|
||||
* answers are not re-run), and retryLastTurn now RETURNS the
|
||||
* runTurn promise so the handler can await the turn's completion
|
||||
* (behavior-neutral for the existing Retry click, which ignores it).
|
||||
* Only when the turn completes WITHOUT the error banner does the handler
|
||||
* persist the linked row through the SAME upsert as Save — PUT
|
||||
* /api/chats/<id> (the server re-stamps sources_version → stale: false);
|
||||
* a 404 (row deleted from History meanwhile) unlinks and recreates
|
||||
* (saveCurrentChat's stale-link rule). A regenerate that errors
|
||||
* mid-stream leaves the row untouched (stale stays true); a regenerate
|
||||
* STOPPED mid-stream (phase 48) persists the stopped partial. Success
|
||||
* hides the banner and announces in the #send-status live region
|
||||
* (PLAN §7.4 never-stale). The banner also clears on "New chat" and on
|
||||
* a successful manual re-Save (both make the row/conversation no longer
|
||||
* the one the banner describes).
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -201,6 +227,8 @@ const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
|
||||
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden)
|
||||
const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden)
|
||||
const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill
|
||||
|
||||
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
|
||||
* (the classic assets/brand.js sets it at parse time; its /api/config
|
||||
@@ -1100,6 +1128,18 @@ async function restoreSavedChatFromUrl() {
|
||||
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
|
||||
currentChatId = chatId; // linked: a subsequent Save updates THIS row
|
||||
saveConversation(); // mirror to localStorage — a plain refresh returns here
|
||||
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
|
||||
// the row's sources stamp is behind the current generation; the
|
||||
// client never does staleness math). Reveal the banner; when the
|
||||
// conversation has NO brain record there is nothing to regenerate,
|
||||
// so the button is removed first (text-only — retryLastTurn is never
|
||||
// called in that state).
|
||||
if (data.stale === true) {
|
||||
if (!conversation.some((m) => m.who === "brain") && staleRegenBtn) {
|
||||
staleRegenBtn.remove(); // no brain answer — nothing to regenerate
|
||||
}
|
||||
if (staleBanner) staleBanner.hidden = false;
|
||||
}
|
||||
// The ?chat= param is a one-shot boot instruction: normalize the URL
|
||||
// back to / so a later refresh / "New chat" + refresh restores the
|
||||
// LOCAL session (the mirror above) instead of re-opening this row.
|
||||
@@ -1151,6 +1191,9 @@ async function saveCurrentChat() {
|
||||
currentChatId = String(created.id); // fresh Save: link to the new row
|
||||
}
|
||||
sendStatus.textContent = "Conversation saved.";
|
||||
// Phase 53: a re-Save re-stamps the row to the current generation
|
||||
// (task 03) — the row is no longer stale, so the banner is done.
|
||||
if (staleBanner) staleBanner.hidden = true;
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the conversation — is the app reachable?");
|
||||
} finally {
|
||||
@@ -1268,6 +1311,75 @@ async function shareCurrentChat() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Regenerate a stale saved chat — the #stale-regenerate handler
|
||||
* (phase 53, task 05). The banner only ever shows on the /?chat=<id>
|
||||
* boot path (admin), so currentChatId is set whenever this runs. The
|
||||
* redo: retryLastTurn(lastBrainWrap) — the phase-49 redo-in-place of
|
||||
* the LAST brain bubble (its own guards — in-flight, wrap !==
|
||||
* lastBrainWrap, no preceding user record — make a stale or superseded
|
||||
* click a no-op that resolves nothing). The handler AWAITs the returned
|
||||
* turn promise, and only when the turn completed WITHOUT the error
|
||||
* banner persists the linked row through the SAME upsert as Save:
|
||||
* PUT /api/chats/<id> (the server re-stamps sources_version → the row
|
||||
* is fresh again); a 404 (the row was deleted from History meanwhile)
|
||||
* follows saveCurrentChat's stale-link rule — unlink + recreate, so the
|
||||
* owner is never left with an unsaved conversation. A regenerate that
|
||||
* errors mid-stream leaves the row untouched (stale stays true —
|
||||
* Regenerate stays available); a regenerate STOPPED mid-stream (phase
|
||||
* 48) persists the stopped partial (the owner engaged with the new
|
||||
* index). Success hides the banner and announces the outcome in the
|
||||
* #send-status live region (PLAN §7.4 never-stale). */
|
||||
async function regenerateStaleChat() {
|
||||
if (staleRegenBtn?.disabled) return; // one regenerate at a time (double-click guard)
|
||||
staleRegenBtn.disabled = true;
|
||||
try {
|
||||
// Phase-49 targeting: the LAST brain bubble's rendered wrap. When a
|
||||
// guard no-ops the redo (no brain bubble — the no-brain-record state
|
||||
// that removed the button at reveal; in-flight turn; superseded
|
||||
// wrap), retryLastTurn returns nothing and there is nothing to
|
||||
// await or persist.
|
||||
const turn = lastBrainWrap ? retryLastTurn(lastBrainWrap) : undefined;
|
||||
if (!turn) return;
|
||||
await turn; // the turn's completion — runTurn settles to idle always
|
||||
// A regenerate that errored mid-stream (the error banner is up) leaves
|
||||
// the linked row untouched — the row stays stale, the banner stays.
|
||||
if (banner.classList.contains("is-error")) return;
|
||||
// Persist the linked row through the SAME upsert as Save: PUT (the
|
||||
// server re-stamps sources_version — the row is fresh again); a 404
|
||||
// (deleted from History meanwhile) unlinks and recreates.
|
||||
const body = JSON.stringify({ messages: conversation });
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
let res;
|
||||
if (currentChatId) {
|
||||
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
|
||||
if (res.status === 404) {
|
||||
// Stale link: the row is gone (deleted from History) — unlink
|
||||
// and retry as a create, so the save never silently dies.
|
||||
currentChatId = null;
|
||||
res = await fetch("/api/chats", { method: "POST", headers, body });
|
||||
}
|
||||
} else {
|
||||
res = await fetch("/api/chats", { method: "POST", headers, body });
|
||||
}
|
||||
if (!res.ok) {
|
||||
showErrorBanner(
|
||||
"Couldn't save the regenerated answer — check you're still signed in and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 201) {
|
||||
const created = await res.json();
|
||||
currentChatId = String(created.id); // the recreate: link the new row
|
||||
}
|
||||
staleBanner.hidden = true; // fresh row — the banner is done
|
||||
sendStatus.textContent = "Regenerated — the answer now reflects the current sources.";
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the regenerated answer — is the app reachable?");
|
||||
} finally {
|
||||
if (staleRegenBtn) staleRegenBtn.disabled = false; // released on EVERY outcome
|
||||
}
|
||||
}
|
||||
|
||||
/* Brain message save point (on `done`): raw accumulated text + metadata.
|
||||
Phase 17: meta.thinking and phase 37: meta.tools are optional —
|
||||
`undefined` drops the key from the JSON, so turns without them persist
|
||||
@@ -1319,6 +1431,7 @@ function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
conversation = [];
|
||||
currentChatId = null; // phase 50: a new conversation is unlinked until saved
|
||||
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
|
||||
clearStoredConversation();
|
||||
removeTyping();
|
||||
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
|
||||
@@ -1377,7 +1490,12 @@ function stopTurn() {
|
||||
* no scroll (phase 42: the fresh bubble lands where the old one was).
|
||||
* Guards: inert while a turn is in flight (one turn at a time), and the
|
||||
* click's wrap must still be the last brain bubble's rendered wrap — a
|
||||
* stale click on a superseded bubble is harmless by construction. */
|
||||
* stale click on a superseded bubble is harmless by construction.
|
||||
* Phase 53 (task 05): RETURNS the runTurn promise when the redo runs
|
||||
* (undefined when a guard no-ops it) — the stale banner's Regenerate
|
||||
* path awaits the turn's completion to know when to persist the linked
|
||||
* row. The existing Retry click handler ignores the return value, so
|
||||
* phase-49 behavior is unchanged. */
|
||||
function retryLastTurn(wrap) {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
|
||||
@@ -1403,7 +1521,9 @@ function retryLastTurn(wrap) {
|
||||
lastBrainWrap = null;
|
||||
// Re-ask without re-adding: the reask turn skips the user append and
|
||||
// persistence save point 1 (the question is already in both).
|
||||
void runTurn(text, { reask: true });
|
||||
// Phase 53: the promise is returned (the Regenerate await above);
|
||||
// runTurn never rejects — a failure surfaces as the error banner.
|
||||
return runTurn(text, { reask: true });
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
@@ -1686,6 +1806,10 @@ saveBtn?.addEventListener("click", saveCurrentChat);
|
||||
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill —
|
||||
* same ship-hidden/reveal contract as Save (the boot IIFE below). */
|
||||
shareBtn?.addEventListener("click", shareCurrentChat);
|
||||
/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding
|
||||
* is inert unless the banner is revealed — which only happens on the
|
||||
* /?chat=<id> boot path (admin, task-50 contract). */
|
||||
staleRegenBtn?.addEventListener("click", regenerateStaleChat);
|
||||
|
||||
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
|
||||
* leaving the chat mid-turn would otherwise drop the in-flight
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
* into the saved conversation through ?chat= (task 03);
|
||||
* • Messages — the row's message_count;
|
||||
* • Updated — locale date+time, the full ISO in the title attribute;
|
||||
* • Stale — phase 53 (task 04): the READ-ONLY staleness marker,
|
||||
* rendered from the row's `stale` flag (the server computes it —
|
||||
* the client never does staleness math): a rose "Stale" pill when
|
||||
* the row was saved before the last KB-changing sync, an em-dash
|
||||
* when fresh. The Regenerate action is NOT here — it lives on the
|
||||
* chat-page banner (task 05); opening the row is the action;
|
||||
* • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
|
||||
* row's share state, rendered from the list's OWN share_url (the
|
||||
* GET /api/chats endpoint populates it — no second fetch per row).
|
||||
@@ -105,6 +111,28 @@ function makeRow(chat) {
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
|
||||
// the READ-ONLY staleness marker. `chat.stale` is computed server-
|
||||
// side (task 03), so this branches on the flag, never on versions.
|
||||
// Stale rows get the rose pill (the exact hover copy points at the
|
||||
// Regenerate action on the chat page, task 05); fresh rows get a
|
||||
// plain em-dash. The <td> carries its own aria-label in BOTH states
|
||||
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
|
||||
const staleTd = document.createElement("td");
|
||||
staleTd.className = "history-stale-cell";
|
||||
if (chat.stale) {
|
||||
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "stale-pill";
|
||||
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
|
||||
pill.textContent = "Stale";
|
||||
staleTd.appendChild(pill);
|
||||
} else {
|
||||
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
|
||||
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
|
||||
}
|
||||
tr.appendChild(staleTd);
|
||||
|
||||
// Phase 51: the Share cell (between Updated and Actions) — the
|
||||
// three-state share control (unshared / shared / confirming-unshare).
|
||||
const shareTd = document.createElement("td");
|
||||
|
||||
@@ -1267,6 +1267,42 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
|
||||
/* Phase 53 (task 05): the stale-saved-chat banner — the .kb-banner
|
||||
FAMILY (the section carries both classes: same flex row, accent
|
||||
tokens — accent-ink on accent-bg 9.5:1 — 18px glyph), but the
|
||||
leading mark is the REDO glyph, distinct from the warning triangle.
|
||||
It sits directly after #kb-banner in .chat-shell, so when both are
|
||||
visible the empty-KB banner keeps the top slot and the stale banner
|
||||
stacks directly below (the flex column's gap spaces them). The text
|
||||
takes the row; the Regenerate pill right-aligns (margin-left: auto)
|
||||
and the row wraps only when it must. The pill is the EXACT
|
||||
brand-pill family of Save/Share: solid --brand, --bg text (5.2:1,
|
||||
AA), borderless, 999px radius, ≥44px target, hover lightens the
|
||||
brand fill; the 16px redo glyph is the phase-49 Retry asset. The
|
||||
≤640px block below makes the pill a full-width row. */
|
||||
.stale-banner { flex-wrap: wrap; }
|
||||
.stale-banner > span { flex: 1 1 auto; }
|
||||
.stale-regenerate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
margin-left: auto;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stale-regenerate:hover { background: #f55a72; color: var(--bg); }
|
||||
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
|
||||
.stale-regenerate svg { width: 16px; height: 16px; display: block; }
|
||||
|
||||
/* ---------- Login page (phase 16) ---------- */
|
||||
/* Centered card in the standard frame: one admin, one password. */
|
||||
@@ -1930,6 +1966,27 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
/* Updated: locale date+time (ink-soft), the full ISO in the title
|
||||
attribute (history.js). */
|
||||
.history-updated-cell { color: var(--ink-soft); white-space: nowrap; }
|
||||
/* Stale marker (phase 53, task 04): the READ-ONLY staleness badge on
|
||||
rows saved before the last KB-changing sync (the Regenerate action
|
||||
lives on the chat-page banner — the marker is a pill, never a
|
||||
control). The rose family, i.e. the Stop-treatment tokens, so
|
||||
"stale" reads in the same visual language as the in-flight control:
|
||||
err-ink on err-bg ≈9.3:1 (≥4.5:1 on --surface too), err-line
|
||||
border — theme tokens, AA in the palette as a whole. Fresh rows'
|
||||
em-dash rides the cell's ink-soft (5.1:1 on --surface). */
|
||||
.history-stale-cell { color: var(--ink-soft); white-space: nowrap; }
|
||||
.stale-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: 999px;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Actions: the Delete ghost button (the tuning row-action language)
|
||||
+ the inline two-step confirm pair (phase 50 task 04). */
|
||||
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
@@ -2650,6 +2707,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.chat-shell .save-chat-btn svg { display: none; }
|
||||
.chat-shell .share-chat-label { display: inline; }
|
||||
.chat-shell .share-chat-btn svg { display: none; }
|
||||
/* Phase 53: the stale banner's row wraps at phone width (message
|
||||
above, action below) — the pill takes a full-width comfortable
|
||||
row instead of squeezing into the text. */
|
||||
.stale-regenerate { margin-left: 0; width: 100%; }
|
||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||
.auth-link { padding: 0.4rem 0.3rem; }
|
||||
|
||||
@@ -135,7 +135,10 @@
|
||||
|
||||
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
|
||||
list): Title (the Open link → /?chat=<id>) | Messages |
|
||||
Updated | Share (phase 51: Create link / Copy / Unshare —
|
||||
Updated | Stale (phase 53: the READ-ONLY staleness marker —
|
||||
the rose pill when the row predates the last KB-changing
|
||||
sync; the Regenerate action lives on the chat-page banner,
|
||||
task 05) | Share (phase 51: Create link / Copy / Unshare —
|
||||
the row's share_url comes from GET /api/chats itself, no
|
||||
second fetch) | Actions (Delete, inline two-step confirm).
|
||||
history.js fills #history-tbody; #history-empty-row ships
|
||||
@@ -150,13 +153,14 @@
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Messages</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Stale</th>
|
||||
<th scope="col">Share</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr class="history-empty-row" id="history-empty-row" hidden>
|
||||
<td colspan="5">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
<td colspan="6">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -89,6 +89,29 @@
|
||||
<span id="kb-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Phase 53 (task 05): the stale-saved-chat banner. The /?chat=<id>
|
||||
boot load reveals it ONLY when the fetched row reports
|
||||
stale: true (the server computes it — the row's sources stamp
|
||||
is behind the current generation, task 03; the client never
|
||||
does staleness math). Regenerate (the exact brand-pill family
|
||||
of the Save/Share pair; the redo glyph is the phase-49 Retry
|
||||
asset) re-asks the last question against the new index via
|
||||
retryLastTurn and re-saves the linked row (the server
|
||||
re-stamps sources_version → stale: false), clearing the
|
||||
banner. A stale chat with no brain answer is revealed
|
||||
text-only — app.js removes the button, so retryLastTurn is
|
||||
never called. Stacks directly below #kb-banner when both are
|
||||
visible (kb-banner keeps the top slot; the .chat-shell flex
|
||||
gap spaces them). -->
|
||||
<section class="kb-banner stale-banner" id="stale-banner" role="status" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span>The sources have been updated since this chat was saved.</span>
|
||||
<button type="button" class="stale-regenerate" id="stale-regenerate">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span>Regenerate</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
|
||||
+48
-8
@@ -45,6 +45,16 @@ debug runs never burn a ``lite`` call, and a ``lite`` failure only
|
||||
reports ``overview=failed`` on the summary line: the import's exit code
|
||||
is about files, and the previous outline stays (an old outline is better
|
||||
than none).
|
||||
|
||||
A run that **changed** the knowledge base (added + updated + pruned > 0
|
||||
— phase 53, task 02) also advances the single-row ``sources_meta``
|
||||
version exactly once (``sources_version=<n>`` on the summary line): the
|
||||
generation saved chats are stamped against, so a sync can no longer
|
||||
silently invalidate a stored answer. The gate is deliberately broader
|
||||
than the overview's — a pruned document can invalidate a saved answer
|
||||
that cited it — and ``--limit`` debug runs (an incomplete walk is
|
||||
debug-only, mirroring the ``--limit`` overview skip) and unchanged
|
||||
re-runs never bump (the line carries ``sources_version=skipped``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -64,6 +74,7 @@ from app.rag.git_sources import effective_sources
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.overview import regenerate_overview
|
||||
from app.rag.sources_meta import bump_sources_version
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
|
||||
logger = logging.getLogger("scripts.import_docs")
|
||||
@@ -211,8 +222,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
llm = LLMClient()
|
||||
|
||||
async def _run() -> tuple[ImportSummary, str]:
|
||||
"""Import, then (change-gated) refresh the stored KB overview.
|
||||
async def _run() -> tuple[ImportSummary, str, str]:
|
||||
"""Import, then (change-gated) advance the sources version and
|
||||
refresh the stored KB overview.
|
||||
|
||||
One event loop, one ``LLMClient`` (phase 31, task 04): the
|
||||
outline that every chat prompt injects as ``<knowledge_base>`` is
|
||||
@@ -224,31 +236,59 @@ def main(argv: list[str] | None = None) -> int:
|
||||
unchanged re-imports never burn a ``lite`` call, and a ``lite``
|
||||
failure only flips the status token (``failed``) — the import's
|
||||
exit code is unchanged.
|
||||
|
||||
The sources version (phase 53, task 02) advances exactly once
|
||||
per run that changed the KB — change-gated on
|
||||
``added + updated + pruned > 0`` (a pruned document can
|
||||
invalidate a saved answer that cited it, so the gate is
|
||||
deliberately broader than the overview's). ``--limit`` debug
|
||||
runs and unchanged re-runs never bump. The returned token is
|
||||
the new version, or ``"skipped"``.
|
||||
"""
|
||||
summary = await import_sources(sources, llm, prune=args.prune, limit=args.limit)
|
||||
if args.limit is not None:
|
||||
# An incomplete walk is debug-only — it must never advance
|
||||
# the generation (mirrors the --limit overview skip below).
|
||||
sources_version = "skipped"
|
||||
elif summary.added + summary.updated + summary.pruned > 0:
|
||||
# The KB changed — advance the saved-chat invalidation
|
||||
# marker exactly once, in its own short session (the
|
||||
# best-effort overview below runs in a separate one, so a
|
||||
# failed outline never rolls the bump back).
|
||||
session = SessionLocal()
|
||||
try:
|
||||
new_version = bump_sources_version(session)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
sources_version = str(new_version)
|
||||
logger.info("sources: version bumped to %d", new_version)
|
||||
else:
|
||||
sources_version = "skipped"
|
||||
logger.info("sources: version bump skipped (KB unchanged)")
|
||||
if args.limit is not None:
|
||||
logger.info("overview: skipped (--limit)")
|
||||
return summary, "skipped"
|
||||
return summary, "skipped", sources_version
|
||||
if summary.added + summary.updated == 0:
|
||||
if summary.files == 0:
|
||||
logger.info("overview: skipped (nothing imported)")
|
||||
return summary, "skipped"
|
||||
return summary, "skipped", sources_version
|
||||
if _overview_row_exists():
|
||||
logger.info("overview: skipped (KB unchanged)")
|
||||
return summary, "skipped"
|
||||
return summary, "skipped", sources_version
|
||||
# No outline yet after an unchanged re-import (e.g. the first
|
||||
# run after migration 0005) — fall through and generate one.
|
||||
ok = await regenerate_overview(llm)
|
||||
return summary, "updated" if ok else "failed"
|
||||
return summary, "updated" if ok else "failed", sources_version
|
||||
|
||||
summary, overview_status = asyncio.run(_run())
|
||||
summary, overview_status, sources_version = asyncio.run(_run())
|
||||
print(
|
||||
f"import_docs: files={summary.files} added={summary.added} "
|
||||
f"updated={summary.updated} unchanged={summary.unchanged} "
|
||||
f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} "
|
||||
f"embed_batches={summary.embed_batches} summaries={summary.summaries} "
|
||||
f"summary_errors={summary.summary_errors} formats={summary.format_counts()} "
|
||||
f"overview={overview_status}"
|
||||
f"overview={overview_status} sources_version={sources_version}"
|
||||
)
|
||||
# Non-zero if any file failed, so cron/CI notice — the rest of the KB
|
||||
# was imported and the failed files are retried on the next run. The
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Phase 53 E2E (Playwright): invalidate saved chats on sources sync.
|
||||
|
||||
TODO.md L4 (owner 2026-08-30): "Make sure the saved chats are invalidated
|
||||
if the docs are synced, that way it generates a new answer with new data".
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov
|
||||
|
||||
The full user-visible invalidation loop under test:
|
||||
|
||||
* **Save (fresh)** — as admin: ask (the mock answers deterministically),
|
||||
Save via the chat-page pill; ``GET /api/chats`` (admin cookie) reports
|
||||
the row with ``stale: false``; opening the FRESH row at ``/?chat=<id>``
|
||||
shows NO banner;
|
||||
* **KB change** — the test process (which shares the app's environment)
|
||||
bumps the ``sources_meta`` seed row through ``bump_sources_version``
|
||||
over a short ``SessionLocal()`` — the exact helper BOTH real sync
|
||||
paths (the Sync button, ``scripts/import_docs``) call change-gated.
|
||||
The bump GATES are pinned by this phase's integration tests (task 02)
|
||||
and the Sync button's end-to-end clone/import path by
|
||||
``test_sync_button.py``; the E2E proves the user-visible loop, not
|
||||
git plumbing (the phase's recorded ASSUMPTION);
|
||||
* **Stale surfaced** — ``/history.html``: the row now carries the rose
|
||||
Stale pill (``aria-label``d for screen readers); ``GET /api/chats``
|
||||
and ``GET /api/chats/<id>`` carry ``stale: true`` (computed
|
||||
server-side — the client never computes staleness);
|
||||
* **Regenerate** — opening the row (``/?chat=<id>``, the same URL the
|
||||
History table links) reveals ``#stale-banner`` with the Regenerate
|
||||
button; clicking it re-streams the last answer IN PLACE against the
|
||||
new index (the phase-49 redo-in-place: the old bubble leaves the DOM,
|
||||
the question is not duplicated), and the handler then auto re-saves
|
||||
the linked row — the server re-stamps ``sources_version`` → the
|
||||
banner clears, ``GET /api/chats/<id>`` reports ``stale: false`` with
|
||||
the last brain message being the fresh answer, and the History pill
|
||||
is gone;
|
||||
* **Guard** — a stale row whose conversation has NO brain record
|
||||
(user-only) reveals the banner text WITHOUT the Regenerate button
|
||||
(``retryLastTurn`` has nothing to redo);
|
||||
* **Anonymous** — sharing the (now fresh) chat and opening
|
||||
``/shared/<token>`` without a session renders the phase-51 snapshot
|
||||
with NO staleness surface — neither in the DOM nor on the public
|
||||
``SharedChatOut`` wire (the snapshot is frozen by design).
|
||||
|
||||
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
|
||||
suites, so every test here uses a DISTINCTIVE question text (its
|
||||
auto-title is therefore unique), never asserts on absolute row counts,
|
||||
and deletes the rows it creates in a ``finally`` (admin cookie). The KB
|
||||
tables are truncated + re-seeded the house way (deterministic mock
|
||||
embeddings); ``saved_chats`` and ``sources_meta`` are never touched by
|
||||
the reset — the version is monotonic by design, and rows stamped
|
||||
against an older generation are simply stale (that is the point).
|
||||
|
||||
Determinism: the mock quotes the asked question into its grounded
|
||||
answer (ending in the ``Deterministic mock answer for E2E`` marker),
|
||||
so the regenerated answer is textually identical to the stale one —
|
||||
OLD-vs-fresh bubble identity is proved the phase-49 way, with a
|
||||
test-only ``data-retry-marker`` attribute set on the old wrap before
|
||||
the click.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Browser, BrowserContext, Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import SavedChat
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.sources_meta import bump_sources_version, current_sources_version
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
#: The banner's line (frontend/index.html, task 05).
|
||||
BANNER_TEXT = "The sources have been updated since this chat was saved."
|
||||
#: The live-region outcome of a successful Regenerate (app.js, task 05).
|
||||
REGEN_STATUS = "Regenerated — the answer now reflects the current sources."
|
||||
#: The fresh row's Stale cell (history.js, task 04).
|
||||
FRESH_STALE_CELL = "—"
|
||||
#: The stale row's Stale cell aria-label (history.js, task 04).
|
||||
STALE_CELL_ARIA = "Stale — sources have changed since this chat was saved"
|
||||
#: The typing indicator is itself a .msg.brain — exclude its bubble.
|
||||
ANSWER = ".msg.brain .bubble:not(.typing)"
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return await import_sources([FIXTURES], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread.
|
||||
|
||||
Playwright's sync API keeps an asyncio loop running on the test
|
||||
thread, so ``asyncio.run`` cannot be called directly from a test body.
|
||||
"""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
"""Truncate the KB (and query log + steering notes — deterministic
|
||||
mock answers), then optionally re-import fixtures. ``saved_chats``
|
||||
and ``sources_meta`` are deliberately NOT touched: rows persist
|
||||
across suites (every test cleans up after itself) and the version
|
||||
is monotonic (the invalidation marker)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes")
|
||||
)
|
||||
db.commit()
|
||||
if not seed:
|
||||
return None
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _bump_sources_version() -> int:
|
||||
"""The test-only stand-in for a KB-changing sync (task 02): the SAME
|
||||
``bump_sources_version`` helper both real sync paths call
|
||||
change-gated, over a short session (bump flushes, the caller
|
||||
commits). The bump GATES — changed/unchanged/`--limit`/failed — are
|
||||
pinned by this phase's integration tests, not by the E2E."""
|
||||
with SessionLocal() as db:
|
||||
v = bump_sources_version(db)
|
||||
db.commit()
|
||||
return v
|
||||
|
||||
|
||||
def _current_version() -> int:
|
||||
with SessionLocal() as db:
|
||||
return current_sources_version(db)
|
||||
|
||||
|
||||
def _row_stamp(chat_id: str) -> int:
|
||||
"""The saved row's ``sources_version`` stamp (helper read — the
|
||||
same pattern as the phase's integration tests)."""
|
||||
with SessionLocal() as db:
|
||||
row = db.get(SavedChat, uuid.UUID(chat_id))
|
||||
assert row is not None
|
||||
return row.sources_version
|
||||
|
||||
|
||||
def _ask(page: Page, question: str) -> None:
|
||||
"""Send one turn and wait until the grounded answer has fully
|
||||
landed (the ``done`` event restored the Send button)."""
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def _save(page: Page) -> None:
|
||||
"""Press Save and wait for the live-region confirmation (the
|
||||
never-stale contract: the status line is the success feedback)."""
|
||||
page.locator("#save-chat-btn").click()
|
||||
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form login —
|
||||
used to call the admin API with plain httpx (the test's API side
|
||||
sees exactly what the signed-in browser sees)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()["chats"]
|
||||
|
||||
|
||||
def _chat(app_url: str, cookies: dict[str, str], chat_id: str) -> dict[str, Any]:
|
||||
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
def _auto_title(question: str) -> str:
|
||||
"""The phase-50 auto-title convention: the first question,
|
||||
whitespace-collapsed, capped at 120 chars."""
|
||||
return " ".join(question.split())[:120]
|
||||
|
||||
|
||||
def _find_row(
|
||||
rows: list[dict[str, Any]], title: str
|
||||
) -> dict[str, Any] | None:
|
||||
return next((c for c in rows if c["title"] == title), None)
|
||||
|
||||
|
||||
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _history_row(page: Page, chat_id: str) -> Any:
|
||||
"""The History table row for this chat (located through the Open
|
||||
link — ``/?chat=<id>`` — the same URL the table links)."""
|
||||
return page.locator(
|
||||
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
|
||||
)
|
||||
|
||||
|
||||
def _assert_no_error_banner(page: Page) -> None:
|
||||
"""Regenerate settles through the normal done path — never the red
|
||||
role=alert error banner (the KB-offline banner is a separate,
|
||||
health-driven state the db_ready fixture keeps away)."""
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_be_hidden()
|
||||
expect(banner).not_to_have_class("is-error")
|
||||
|
||||
|
||||
def _tag_last_brain_wrap(page: Page, marker: str) -> None:
|
||||
"""Tag the rendered wrap of the LAST brain bubble (settled state —
|
||||
no typing indicator present) so the test can prove the OLD element
|
||||
leaves the DOM: the mock answers are byte-stable, so the redo of
|
||||
the same question is textually indistinguishable from the original."""
|
||||
page.evaluate(
|
||||
"""(marker) => {
|
||||
const wraps = document.querySelectorAll("#messages > .msg.brain");
|
||||
wraps[wraps.length - 1].setAttribute("data-retry-marker", marker);
|
||||
}""",
|
||||
marker,
|
||||
)
|
||||
|
||||
|
||||
def _regenerate_in_place(page: Page, marker: str) -> None:
|
||||
"""Click the banner's Regenerate and wait for the FULL phase-49
|
||||
redo-in-place + auto re-save to settle: the turn goes in-flight
|
||||
(the button IS Stop), the old bubble leaves the DOM before the
|
||||
first fresh token, the fresh answer lands, and the handler's
|
||||
post-turn PUT (the server re-stamps ``sources_version``) clears the
|
||||
banner and lands the outcome on the live region."""
|
||||
_tag_last_brain_wrap(page, marker)
|
||||
regen = page.locator("#stale-regenerate")
|
||||
expect(regen).to_be_enabled()
|
||||
regen.click()
|
||||
# In flight: the redo owns the Send/Stop control…
|
||||
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
|
||||
# …and the double-click guard holds (one regenerate at a time).
|
||||
expect(regen).to_be_disabled()
|
||||
# Redo in place: the OLD wrap is already gone (the phase-49
|
||||
# contract — removal precedes the rerun).
|
||||
expect(page.locator(f"[data-retry-marker='{marker}']")).to_have_count(0)
|
||||
# The fresh answer streams into its place and the turn settles.
|
||||
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
_assert_no_error_banner(page)
|
||||
# Post-turn: the linked row was re-saved (the server re-stamped
|
||||
# it), so the banner cleared and the outcome is announced.
|
||||
expect(page.locator("#stale-banner")).to_be_hidden(timeout=15_000)
|
||||
expect(page.locator("#send-status")).to_have_text(REGEN_STATUS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The full loop: save (fresh) → KB change → stale surfaced → Regenerate
|
||||
# → fresh again (UI + API agree at every step)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_invalidation_loop(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (stale-loop)"
|
||||
_ask(page, q)
|
||||
|
||||
# --- Save (fresh): the API agrees, and a fresh open shows NO banner.
|
||||
_save(page)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _find_row(_chats(app_url, cookies), _auto_title(q))
|
||||
assert row is not None, "the saved chat row must exist"
|
||||
assert row["message_count"] == 2
|
||||
assert row["stale"] is False, "a just-saved chat is fresh, not stale"
|
||||
chat_id: str = row["id"]
|
||||
# The DB agrees: Save stamped the row with the CURRENT generation
|
||||
# (helper read — the integration suite pins the same contract).
|
||||
assert _row_stamp(chat_id) == _current_version(), (
|
||||
"Save must stamp sources_version with the current generation"
|
||||
)
|
||||
try:
|
||||
# A fresh row opened at /?chat=<id> shows NO stale banner.
|
||||
page.goto(app_url + f"/?chat={chat_id}")
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
expect(page.locator(ANSWER)).to_have_count(1)
|
||||
expect(page.locator("#stale-banner")).to_be_hidden()
|
||||
expect(page.locator("#stale-regenerate")).to_be_hidden()
|
||||
|
||||
# --- KB change: the version bumps (the test-only stand-in for a
|
||||
# KB-changing sync — see the module docstring / task ASSUMPTION).
|
||||
bumped = _bump_sources_version()
|
||||
assert bumped > 0
|
||||
|
||||
# The API agrees the row is stale NOW (server-computed — both
|
||||
# the detail and the list shapes carry the flag):
|
||||
detail = _chat(app_url, cookies, chat_id)
|
||||
assert detail["stale"] is True
|
||||
mine = _find_row(_chats(app_url, cookies), _auto_title(q))
|
||||
assert mine is not None and mine["stale"] is True
|
||||
|
||||
# --- History: the row carries the rose Stale pill (and its
|
||||
# aria-label, so the marker is conveyed without the visual).
|
||||
page.goto(app_url + "/history.html")
|
||||
tr = _history_row(page, chat_id)
|
||||
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
|
||||
pill = tr.locator(".stale-pill")
|
||||
expect(pill).to_have_count(1)
|
||||
expect(pill).to_have_text("Stale")
|
||||
expect(tr.locator(".history-stale-cell")).to_have_attribute(
|
||||
"aria-label", STALE_CELL_ARIA
|
||||
)
|
||||
|
||||
# --- Open the row (the SAME URL the History table links): the
|
||||
# banner reveals, with the Regenerate button.
|
||||
tr.locator("a.history-title-link").click()
|
||||
banner = page.locator("#stale-banner")
|
||||
expect(banner).to_be_visible(timeout=15_000)
|
||||
expect(banner).to_have_attribute("role", "status")
|
||||
expect(banner).to_contain_text(BANNER_TEXT)
|
||||
expect(page.locator("#stale-regenerate")).to_have_text("Regenerate")
|
||||
# The conversation restored in full (one question, one answer).
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(ANSWER)).to_have_count(1)
|
||||
|
||||
# --- Regenerate: the fresh answer streams in place against the
|
||||
# new index, and the handler re-saves the linked row.
|
||||
_regenerate_in_place(page, "stale-old")
|
||||
|
||||
# Redo mechanics: the question was NOT duplicated, the fresh
|
||||
# answer stands alone in the last bubble (it quotes the
|
||||
# question — the mock is deterministic).
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(ANSWER)).to_have_count(1)
|
||||
expect(page.locator(ANSWER).last).to_contain_text(q)
|
||||
expect(page.locator("#stale-regenerate")).to_be_enabled()
|
||||
|
||||
# --- The API agrees the row is fresh again, with the fresh
|
||||
# answer as its last brain message (the re-save re-stamped the
|
||||
# row — the DB agrees).
|
||||
detail = _chat(app_url, cookies, chat_id)
|
||||
assert detail["stale"] is False, "the re-saved row must be fresh"
|
||||
msgs = detail["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"], (
|
||||
"the re-save must not duplicate the question"
|
||||
)
|
||||
assert msgs[0]["text"] == q
|
||||
assert MOCK_ANSWER_MARKER in msgs[-1]["text"]
|
||||
assert q in msgs[-1]["text"], "the fresh answer quotes the re-asked question"
|
||||
assert _row_stamp(chat_id) == _current_version(), (
|
||||
"the Regenerate re-save must re-stamp sources_version"
|
||||
)
|
||||
|
||||
# --- History: the Stale pill is gone (the em-dash returns).
|
||||
page.goto(app_url + "/history.html")
|
||||
tr = _history_row(page, chat_id)
|
||||
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
|
||||
expect(tr.locator(".stale-pill")).to_have_count(0)
|
||||
expect(tr.locator(".history-stale-cell")).to_have_text(FRESH_STALE_CELL)
|
||||
finally:
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Guard: a stale chat with NO brain record reveals the banner text
|
||||
# WITHOUT the Regenerate button (nothing to regenerate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stale_chat_without_brain_answer_is_text_only(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
js_errors: list[str] = []
|
||||
page.on("pageerror", lambda e: js_errors.append(str(e)))
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
cookies = _admin_cookies(page)
|
||||
# A user-only saved conversation — the same wire shape the Save pill
|
||||
# posts (auto-title from the first user message).
|
||||
q = "How is my Kubernetes cluster set up? (stale-nobrain)"
|
||||
r = httpx.post(
|
||||
f"{app_url}/api/chats",
|
||||
timeout=10,
|
||||
cookies=cookies,
|
||||
json={"messages": [{"who": "user", "text": q}]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
chat_id = r.json()["id"]
|
||||
assert r.json()["stale"] is False # freshly stamped on create
|
||||
try:
|
||||
_bump_sources_version()
|
||||
assert _chat(app_url, cookies, chat_id)["stale"] is True
|
||||
|
||||
# Opening the stale user-only row: the banner reveals…
|
||||
page.goto(app_url + f"/?chat={chat_id}")
|
||||
banner = page.locator("#stale-banner")
|
||||
expect(banner).to_be_visible(timeout=15_000)
|
||||
expect(banner).to_contain_text(BANNER_TEXT)
|
||||
# …but the Regenerate button is REMOVED — with no brain record
|
||||
# there is nothing to regenerate (retryLastTurn is never called).
|
||||
expect(page.locator("#stale-regenerate")).to_have_count(0)
|
||||
# The conversation restored: the user question, no brain bubble.
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
expect(page.locator(ANSWER)).to_have_count(0)
|
||||
assert not js_errors, f"the text-only banner must not throw: {js_errors}"
|
||||
finally:
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Anonymous: the shared (now fresh) chat renders at /shared/<token>
|
||||
# with NO staleness surface — neither in the DOM nor on the public
|
||||
# SharedChatOut wire (phase 51 unchanged)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_shared_snapshot_has_no_staleness_surface(
|
||||
page: Page,
|
||||
browser: Browser,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (stale-share)"
|
||||
_ask(page, q)
|
||||
_save(page)
|
||||
|
||||
cookies = _admin_cookies(page)
|
||||
row = _find_row(_chats(app_url, cookies), _auto_title(q))
|
||||
assert row is not None
|
||||
chat_id: str = row["id"]
|
||||
anon_ctx: BrowserContext | None = None
|
||||
try:
|
||||
# Make the chat stale, then bring it back CURRENT through the
|
||||
# full Regenerate loop — the chat shared below is fresh.
|
||||
_bump_sources_version()
|
||||
page.goto(app_url + f"/?chat={chat_id}")
|
||||
expect(page.locator("#stale-banner")).to_be_visible(timeout=15_000)
|
||||
_regenerate_in_place(page, "stale-share-old")
|
||||
assert _chat(app_url, cookies, chat_id)["stale"] is False
|
||||
|
||||
# Share from the History row's Share column (Create link → the
|
||||
# cell re-renders to Copy + Unshare).
|
||||
page.goto(app_url + "/history.html")
|
||||
tr = _history_row(page, chat_id)
|
||||
create = tr.locator("button.history-share-create")
|
||||
expect(create).to_be_visible(timeout=15_000)
|
||||
create.click()
|
||||
expect(tr.locator("button.history-share-copy")).to_be_visible(timeout=15_000)
|
||||
|
||||
share_url = _chat(app_url, cookies, chat_id).get("share_url")
|
||||
assert share_url is not None, "the Create link must have shared the row"
|
||||
token = share_url.removeprefix("/shared/")
|
||||
|
||||
# --- The FRESH anonymous context (no session): the phase-51
|
||||
# snapshot renders in full…
|
||||
anon_ctx = browser.new_context()
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + share_url)
|
||||
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
|
||||
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
|
||||
# …with NO staleness surface anywhere: none of the phase-53
|
||||
# elements exist on the shared page, and the banner's line never
|
||||
# appears (the snapshot is frozen by design — phase 51). (The
|
||||
# check is on the UI strings, not the bare word: the question
|
||||
# text is quoted into the mock's answer and is data, not a
|
||||
# staleness surface.)
|
||||
stale_selectors = (
|
||||
"#stale-banner, #stale-regenerate, "
|
||||
".stale-banner, .stale-pill, .stale-regenerate"
|
||||
)
|
||||
expect(anon.locator(stale_selectors)).to_have_count(0)
|
||||
body_text = anon.locator("body").inner_text().lower()
|
||||
assert "sources have been updated since this chat was saved" not in body_text, (
|
||||
"the shared snapshot must carry no staleness surface"
|
||||
)
|
||||
anon_ctx.close()
|
||||
anon_ctx = None
|
||||
|
||||
# …and the public wire agrees: the anonymous read carries
|
||||
# title + messages ONLY (no stale flag, no staleness surface).
|
||||
r = httpx.get(f"{app_url}/api/shared/{token}", timeout=10)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"title", "messages"}, (
|
||||
f"the public snapshot must stay minimal: {set(body)}"
|
||||
)
|
||||
assert "stale" not in body
|
||||
assert body["title"] == _auto_title(q)
|
||||
assert MOCK_ANSWER_MARKER in body["messages"][-1]["text"]
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
@@ -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) ----------
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ kind).
|
||||
|
||||
Drives ``scripts.import_docs`` end to end with a fake ``clone_or_pull``
|
||||
(no real git, no network) and a recording fake ``import_sources`` (no
|
||||
real DB), covering:
|
||||
real DB), covering: the phase-53 version bump is stubbed in the
|
||||
``main()`` tests the same way (the ``sources_version=`` summary token
|
||||
is asserted against the canned value).
|
||||
|
||||
- Effective sources set (phase 35: the shared resolver — stubbed here,
|
||||
keeping this file's no-real-DB style) → each git URL is cloned/pulled
|
||||
@@ -83,6 +85,20 @@ def _fake_clone_factory() -> tuple[list[tuple[str, Path]], object]:
|
||||
return calls, fake_clone_or_pull
|
||||
|
||||
|
||||
def _stub_bump(monkeypatch: pytest.MonkeyPatch) -> list[None]:
|
||||
"""Stub the phase-53 version bump (this file keeps its no-real-DB
|
||||
style for the counter — the fake import already avoids the KB
|
||||
tables). Returns the call record; the canned new version is 1."""
|
||||
bumps: list[None] = []
|
||||
|
||||
def fake_bump(session: object) -> int:
|
||||
bumps.append(None)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(import_docs, "bump_sources_version", fake_bump)
|
||||
return bumps
|
||||
|
||||
|
||||
# --- repo_name -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -210,6 +226,7 @@ def test_main_git_sources_clone_then_import(
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
fake_import = FakeImportSources()
|
||||
monkeypatch.setattr(import_docs, "import_sources", fake_import)
|
||||
bumps = _stub_bump(monkeypatch)
|
||||
|
||||
rc = import_docs.main([])
|
||||
|
||||
@@ -227,11 +244,19 @@ def test_main_git_sources_clone_then_import(
|
||||
for dest in (tmp_path / "bor" / "homelab", tmp_path / "bor" / "deploy"):
|
||||
assert (dest / "notes.md").is_file()
|
||||
# The final summary print reflects the import (added > 0).
|
||||
assert "added=1" in capsys.readouterr().out
|
||||
out = capsys.readouterr().out
|
||||
assert "added=1" in out
|
||||
# Phase 53: a KB-changing run bumps the sources version exactly
|
||||
# once and reports it (stubbed — this file keeps its no-real-DB
|
||||
# style for the counter, like the fake import above).
|
||||
assert len(bumps) == 1
|
||||
assert "sources_version=1" in out
|
||||
|
||||
|
||||
def test_main_cli_source_still_imports_manual_dir(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
manual = tmp_path / "manual"
|
||||
manual.mkdir()
|
||||
@@ -243,6 +268,7 @@ def test_main_cli_source_still_imports_manual_dir(
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
fake_import = FakeImportSources()
|
||||
monkeypatch.setattr(import_docs, "import_sources", fake_import)
|
||||
bumps = _stub_bump(monkeypatch)
|
||||
|
||||
rc = import_docs.main(["--source", str(manual)])
|
||||
|
||||
@@ -250,6 +276,10 @@ def test_main_cli_source_still_imports_manual_dir(
|
||||
assert calls == []
|
||||
assert fake_import.calls[0]["sources"] == [manual]
|
||||
assert fake_import.calls[0]["prune"] is False
|
||||
# Phase 53: a manual --source run that changes the KB bumps exactly
|
||||
# once (the CLI is the other canonical sync path).
|
||||
assert len(bumps) == 1
|
||||
assert "sources_version=1" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_resolve_sources_mixed_git_and_local(
|
||||
|
||||
@@ -12,6 +12,17 @@ covering the change-gated overview trigger:
|
||||
``overview=failed``, the previous row untouched;
|
||||
- a ``--limit`` debug run with changes → ``overview=skipped``;
|
||||
- an empty source run with no row → no row created, ``overview=skipped``.
|
||||
|
||||
Phase 53 (task 02): the sources-version bump sits alongside the
|
||||
overview gate — a KB-changing run bumps ``sources_meta`` exactly once
|
||||
(``sources_version=<n>`` on the summary line), including a **prune-only**
|
||||
run: the invalidation gate ``added + updated + pruned > 0`` is
|
||||
deliberately broader than the overview's (a pruned doc can invalidate a
|
||||
saved answer that cited it, while the outline stays). ``--limit`` runs
|
||||
and unchanged re-runs never bump (``sources_version=skipped``), and a
|
||||
failed ``lite`` never rolls the bump back. The counter is pinned to the
|
||||
migration-0010 seed (0) around every test by
|
||||
:func:`_reset_sources_version`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,8 +35,10 @@ from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal, db_available
|
||||
from app.models import KbOverview
|
||||
from app.rag.llm import LLMError
|
||||
from app.rag.sources_meta import current_sources_version
|
||||
from scripts import import_docs
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
@@ -44,6 +57,12 @@ def _row(db: Session) -> KbOverview | None:
|
||||
return db.get(KbOverview, 1)
|
||||
|
||||
|
||||
def _version(db: Session) -> int:
|
||||
"""The ``sources_meta`` generation (phase 53; freshly reloaded)."""
|
||||
db.expire_all()
|
||||
return current_sources_version(db)
|
||||
|
||||
|
||||
def _run_main(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
llm: FakeEmbedder,
|
||||
@@ -86,6 +105,26 @@ def _clean_kb(db: Session) -> Iterator[None]:
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sources_version() -> Iterator[None]:
|
||||
"""Phase 53: the sources version counter is global mutable state —
|
||||
pin it to the migration-0010 seed (0) around every test so the bump
|
||||
assertions start from a known generation (own session: the CLI
|
||||
bumps through its own short-lived ``SessionLocal``). Skips like the
|
||||
``db`` fixture when Postgres is down."""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
session = SessionLocal()
|
||||
try:
|
||||
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
||||
session.commit()
|
||||
yield
|
||||
finally:
|
||||
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
|
||||
def test_changed_import_writes_overview_row(
|
||||
db: Session,
|
||||
src: Path,
|
||||
@@ -110,7 +149,8 @@ def test_changed_import_writes_overview_row(
|
||||
|
||||
assert rc == 0
|
||||
assert "added=2" in out
|
||||
assert out.rstrip().endswith("overview=updated")
|
||||
assert out.rstrip().endswith("overview=updated sources_version=1")
|
||||
assert _version(db) == 1 # phase 53: a changed import bumps exactly once
|
||||
# Exactly one lite call — the overview itself (markdown files never
|
||||
# get a summary, so nothing else may touch ``chat``).
|
||||
assert len(llm.chat_calls) == 1
|
||||
@@ -137,7 +177,7 @@ def test_unchanged_reimport_does_not_call_lite(
|
||||
llm = FakeEmbedder()
|
||||
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
|
||||
assert rc == 0
|
||||
assert out.rstrip().endswith("overview=updated")
|
||||
assert out.rstrip().endswith("overview=updated sources_version=1")
|
||||
assert len(llm.chat_calls) == 1
|
||||
assert _row(db) is not None
|
||||
|
||||
@@ -145,10 +185,11 @@ def test_unchanged_reimport_does_not_call_lite(
|
||||
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
|
||||
assert rc == 0
|
||||
assert "unchanged=2" in out
|
||||
assert out.rstrip().endswith("overview=skipped")
|
||||
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
|
||||
assert len(llm.chat_calls) == 1 # no new lite call
|
||||
row = _row(db)
|
||||
assert row is not None and row.content == "Summary of MyDocs"
|
||||
assert _version(db) == 1 # phase 53: an unchanged re-run never bumps
|
||||
|
||||
|
||||
def test_lite_failure_is_fail_soft(
|
||||
@@ -160,7 +201,7 @@ def test_lite_failure_is_fail_soft(
|
||||
good = FakeEmbedder()
|
||||
rc, out = _run_main(monkeypatch, good, ["--source", str(src)], capsys)
|
||||
assert rc == 0
|
||||
assert out.rstrip().endswith("overview=updated")
|
||||
assert out.rstrip().endswith("overview=updated sources_version=1")
|
||||
previous = _row(db)
|
||||
assert previous is not None
|
||||
previous_content = previous.content
|
||||
@@ -172,11 +213,14 @@ def test_lite_failure_is_fail_soft(
|
||||
rc, out = _run_main(monkeypatch, bad, ["--source", str(src)], capsys)
|
||||
assert rc == 0 # a failed outline must not fail the import
|
||||
assert "updated=1" in out
|
||||
assert out.rstrip().endswith("overview=failed")
|
||||
assert out.rstrip().endswith("overview=failed sources_version=2")
|
||||
assert len(bad.chat_calls) == 1 # the (failed) attempt was made
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.content == previous_content # previous row untouched
|
||||
# Phase 53: the bump commits independently of the best-effort
|
||||
# overview — a failed ``lite`` never rolls the version back.
|
||||
assert _version(db) == 2
|
||||
|
||||
|
||||
def test_limit_run_skips_overview(
|
||||
@@ -188,19 +232,20 @@ def test_limit_run_skips_overview(
|
||||
llm = FakeEmbedder()
|
||||
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
|
||||
assert rc == 0
|
||||
assert out.rstrip().endswith("overview=updated")
|
||||
assert out.rstrip().endswith("overview=updated sources_version=1")
|
||||
assert len(llm.chat_calls) == 1
|
||||
|
||||
# An incomplete walk must not rewrite the outline (mirrors the
|
||||
# --prune-with---limit guard).
|
||||
# --prune-with---limit guard) — and must not advance the version.
|
||||
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
|
||||
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "1"], capsys)
|
||||
assert rc == 0
|
||||
assert "updated=1" in out
|
||||
assert out.rstrip().endswith("overview=skipped")
|
||||
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
|
||||
assert len(llm.chat_calls) == 1 # --limit never burns a lite call
|
||||
row = _row(db)
|
||||
assert row is not None and row.content == "Summary of MyDocs"
|
||||
assert _version(db) == 1 # phase 53: --limit debug runs never bump
|
||||
|
||||
|
||||
def test_empty_source_without_row_creates_nothing(
|
||||
@@ -217,6 +262,36 @@ def test_empty_source_without_row_creates_nothing(
|
||||
|
||||
assert rc == 0
|
||||
assert "files=0" in out
|
||||
assert out.rstrip().endswith("overview=skipped")
|
||||
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
|
||||
assert llm.chat_calls == [] # no KB → no outline, no wasted model call
|
||||
assert _row(db) is None # nothing created
|
||||
assert _version(db) == 0 # nothing changed → nothing bumped
|
||||
|
||||
|
||||
def test_prune_only_run_bumps_sources_version(
|
||||
db: Session,
|
||||
src: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Phase 53 (task 02): the invalidation gate is deliberately broader
|
||||
than the overview's — a prune-only run (added + updated == 0,
|
||||
pruned > 0) advances the version (a pruned document can invalidate
|
||||
a saved answer that cited it) while the outline stays.
|
||||
"""
|
||||
llm = FakeEmbedder()
|
||||
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
|
||||
assert rc == 0
|
||||
assert "added=2" in out
|
||||
assert out.rstrip().endswith("overview=updated sources_version=1")
|
||||
assert _version(db) == 1
|
||||
|
||||
# Delete one file; a --prune run drops exactly it: no add/update,
|
||||
# but pruned=1 → the version still bumps while the overview skips.
|
||||
(src / "alpha.md").unlink()
|
||||
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--prune"], capsys)
|
||||
assert rc == 0
|
||||
assert "pruned=1" in out
|
||||
assert out.rstrip().endswith("overview=skipped sources_version=2")
|
||||
assert _version(db) == 2 # the prune-only change bumped exactly once
|
||||
assert _row(db) is not None # the outline row is untouched
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Integration: migration 0010 (sources_meta + saved_chats.sources_version).
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0009.py`` (information_schema / pg catalog assertions
|
||||
on the state the migration must leave). The tests target revision
|
||||
``0010`` explicitly so later migrations cannot break them:
|
||||
|
||||
* upgrade 0009 → 0010 → the single-row ``sources_meta`` table exists
|
||||
(``id`` Integer PK default 1, ``version`` Integer NOT NULL default 0,
|
||||
``updated_at`` TIMESTAMPTZ NOT NULL default now()) with its **seed
|
||||
row** (id 1, version 0), and ``saved_chats.sources_version`` is
|
||||
Integer NOT NULL default 0 — a pre-0010 row comes back stamped 0
|
||||
(the pre-counter KB, phase-53 locked decision 2);
|
||||
* inserted rows round-trip the stamp (default and explicit);
|
||||
* downgrade to 0009 → column + table gone (A13 — reversible), the rest
|
||||
of ``saved_chats`` survives;
|
||||
* upgrade back to 0010 → table, seed row, and column are all back
|
||||
(round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _table_exists(db: Session, table: str) -> bool:
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables"
|
||||
" WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
assert count is not None, "information_schema count must be an int"
|
||||
return int(count) == 1
|
||||
|
||||
|
||||
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one table column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _seed_row(db: Session) -> tuple[int, int] | None:
|
||||
"""The (id, version) of the ``sources_meta`` row with id 1."""
|
||||
row = db.execute(
|
||||
text("SELECT id, version FROM sources_meta WHERE id = 1")
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _insert(db: Session, version: int | None) -> uuid.UUID:
|
||||
"""Insert one saved_chats row, optionally with an explicit stamp."""
|
||||
if version is None:
|
||||
sql = (
|
||||
"INSERT INTO saved_chats (id, title, messages)"
|
||||
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
|
||||
" RETURNING id"
|
||||
)
|
||||
params: dict[str, Any] = {}
|
||||
else:
|
||||
sql = (
|
||||
"INSERT INTO saved_chats (id, title, messages, sources_version)"
|
||||
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :v)"
|
||||
" RETURNING id"
|
||||
)
|
||||
params = {"v": version}
|
||||
params.update(
|
||||
{"t": "Mig 0010", "m": '[{"who": "user", "text": "How did I install gitlab?"}]'}
|
||||
)
|
||||
chat_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
|
||||
db.commit()
|
||||
return chat_id
|
||||
|
||||
|
||||
def _legacy_insert(db: Session) -> uuid.UUID:
|
||||
"""Insert one row WITHOUT the ``sources_version`` column — the only
|
||||
possible shape at revision 0009 (the column does not exist yet)."""
|
||||
chat_id: uuid.UUID = db.execute(
|
||||
text(
|
||||
"INSERT INTO saved_chats (id, title, messages)"
|
||||
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
|
||||
" RETURNING id"
|
||||
),
|
||||
{
|
||||
"t": "Mig 0010",
|
||||
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
|
||||
},
|
||||
).scalar_one()
|
||||
db.commit()
|
||||
return chat_id
|
||||
|
||||
|
||||
def _delete(db: Session, chat_id: uuid.UUID) -> None:
|
||||
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0010_adds_sources_meta_and_stamp(
|
||||
db: Session, alembic: Config
|
||||
) -> None:
|
||||
"""Upgrade 0009 → 0010: the seeded counter table and the NOT NULL
|
||||
stamp column exist; a pre-0010 row comes back stamped 0 (the
|
||||
pre-counter KB)."""
|
||||
command.downgrade(alembic, "0009") # start from the pre-0010 state
|
||||
assert _version(db) == "0009"
|
||||
assert not _table_exists(db, "sources_meta"), "sources_meta must be absent at 0009"
|
||||
assert _column(db, "saved_chats", "sources_version") is None, (
|
||||
"sources_version must be absent at 0009"
|
||||
)
|
||||
|
||||
# A pre-0010 row (no sources_version in the INSERT — the column does
|
||||
# not exist at 0009): its data must survive the additive migration.
|
||||
legacy = _legacy_insert(db)
|
||||
try:
|
||||
command.upgrade(alembic, "0010")
|
||||
assert _version(db) == "0010", "alembic_version must be at 0010"
|
||||
|
||||
id_col = _column(db, "sources_meta", "id")
|
||||
assert id_col is not None, "sources_meta.id is missing"
|
||||
assert id_col[0] == "integer", "sources_meta.id must be INTEGER"
|
||||
assert id_col[1] == "NO", "sources_meta.id must be NOT NULL (PK)"
|
||||
assert id_col[2] == "1", "sources_meta.id must default to 1"
|
||||
|
||||
ver_col = _column(db, "sources_meta", "version")
|
||||
assert ver_col is not None, "sources_meta.version is missing"
|
||||
assert ver_col[0] == "integer", "sources_meta.version must be INTEGER"
|
||||
assert ver_col[1] == "NO", "sources_meta.version must be NOT NULL"
|
||||
assert ver_col[2] == "0", "sources_meta.version must default to 0"
|
||||
|
||||
updated = _column(db, "sources_meta", "updated_at")
|
||||
assert updated is not None, "sources_meta.updated_at is missing"
|
||||
assert updated[0] == "timestamp with time zone", (
|
||||
"sources_meta.updated_at must be TIMESTAMPTZ"
|
||||
)
|
||||
assert updated[1] == "NO", "sources_meta.updated_at must be NOT NULL"
|
||||
assert str(updated[2]).startswith("now("), (
|
||||
"sources_meta.updated_at must have server default now()"
|
||||
)
|
||||
|
||||
assert _seed_row(db) == (1, 0), "the seed row (id 1, version 0) is missing"
|
||||
|
||||
stamp = _column(db, "saved_chats", "sources_version")
|
||||
assert stamp is not None, "saved_chats.sources_version is missing"
|
||||
assert stamp[0] == "integer", "sources_version must be INTEGER"
|
||||
assert stamp[1] == "NO", "sources_version must be NOT NULL"
|
||||
assert stamp[2] == "0", "sources_version must default to 0"
|
||||
|
||||
row = db.execute(
|
||||
text("SELECT title, sources_version FROM saved_chats WHERE id = :i"),
|
||||
{"i": legacy},
|
||||
).fetchone()
|
||||
assert row is not None, "the pre-0010 row must survive the upgrade"
|
||||
assert row[1] == 0, "a pre-0010 row must upgrade stamped 0 (pre-counter KB)"
|
||||
finally:
|
||||
_delete(db, legacy)
|
||||
|
||||
|
||||
def test_inserted_rows_round_trip_the_stamp(db: Session, alembic: Config) -> None:
|
||||
"""At 0010, an omitted stamp defaults to 0 and an explicit stamp
|
||||
round-trips verbatim."""
|
||||
command.upgrade(alembic, "head")
|
||||
default_id = _insert(db, None)
|
||||
explicit_id = _insert(db, 7)
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("SELECT sources_version FROM saved_chats WHERE id IN (:a, :b)"),
|
||||
{"a": default_id, "b": explicit_id},
|
||||
).all()
|
||||
stamps = {row[0] for row in rows}
|
||||
assert stamps == {0, 7}, "default stamp 0 and explicit stamp 7 must round-trip"
|
||||
default_stamp = db.execute(
|
||||
text("SELECT sources_version FROM saved_chats WHERE id = :i"),
|
||||
{"i": default_id},
|
||||
).scalar_one()
|
||||
assert default_stamp == 0, "an omitted stamp must default to 0"
|
||||
finally:
|
||||
_delete(db, default_id)
|
||||
_delete(db, explicit_id)
|
||||
|
||||
|
||||
def test_downgrade_to_0009_drops_both(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0009: the stamp column and the counter table are
|
||||
gone (A13 — reversible) while the rest of ``saved_chats`` survives."""
|
||||
command.downgrade(alembic, "0009")
|
||||
assert _version(db) == "0009"
|
||||
assert _column(db, "saved_chats", "sources_version") is None, (
|
||||
"sources_version must be dropped"
|
||||
)
|
||||
assert not _table_exists(db, "sources_meta"), "sources_meta must be dropped"
|
||||
|
||||
id_col = _column(db, "saved_chats", "id")
|
||||
assert id_col is not None and id_col[0] == "uuid", (
|
||||
"saved_chats.id must survive the downgrade"
|
||||
)
|
||||
token_col = _column(db, "saved_chats", "share_token")
|
||||
assert token_col is not None and token_col[0] == "uuid", (
|
||||
"saved_chats.share_token must survive the downgrade"
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_both(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0009, then upgrade back to 0010: the counter table
|
||||
(with a fresh seed row) and the stamp column are back."""
|
||||
command.downgrade(alembic, "0009")
|
||||
command.upgrade(alembic, "0010")
|
||||
assert _version(db) == "0010", "round-trip upgrade must land at 0010"
|
||||
|
||||
assert _table_exists(db, "sources_meta"), "sources_meta must be back"
|
||||
assert _seed_row(db) == (1, 0), "the seed row must be re-seeded on upgrade"
|
||||
|
||||
stamp = _column(db, "saved_chats", "sources_version")
|
||||
assert stamp is not None, "sources_version must be back after the round-trip"
|
||||
assert stamp[0] == "integer" and stamp[1] == "NO", (
|
||||
"sources_version must be INTEGER NOT NULL after the round-trip"
|
||||
)
|
||||
assert stamp[2] == "0", "sources_version must default to 0 after the round-trip"
|
||||
@@ -39,6 +39,15 @@ real ``LLMClient`` the probe is stubbed (:func:`_stub_probe`) so no
|
||||
test ever hits the network; the ``_real_llm`` tests get a passing
|
||||
probe from ``FakeEmbedder.embed_one``/``chat``.
|
||||
|
||||
Phase 53 (task 02): the sources-version bump — a sync whose import
|
||||
changed the KB (added + updated + pruned > 0) advances the single-row
|
||||
``sources_meta`` counter exactly once (the new generation lands in the
|
||||
``/api/sync/status`` detail as ``sources_version``); an unchanged
|
||||
re-sync never bumps (the detail still reports the current generation),
|
||||
and every failure path (git error, model down) never bumps. The
|
||||
counter is pinned to the migration-0010 seed (0) around every test by
|
||||
:func:`_reset_sources_version`.
|
||||
|
||||
The git / import / overview layers are monkeypatched in ``app.api.sync``
|
||||
(same fake style as ``test_import_docs_git.py``) — no real git, no LLM:
|
||||
the runner's state machine and HTTP surface are under test.
|
||||
@@ -66,11 +75,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import sync as sync_api
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal, db_available
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import GitSource
|
||||
from app.rag import git_sources as git_sources_resolver
|
||||
from app.rag.importer import ImportSummary
|
||||
from app.rag.llm import EmbeddingError, LLMClient, ModelUnavailableError
|
||||
from app.rag.sources_meta import current_sources_version
|
||||
from scripts.git_sync import GitSyncError
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
from tests.fakes import FakeEmbedder
|
||||
@@ -100,6 +111,26 @@ def clean_git_sources(db: Session) -> Iterator[None]:
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sources_version() -> Iterator[None]:
|
||||
"""Phase 53: the sources version counter is global mutable state —
|
||||
pin it to the migration-0010 seed (0) around every sync test so the
|
||||
bump assertions start from a known generation (own session: the
|
||||
runner bumps through its own short-lived ``SessionLocal``).
|
||||
Skips like the ``db`` fixture when Postgres is down."""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
session = SessionLocal()
|
||||
try:
|
||||
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
||||
session.commit()
|
||||
yield
|
||||
finally:
|
||||
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sync_client() -> Iterator[TestClient]:
|
||||
"""Context-managed TestClient — one app event loop across requests
|
||||
@@ -310,7 +341,10 @@ def test_admin_sync_success_reports_full_detail(
|
||||
"files": 5, "added": 1, "updated": 2, "unchanged": 2, "pruned": 3,
|
||||
"errors": 0, "chunks": 11, "summaries": 1, "summary_errors": 0,
|
||||
"overview": True,
|
||||
"sources_version": 1, # phase 53: changed KB → exactly one bump (0 → 1)
|
||||
}
|
||||
# The bump committed: the counter advanced exactly once, not twice.
|
||||
assert current_sources_version(db) == 1
|
||||
# Git: the configured repo was cloned into BOR_SOURCES_DIR/<repo-name>/.
|
||||
assert clone_calls == [(repo_url, tmp_path / "bor" / "repo")]
|
||||
# Import: exactly the checkouts, with prune=True (the button is the
|
||||
@@ -354,6 +388,10 @@ def test_unchanged_kb_skips_overview_refresh(
|
||||
assert body["detail"]["overview"] is False
|
||||
assert fake_overview.llms == [] # no wasted model call
|
||||
assert len(fake_import.llms) == 1 # the import itself ran
|
||||
# Phase 53: an unchanged re-sync never bumps — the detail reports
|
||||
# the current (unadvanced) generation.
|
||||
assert body["detail"]["sources_version"] == 0
|
||||
assert current_sources_version(db) == 0
|
||||
|
||||
|
||||
# --- admin: concurrency ----------------------------------------------------
|
||||
@@ -435,6 +473,8 @@ def test_git_failure_marks_failed_and_skips_import(
|
||||
assert body["finished_at"] is not None
|
||||
assert fake_import.sources == [] # no partial import
|
||||
assert fake_overview.llms == []
|
||||
# Phase 53: a FAILED sync never bumps — the version is untouched.
|
||||
assert current_sources_version(db) == 0
|
||||
|
||||
# A failed run leaves the system restartable: a new POST is accepted.
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
@@ -773,6 +813,8 @@ def test_model_down_fails_fast_before_any_clone(
|
||||
assert body["detail"] == {}
|
||||
assert clone_calls == [] # fail fast: before any clone
|
||||
assert fake_import.sources == [] # and before any import
|
||||
# Phase 53: a FAILED sync (model down) never bumps.
|
||||
assert current_sources_version(db) == 0
|
||||
|
||||
|
||||
def test_probe_names_dead_embed_model_and_masks_credentials(
|
||||
|
||||
@@ -17,7 +17,13 @@ regression is caught without a browser:
|
||||
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
|
||||
+ ``header.js``'s reveal-for-admin block;
|
||||
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
|
||||
the empty-state row.
|
||||
the empty-state row;
|
||||
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
|
||||
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
|
||||
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
|
||||
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
|
||||
between Updated and Share in ``history.html``, and the ``.stale-pill``
|
||||
rose-family CSS in ``styles.css``.
|
||||
|
||||
The Containerfile stage-1 coverage (history.html copied, history.js
|
||||
bundled) is pinned dynamically by
|
||||
@@ -177,35 +183,39 @@ def test_history_page_scaffold_and_landmarks() -> None:
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
"""The table skeleton: ``.history-table`` with the five columns —
|
||||
Title | Messages | Updated | Share (phase 51) | Actions (the
|
||||
Actions header text is visually-hidden — the row buttons carry
|
||||
their own aria-labels) — and the empty-state row (ship-hidden, the
|
||||
exact copy)."""
|
||||
"""The table skeleton: ``.history-table`` with the six columns —
|
||||
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
|
||||
Actions (the Actions header text is visually-hidden — the row
|
||||
buttons carry their own aria-labels) — and the empty-state row
|
||||
(ship-hidden, the exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<table class="history-table">' in html
|
||||
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
|
||||
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
|
||||
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
|
||||
'<th scope="col">Share</th>'):
|
||||
assert col in html
|
||||
# The Share column sits BETWEEN Updated and Actions.
|
||||
# The Stale column (phase 53) sits BETWEEN Updated and Share —
|
||||
# i.e. between Updated and Actions — so the phase-51 contract
|
||||
# (Share between Updated and Actions) still holds.
|
||||
assert (
|
||||
html.find('<th scope="col">Updated</th>')
|
||||
< html.find('<th scope="col">Stale</th>')
|
||||
< html.find('<th scope="col">Share</th>')
|
||||
< html.find('visually-hidden">Actions')
|
||||
), "the Share column must sit between Updated and Actions"
|
||||
), "the Stale column must sit between Updated and Share"
|
||||
actions_th = re.search(
|
||||
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
|
||||
html,
|
||||
)
|
||||
assert actions_th, "the Actions column header must be visually-hidden text"
|
||||
assert actions_th.group(1) == "", "no visible text beside the hidden header"
|
||||
# The empty-state row: ship-hidden, colspan 5 (the Share column
|
||||
# joined the table in phase 51), the exact copy.
|
||||
# The empty-state row: ship-hidden, colspan 6 (phase 51 added
|
||||
# Share, phase 53 added Stale), the exact copy.
|
||||
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
|
||||
assert row, "the empty-state row must ship in the skeleton"
|
||||
assert "hidden" in row.group(0)
|
||||
assert 'id="history-empty-row"' in row.group(0)
|
||||
assert "<td colspan=\"5\">" in html
|
||||
assert "<td colspan=\"6\">" in html
|
||||
assert (
|
||||
"No saved chats yet — finish a conversation and press"
|
||||
" <strong>Save</strong> in the chat."
|
||||
@@ -446,20 +456,91 @@ def test_history_table_mobile_behavior() -> None:
|
||||
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
|
||||
"""makeRow: the Share <td> (with the share control) lands BETWEEN
|
||||
the Updated cell and the Actions cell — the column order in
|
||||
history.html is Title | Messages | Updated | Share | Actions."""
|
||||
history.html is Title | Messages | Updated | Stale (phase 53) |
|
||||
Share | Actions."""
|
||||
js = _js()
|
||||
row = _fn(js, "makeRow")
|
||||
updated_i = row.find('updatedTd.className = "history-updated-cell"')
|
||||
stale_i = row.find('staleTd.className = "history-stale-cell"')
|
||||
share_i = row.find('shareTd.className = "history-share-cell"')
|
||||
actions_i = row.find('actionsTd.className = "history-actions-cell"')
|
||||
assert -1 < updated_i < share_i < actions_i, (
|
||||
"the share cell must sit between Updated and Actions"
|
||||
assert -1 < updated_i < stale_i < share_i < actions_i, (
|
||||
"the stale cell (phase 53) must sit between Updated and Share —"
|
||||
" i.e. the share cell must still sit between Updated and Actions"
|
||||
)
|
||||
assert "makeShareControl(chat)" in row
|
||||
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
|
||||
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
|
||||
f"row cell order must be title/count/updated/share/actions, got {seq}"
|
||||
assert seq == [
|
||||
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
|
||||
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
|
||||
|
||||
|
||||
# ---------- the Stale column (phase 53, task 04) ----------
|
||||
|
||||
|
||||
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
|
||||
"""makeRow (phase 53 task 04): the Stale cell renders from the
|
||||
row's ``stale`` flag — the SERVER computes staleness (task 03),
|
||||
the client never does version math. Stale rows: the rose
|
||||
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
|
||||
at the Regenerate action on the chat page, task 05). Fresh rows:
|
||||
a plain em-dash (no pill). The <td> carries its own aria-label in
|
||||
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
|
||||
visual). READ-ONLY badge: the cell binds no events and creates no
|
||||
controls (the Regenerate button lives on the chat-page banner);
|
||||
textContent only (XSS contract)."""
|
||||
js = _js()
|
||||
row = _fn(js, "makeRow")
|
||||
assert 'staleTd.className = "history-stale-cell"' in row
|
||||
assert "tr.appendChild(staleTd)" in row
|
||||
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
|
||||
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
|
||||
# The stale branch: the rose pill with the exact hover copy.
|
||||
assert 'pill.className = "stale-pill"' in branch
|
||||
assert 'pill.textContent = "Stale"' in branch
|
||||
assert (
|
||||
'pill.title = "Sources have changed since this chat was saved'
|
||||
" — open the chat to Regenerate\";"
|
||||
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
|
||||
# The fresh branch: the plain em-dash, never the pill.
|
||||
else_i = branch.find("} else {")
|
||||
assert else_i != -1, "the fresh branch must exist"
|
||||
fresh = branch[else_i:]
|
||||
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
|
||||
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
|
||||
# The <td> aria-label ships in BOTH states (conveyed without the
|
||||
# visual — WCAG 2.1 AA).
|
||||
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
|
||||
"the cell's aria-label must exist in the stale AND the fresh branch"
|
||||
)
|
||||
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
|
||||
# cell's construction.
|
||||
assert "addEventListener" not in branch
|
||||
assert "createElement(\"button\")" not in branch
|
||||
assert "innerHTML" not in branch
|
||||
|
||||
|
||||
def test_stale_column_css_rose_family() -> None:
|
||||
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
|
||||
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
|
||||
err-line border), theme-token based so it stays AA with the
|
||||
palette; a compact rounded pill (border-radius 999px, nowrap). The
|
||||
``.history-stale-cell`` keeps the marker on one line and rides
|
||||
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
|
||||
css = _css()
|
||||
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
|
||||
assert pill, "styles.css must style .stale-pill"
|
||||
body = pill.group(1)
|
||||
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
|
||||
assert "color: var(--err-ink)" in body
|
||||
assert "border: 1px solid var(--err-line)" in body
|
||||
assert "border-radius: 999px" in body, "the pill shape"
|
||||
assert "white-space: nowrap" in body
|
||||
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
|
||||
assert cell, "the stale cell must be styled"
|
||||
cbody = cell.group(1)
|
||||
assert "white-space: nowrap" in cbody
|
||||
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
|
||||
|
||||
|
||||
def test_share_control_three_states_and_two_step_unshare() -> None:
|
||||
|
||||
@@ -447,3 +447,227 @@ def test_share_button_revealed_only_for_admin() -> None:
|
||||
assert boot_start < save_reveal < reveal, (
|
||||
"the Share reveal joins the same admin-reveal block as Save"
|
||||
)
|
||||
|
||||
|
||||
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
|
||||
|
||||
|
||||
def test_stale_banner_html_after_kb_banner() -> None:
|
||||
"""index.html: the #stale-banner section sits DIRECTLY AFTER
|
||||
#kb-banner (the chat-shell top-of-column position — kb-banner keeps
|
||||
the top slot when both are visible), role="status", shipped hidden,
|
||||
with the exact text and the #stale-regenerate button (type=button,
|
||||
visible label "Regenerate", the redo glyph — the SAME SVG paths as
|
||||
RETRY_ICON in app.js, the phase-49 Retry asset). No other page
|
||||
carries it (chat-page only)."""
|
||||
html = _index()
|
||||
kb_idx = html.find('id="kb-banner"')
|
||||
banner = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
|
||||
assert banner, "index.html must contain the #stale-banner section"
|
||||
tag = banner.group(0)
|
||||
assert 'role="status"' in tag
|
||||
assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)"
|
||||
assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner"
|
||||
# Nothing between the kb-banner close and the stale banner except
|
||||
# whitespace + the phase-53 comment: the top-of-column pair is kept.
|
||||
between = html[html.find("</div>", kb_idx) : banner.start()]
|
||||
assert "id=" not in between, "no other element lands between the two banners"
|
||||
block = html[banner.start() : html.find("</section>", banner.start())]
|
||||
assert "The sources have been updated since this chat was saved." in block
|
||||
btn = re.search(r'<button[^>]*id="stale-regenerate"[^>]*>', block)
|
||||
assert btn, "the banner carries the #stale-regenerate button"
|
||||
assert 'type="button"' in btn.group(0)
|
||||
btn_block = block[btn.start() : block.find("</button>", btn.start())]
|
||||
assert ">Regenerate</span>" in btn_block, "the visible label is Regenerate"
|
||||
# The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset).
|
||||
assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block
|
||||
assert 'd="M21 3v5h-5"' in btn_block
|
||||
# The banner's own leading mark is the redo glyph too (distinct from
|
||||
# the kb-banner warning triangle) — aria-hidden decoration.
|
||||
lead = block[: btn.start()]
|
||||
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the stale banner is chat-page only"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_load_reveals_stale_banner_on_payload_stale() -> None:
|
||||
"""restoreSavedChatFromUrl: on a 200 payload with stale: true, the
|
||||
#stale-banner is revealed (hidden removed) — the flag is
|
||||
server-computed (task 03), the client never does staleness math.
|
||||
The reveal rides the boot SUCCESS path (after the link + the
|
||||
localStorage mirror), so a non-stale payload leaves the banner
|
||||
hidden."""
|
||||
js = _js()
|
||||
body = _fn(js, "restoreSavedChatFromUrl")
|
||||
assert "data.stale === true" in body, "the reveal branches on the payload's stale flag"
|
||||
link_i = body.find("currentChatId = chatId")
|
||||
reveal_i = body.find("staleBanner.hidden = false")
|
||||
assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link"
|
||||
assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`"
|
||||
|
||||
|
||||
def test_boot_load_stale_reveal_no_brain_record_guard() -> None:
|
||||
"""The no-brain-record guard: a stale conversation with NO brain
|
||||
record (user-only) is revealed TEXT-ONLY — the #stale-regenerate
|
||||
button is removed BEFORE the reveal (retryLastTurn is never called
|
||||
in that state)."""
|
||||
js = _js()
|
||||
body = _fn(js, "restoreSavedChatFromUrl")
|
||||
guard_i = body.find('!conversation.some((m) => m.who === "brain")')
|
||||
remove_i = body.find("staleRegenBtn.remove()")
|
||||
reveal_i = body.find("staleBanner.hidden = false")
|
||||
assert -1 < guard_i < remove_i < reveal_i, (
|
||||
"the no-brain check removes the button before the banner is revealed"
|
||||
)
|
||||
|
||||
|
||||
def test_retry_last_turn_returns_the_turn_promise() -> None:
|
||||
"""retryLastTurn RETURNS the runTurn promise (phase 53 task 05):
|
||||
the Regenerate path awaits the turn's completion to know when to
|
||||
persist. The phase-49 Retry click handler ignores the return value
|
||||
— behavior-neutral for it (the redo-order pins in
|
||||
test_frontend_feedback.py keep holding unchanged)."""
|
||||
js = _js()
|
||||
body = _fn(js, "retryLastTurn")
|
||||
assert "return runTurn(text, { reask: true })" in body, (
|
||||
"the redo promise is returned for the Regenerate await"
|
||||
)
|
||||
assert "void runTurn" not in body, "the fire-and-forget void is gone"
|
||||
# The existing Retry click handler still ignores the return value.
|
||||
append = _fn(js, "appendRetryButton")
|
||||
assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)"
|
||||
|
||||
|
||||
def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None:
|
||||
"""regenerateStaleChat: drives retryLastTurn on the LAST brain
|
||||
bubble's rendered wrap (phase-49 targeting — retryLastTurn's own
|
||||
`wrap !== lastBrainWrap` guard makes a stale click a no-op that
|
||||
resolves nothing), AWAITs the returned turn promise, and persists
|
||||
only when the turn completed WITHOUT the error banner (a
|
||||
mid-stream error leaves the linked row untouched — stale stays
|
||||
true). The double-click guard releases in the finally — never
|
||||
stale (PLAN §7.4)."""
|
||||
js = _js()
|
||||
body = _fn(js, "regenerateStaleChat")
|
||||
assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time"
|
||||
call_i = body.find("retryLastTurn(lastBrainWrap)")
|
||||
await_i = body.find("await turn")
|
||||
assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it"
|
||||
err_i = body.find('banner.classList.contains("is-error")')
|
||||
put_i = body.find('`/api/chats/${currentChatId}`')
|
||||
assert -1 < await_i < err_i < put_i, (
|
||||
"the error-banner check sits between the await and the persist"
|
||||
)
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_regenerate_persists_the_linked_row() -> None:
|
||||
"""The post-regenerate persist (the existing upsert path): linked →
|
||||
PUT /api/chats/<id> (the server re-stamps sources_version → the row
|
||||
is fresh); a 404 (the row was deleted from History meanwhile)
|
||||
follows saveCurrentChat's stale-link rule — unlink + recreate
|
||||
(POST), and the recreate links the new id. Success hides the banner
|
||||
AND announces the outcome in the #send-status live region; 403/5xx
|
||||
→ the actionable error banner (the row stays as the turn left it);
|
||||
network → the reachable? banner."""
|
||||
js = _js()
|
||||
body = _fn(js, "regenerateStaleChat")
|
||||
assert "if (currentChatId)" in body
|
||||
assert 'method: "PUT"' in body
|
||||
assert 'fetch("/api/chats"' in body and 'method: "POST"' in body
|
||||
put_idx = body.find('method: "PUT"')
|
||||
post_idx = body.find('method: "POST"')
|
||||
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
|
||||
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
|
||||
# The 404→recreate fallback: unlink, then POST again.
|
||||
notfound_idx = body.find("res.status === 404")
|
||||
assert notfound_idx != -1, "the PUT 404 must be handled"
|
||||
fallback = body[notfound_idx:post_idx]
|
||||
assert "currentChatId = null" in fallback, "the stale link is dropped"
|
||||
# The recreate links the new row.
|
||||
assert "res.status === 201" in body
|
||||
assert "currentChatId = String(created.id)" in body
|
||||
# Success: hide the banner, then announce in the live region.
|
||||
hide_i = body.find("staleBanner.hidden = true")
|
||||
ann_i = body.find('sendStatus.textContent = "Regenerated')
|
||||
assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome"
|
||||
assert "the answer now reflects the current sources." in body, ("the live-region line")
|
||||
# Failures raise an actionable banner (non-ok HTTP + network).
|
||||
assert (
|
||||
'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body
|
||||
)
|
||||
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
|
||||
|
||||
|
||||
def test_stale_regenerate_binding_and_element_queries() -> None:
|
||||
"""The wiring: app.js queries #stale-banner + #stale-regenerate at
|
||||
module scope and binds the click to regenerateStaleChat. The banner
|
||||
only ever shows on the /?chat=<id> boot path (admin), so the
|
||||
binding is inert otherwise."""
|
||||
js = _js()
|
||||
assert 'document.querySelector("#stale-banner")' in js
|
||||
assert 'document.querySelector("#stale-regenerate")' in js
|
||||
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
|
||||
|
||||
|
||||
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
|
||||
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
|
||||
banner described (and unlinks it) — the banner hides; a successful
|
||||
manual re-Save re-stamps the row to the current generation (task
|
||||
03) — the banner is done the moment the save succeeds."""
|
||||
js = _js()
|
||||
new_body = _fn(js, "startNewChat")
|
||||
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
|
||||
save_body = _fn(js, "saveCurrentChat")
|
||||
saved_line = 'sendStatus.textContent = "Conversation saved."'
|
||||
after = save_body[save_body.find(saved_line):]
|
||||
assert "staleBanner.hidden = true" in after, (
|
||||
"a successful re-save re-stamps the row — the banner is done"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_banner_css_is_the_kb_banner_family() -> None:
|
||||
"""styles.css: the banner rides the .kb-banner family (the section
|
||||
carries BOTH classes — the flex row + accent tokens come from
|
||||
.kb-banner; .stale-banner adds the wrap so the pill can drop below
|
||||
the text when the row must wrap), and the .stale-regenerate pill is
|
||||
the EXACT brand-pill family of Save/Share (solid --brand, --bg text
|
||||
5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px
|
||||
redo glyph). The ≤640px block makes the pill a full-width row."""
|
||||
html = _index()
|
||||
tag = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
|
||||
assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), (
|
||||
"the section carries both classes — the family comes from .kb-banner"
|
||||
)
|
||||
css = _css()
|
||||
block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .stale-regenerate"
|
||||
body = block.group(1)
|
||||
for prop in (
|
||||
"display: inline-flex",
|
||||
"min-height: 44px",
|
||||
"margin-left: auto",
|
||||
"border-radius: 999px",
|
||||
"border: 0",
|
||||
"background: var(--brand)",
|
||||
"color: var(--bg)",
|
||||
"font-weight: 700",
|
||||
"cursor: pointer",
|
||||
):
|
||||
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
|
||||
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
|
||||
"the redo glyph rides the 16px pill size"
|
||||
)
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "mobile media query missing"
|
||||
assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), (
|
||||
"at phone width the pill takes a full-width row"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Unit: sources-version counter helpers (phase 53, task 01).
|
||||
|
||||
``app.rag.sources_meta`` runs against the local compose Postgres
|
||||
(``podman compose up -d db``) — the house DB-test pattern: the helpers
|
||||
are thin session wrappers whose contract (seeded single row, flush-not
|
||||
commit, defensive absence) only holds against a real database. Skips
|
||||
with clear instructions when the stack is not up.
|
||||
|
||||
A fixture resets ``sources_meta`` to the migration-0010 seed state
|
||||
(id 1, version 0) before and after every test, so the suite leaves the
|
||||
dev DB exactly as the migration left it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import SourcesMeta
|
||||
from app.rag.sources_meta import bump_sources_version, current_sources_version
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_sources_meta(db: Session):
|
||||
"""Reset the counter to the migration-0010 seed (id 1, version 0)."""
|
||||
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 test_current_absent_row_returns_zero_without_raising(
|
||||
db: Session, seeded_sources_meta: None
|
||||
) -> None:
|
||||
"""Defensive: a deleted seed row reads as 0 — never an exception."""
|
||||
db.execute(text("DELETE FROM sources_meta"))
|
||||
db.commit()
|
||||
assert current_sources_version(db) == 0
|
||||
|
||||
|
||||
def test_first_bump_zero_to_one(db: Session, seeded_sources_meta: None) -> None:
|
||||
"""Seeded at 0 (the pre-counter KB), the first bump returns 1 and
|
||||
``current`` reflects it."""
|
||||
assert current_sources_version(db) == 0
|
||||
assert bump_sources_version(db) == 1
|
||||
db.commit() # the caller commits — the helper only flushes
|
||||
assert current_sources_version(db) == 1
|
||||
|
||||
|
||||
def test_second_bump_increments(db: Session, seeded_sources_meta: None) -> None:
|
||||
"""Bumps are monotonic: 0 → 1 → 2, one step per KB-changing sync."""
|
||||
assert bump_sources_version(db) == 1
|
||||
db.commit()
|
||||
assert bump_sources_version(db) == 2
|
||||
db.commit()
|
||||
assert current_sources_version(db) == 2
|
||||
|
||||
|
||||
def test_bump_absent_row_upserts_to_one(db: Session, seeded_sources_meta: None) -> None:
|
||||
"""Upsert semantics: a deleted seed row is recreated by the first
|
||||
bump (version 0 → 1), never left dangling."""
|
||||
db.execute(text("DELETE FROM sources_meta"))
|
||||
db.commit()
|
||||
assert bump_sources_version(db) == 1
|
||||
db.commit()
|
||||
row = db.get(SourcesMeta, 1)
|
||||
assert row is not None, "the single row must be recreated"
|
||||
assert row.id == 1
|
||||
assert row.version == 1
|
||||
assert row.updated_at is not None, "updated_at must be server-stamped"
|
||||
|
||||
|
||||
def test_bump_flushes_without_committing(db: Session, seeded_sources_meta: None) -> None:
|
||||
"""The helper flushes, it does not commit: a rolled-back session
|
||||
must roll the bump back with it (each sync path owns its
|
||||
transaction)."""
|
||||
assert bump_sources_version(db) == 1
|
||||
db.rollback()
|
||||
assert current_sources_version(db) == 0, "the uncommitted bump must roll back"
|
||||
|
||||
|
||||
def test_bumps_in_separate_sessions_progress(db: Session, seeded_sources_meta: None) -> None:
|
||||
"""One writer at a time is the deployment reality, but two bumps in
|
||||
two sessions must not race to the same value: the second session
|
||||
sees the committed increment and lands on the next generation."""
|
||||
assert bump_sources_version(db) == 1
|
||||
db.commit()
|
||||
|
||||
other = SessionLocal()
|
||||
try:
|
||||
assert bump_sources_version(other) == 2
|
||||
other.commit()
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
db.expire_all() # drop the stale identity-map state
|
||||
assert current_sources_version(db) == 2
|
||||
Reference in New Issue
Block a user