feat: node-level text/summary versioning with deferred regeneration - #383
Open
harshrathod0585 wants to merge 4 commits into
Open
feat: node-level text/summary versioning with deferred regeneration#383harshrathod0585 wants to merge 4 commits into
harshrathod0585 wants to merge 4 commits into
Conversation
Add PageIndexClient.update(doc_id) for MD docs. Detects changed sections via a section-hash diff and re-summarizes only the changed sections plus their ancestors, reusing cached summaries for the rest. - extract_node_text_content now stamps a hierarchical title_path on each node, giving sections a stable identity across edits. - utils: hash_text, compute_section_hashes, find_ancestors helpers. - index() stores file_hash + section_hashes for MD docs so update() has a baseline; _ensure_doc_loaded restores them on demand. - update() gates on file_hash, then per-section hashes; returns the updated/added/deleted section paths. Markdown only: its heading structure is parsed deterministically, so the new tree shape is free and the LLM runs only on changed sections.
Indexing was non-idempotent: re-ingesting the same file minted a new UUID and wrote a duplicate <doc_id>.json every time, silently bloating the workspace and orphaning prior summaries. index() now resolves a document by its absolute path and reuses the existing doc_id, overwriting in place. New get_doc_id_by_path() exposes this lookup so callers can cleanly branch: index() when new, update() when known. Ships examples/incremental_update_demo.py demonstrating the index-vs-update flow, a PageIndex-themed sample.md, and an examples README.
update() computed summaries and then discarded them, so every tree it wrote back had no summaries at all. Two independent causes: 1. build_tree_from_nodes() rebuilt each node dict from scratch and never copied `summary`. md_to_tree() was unaffected because it summarizes after building the tree; update() summarizes before, so its results were dropped. Fixed at the shared function, which both paths use. 2. The cached-summary lookup keyed the old tree by bare `title` but read it by full `title_path`. title_path exists only on the flat node list, never on the persisted tree, so every clean section missed the cache and fell through to ''. Rebuilt the map by walking the tree with the same ' > ' join. Also adds split_summary_fields() so update() matches index()'s convention (parents -> prefix_summary, leaves -> summary), and drops the now-unused structure_to_list import. This mattered because get_document_structure() strips `text` and hands the model titles + summaries only. After an update the payload was bare titles, so retrieval had nothing to reason over -- it would misroute silently rather than error. sample.md is rewritten with longer sections so the demo actually crosses the 200-token summarization threshold; the previous version was short enough that every node returned raw text and no summary was generated. Tests: 2 new deterministic cases (no API key needed) covering summary survival through build_tree_from_nodes and the tree-walk/title_path key agreement. 7 passing.
Decouples "this node changed" from "this node's summary is current", so a small edit no longer prices a summary regeneration immediately. Each MD node carries two counters: text_version bumps on every detected content change (cheap, sha256) summary_version the text_version the stored summary was generated from A node is stale iff the two differ. update() now does NO LLM work at all. It diffs section hashes, bumps text_version on changed/added nodes, and leaves summary_version behind. Regeneration is deferred to the next read (get_document_structure), where every stale node is regenerated in one batch and summary_version catches up. N edits between two reads therefore cost one regeneration, not N. This also removes the previous ancestor-expansion pass, which was a no-op: a parent's `text` excludes its children, so re-summarizing an ancestor fed the model byte-identical input and produced the same summary at full cost. Parent summaries are still generated from each node's own text -- there is no child-to-parent roll-up, by design. Versions are monotonic across re-index. index() reuses the doc_id for a known path, so versions are carried forward and only bumped where the section hash actually moved; a reader holding version N never sees it drop. Also fixes a bug this exposed: _reconcile_summaries called _save_doc, which evicts `structure` from memory for lazy reload, so get_document_structure then served an empty tree. A retrieval against a doc with any stale node got `[]` and answered from hallucinated line numbers instead of erroring. Reconcile now reloads after saving. Scope: MD only. update() already rejects PDFs, and PDF nodes carry no section hashes, so version fields are simply absent there and the staleness check tolerates that. Deliberately not included: a semantic-change gate (embedding or LLM) to suppress regeneration for immaterial edits. Dropping propagation and deferring to read already removed both cost drivers, so the remaining saving is marginal and a predicate that can under-fire on a negation or a changed number risks the silent staleness this design exists to prevent. Tests: 8 new cases, summarizer stubbed so they run offline with no API key. 15 passing.
harshrathod0585
force-pushed
the
feat/node-summary-versioning
branch
from
August 3, 2026 05:21
59ee2ef to
0069c97
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #314. Implements the version and staleness split discussed in #316.
Each markdown node now carries
text_versionandsummary_version.text_versionbumps when the section hash changes, andsummary_versionrecords whichtext_versionthe stored summary was generated from, so a node is stale when the two differ.update()no longer calls the model. It diffs section hashes, bumpstext_versionon the changed and added sections, and stops. Regeneration happens on the next read of the document structure, with all the stale nodes going through in one batch, so several edits between two reads cost one regeneration rather than one each.It also no longer re-summarizes ancestors. That pass did nothing useful: a parent's text excludes its children, so re-summarizing an ancestor sent the model the same bytes it saw last time and got the same summary back, at one model call per ancestor.
Two bugs fixed along the way.
update()was discarding every summary it generated, becausebuild_tree_from_nodescreates fresh node dicts and never copied thesummaryfield, and the cache lookup for unchanged sections was keyed on the bare title but read back by full title path. After any update the saved tree had no summaries at all, which matters becauseget_document_structurestrips the body text and serves titles and summaries only. The second bug was that reconciling on read calls_save_doc, which dropsstructurefrom memory for lazy reloading, so the read then returned an empty tree.Markdown only.
update()already rejects PDFs and PDF nodes carry no section hashes, so the version fields are simply absent there.Eight new tests in
tests/test_summary_versioning.py. The summarizer is stubbed so they run offline without an API key, matching howtest_incremental_update.pyalready works. 15 passing.Note on the commits: this branch is cut from
feat/incremental-md-update, so the first two are shared with #314 and drop out of this diff once that merges.