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
+48 -8
View File
@@ -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