Protocol B append (phases 146-149) for the four TODO items: the search tool line missing the grep's document scope (146), sync-only- what-changed via a raw-file byte hash checked before extraction and vision (147), pictorial PDFs showing their rendered pages in the sources viewer instead of page descriptions (148), and the sync detail moved to a text box under the button with the button as a spinner (149). TODO.md cleared (items now live in .agents/phases/ todo/). LLM-Generated: true
9.8 KiB
Phase 147 — sync only what changed: the source file's byte hash is checked BEFORE extraction and vision
Source: TODO.md L4 — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
Story: n/a (TODO-derived phase, Protocol B append).
Context: app/rag/importer.py — import_sources (L560: the per-source walk; the progress/on_chunk_progress hooks fire per file before indexing — unchanged by this phase), _index_file (L879: the single upsert entry — dispatches image → _index_image_file (L2642), .pdf → _index_pdf_file (L1181), text fallback), and the three per-branch unchanged blocks that today run only AFTER the expensive work: text (L928: digest over the read text — cheap, the read itself is the cost), image (L2660: digest over read_bytes() — the vision description runs only on a digest MISS, already fine), PDF (L~1303: digest over the FINAL constructed content — reached only after extract_pdf_text (L162 of app/rag/pdf.py) ALWAYS runs and, for garbage layers, _fallback_page_screenshots (every page rendered + vision-described) or, for sparse layers with pdf_pictorial on, _pictorial_pdf_pass (every candidate page rendered + vision-described) — so an UNCHANGED pictorial PDF re-extracts and re-burns vision on EVERY sync; a non-deterministic vision model additionally flips the final-content digest and forces a full re-chunk/re-embed/re-summary each run — the owner's "synced every time even if they haven't changed"). Document.content_hash (app/models.py L112) is the FINAL-CONTENT digest (exposed via app/rag/retriever.py L203/426/555/611 — the dedupe/caching identity) and stays untouched; the new file_hash is the RAW SOURCE-FILE-BYTES digest, compared first, used for change detection only. alembic/versions/ — the latest migration is 0024_git_source_branch.py. The unchanged-block semantics that must survive the fast path (all three branches, identical shape): summary.unchanged += 1 + log; the phase-118 (A2) summary-NULL backfill; the phase-106 (D1) created_at_manual early-return; the phase-106 (D4) date refresh (normalize_doc_date of the raw_date — mtime fallback — may go OLDER, counts summary.dates_updated, commits).
Locked (owner delegation 2026-09-30, chat — "pick the best choices"): change detection = sha256 of the RAW FILE BYTES (the owner's "hash the document contents"), computed ONCE at the top of _index_file — before the image/text/PDF dispatch — and compared against a new documents.file_hash column (migration 0025, nullable). A match takes a shared unchanged fast path: NO file read (beyond the streamed hash), NO PDF extraction, NO vision call, NO embed, NO summary call — only the preserved bookkeeping (summary-NULL backfill from the STORED doc.content, manual-date early-return, date refresh). Pre-phase rows (file_hash NULL) fall through to the branches' existing logic exactly as today and get their hash STAMPED on that first post-upgrade sync (one full pass, then fast forever). content_hash and everything built on it (retriever exposure, chunk identity) are untouched.
Objective
An unchanged source file costs one streamed sha256 and a row lookup per sync — zero extraction, zero vision, zero embedding, zero summary LLM — across all three document classes (text, image, pictorial PDF), while every preserved unchanged-path behavior (summary backfill, manual-date lock, date refresh, counts) is byte-for-byte maintained.
Dependencies
146_search_tool_scope(todo) — pipeline order only; no shared code (this phase touches the importer + one migration; 146 touches the tool-frame wire + the chat renderer).
Design (shared by all tasks — the executor reads this, not the chat)
- The column (task 01):
alembic/versions/0025_documents_file_hash.py—documents.file_hashString(64), nullable, no index (it is compared in Python after the unique(source, path)lookup — unlikecontent_hash, it is never a query surface and never exposed to the retriever); downgrade drops it.Document.file_hash: Mapped[str | None]+ docstring (the raw source-file-bytes sha256 — the sync change signal; DISTINCT fromcontent_hash— the final indexed content's digest, which for a pictorial PDF includes vision text and is the retriever's identity). - The fast path (task 02):
_file_hash(path: Path) -> str— sha256 over the raw bytes, streamed in 1 MiB chunks (a multi-hundred-MB PDF never loads fully; thehashlibincremental pattern)._index_filetop (before the image/PDF/text dispatch):file_digest = _file_hash(full_path); the row lookup (the(source, path)scalar select the branches already do — moved up);if doc is not None and doc.file_hash == file_digest:→_unchanged_doc(...)→ return. Theraw_datemtime fallback (the D2 convention: read before the unchanged early-return — the branches already do this; the fast path needs it for the date refresh) is computed in_index_filebefore the check whenraw_date is None._unchanged_doc(session, doc, *, source, rel, llm, summary, raw_date)— the ONE shared implementation of the three near-identical unchanged blocks:summary.unchanged += 1+ theimport: unchangedlog line (unchanged wording); the phase-118 (A2) backfill whendoc.summary is None—content=doc.content(EXACTLY what the bytes produced — for text the stored content IS the file text; for an image the stored description; for a PDF the stored final content — so the backfill never re-reads/re-extracts/re-describes); the D1created_at_manualearly-return; the D4 date refresh (target = normalize_doc_date(raw_date); on change: commit +summary.dates_updated+ theimport: date-refreshedlog line — unchanged wording). The three branches' existing unchanged blocks are REPLACED by the shared helper (the per-branch callsite passes the same arguments; behavior is identical, code is one site).- Hash stamping: every branch writes
doc.file_hash = file_digeston create AND update (all three — text, image, PDF —file_digestpassed down from_index_file). The fast path makes the stamp redundant for the common case, but a bytes-changed/content-same edge (e.g. a PDF metadata edit with identical extracted text) lands in a branch whose content digest still matches — the branch's unchanged flow (now the shared helper) ALSO stampsdoc.file_hash = file_digestand commits when it is dirty (the date-refresh commit may already have run — stamp BEFORE that block's final decision: set the attribute, and commit once at the end of the helper if dirty) so the NEXT sync takes the fast path. Pre-phase rows (file_hashNULL) take exactly this path on the first post-upgrade sync (full pipeline or the shared helper — whichever the branch's content digest decides) and are stamped either way. - The
progress/on_chunk_progresshooks are untouched (theprogresscall fires per file before_index_fileas today — an unchanged file was already "the current file" pre-phase; the fast path just makes it cheap).summary.filescounts the walked file either way. Pruning,limit, the per-source post-index passes (_enrich_pending_images,_backfill_image_contexts— row-driven) are untouched.
- The E2E (task 03): the
file://fixture-repo module-app idiom (thetest_sync_button.pyrecipe — real clone, mock LLM): run 1 (N added) → capture every document row'sindexed_at/content_hash→ run 2 → the settle line reads0 added · 0 updated · N unchangedand every row'sindexed_at/content_hashis unchanged (the re-index would bumpindexed_at) → commit a new file to the fixture repo → run 3 → only the new file isadded.
Tasks
01_file_hash_column.md— migration 0025 + the model field.02_unchanged_fast_path.md— the streamed hash, the top-of-_index_filecheck, the shared unchanged helper, the stamping, the branch rewiring, the unit matrix.03_e2e_unchanged_second_run.md— the dedicated E2E: the idempotent second sync is provably free of re-indexing.
Testing & Quality
- Unit: the fast-path matrix (text / image / garbage-PDF / pictorial-PDF unchanged → zero LLM calls; changed → full pipeline + stamp; NULL hash → pre-phase behavior + stamp; D1/D4 pins on the fast path; the streamed hash on a >1 MiB file).
- Integration: the migration upgrade/downgrade round-trip (the house migration-test idiom).
- E2E: one dedicated Playwright file, run in isolation, mock-only (real git clone).
- Coverage: >90% on new/modified code (
app/).
Completion Criteria
documents.file_hashexists (nullable String(64), no index) via migration 0025; the upgrade/downgrade round-trips on the test DB- an unchanged text file, image, garbage-layer PDF, and pictorial PDF each cost ZERO llm calls (embed + chat) and zero PDF extraction/vision on the second sync —
summary.unchangedcounts them;indexed_at/content_hashuntouched - a changed file (any byte) re-indexes fully and stamps the new hash; a bytes-changed/content-same file stamps the hash without a content change
- the preserved unchanged semantics are pinned: summary-NULL backfill,
created_at_manualearly-return, the date refresh (may go older, countsdates_updated, commits), the log-line wordings content_hashand the retriever's exposure of it are byte-identical to pre-phaseuv run pytest --cov=app --cov-report=term-missinggreen,app/coverage >90%;uv run pytest tests/e2e/test_sync_changed_only.py -v --no-covgreen in isolation;uv run ruff check . && uv run pyrightclean- no behavior change in completed phases (prune semantics,
limit/progresscontracts, the per-source post-index passes, the sync status/result wire)