feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+84 -9
View File
@@ -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