feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user