Skip to content

feat: node-level text/summary versioning with deferred regeneration - #383

Open
harshrathod0585 wants to merge 4 commits into
VectifyAI:mainfrom
harshrathod0585:feat/node-summary-versioning
Open

feat: node-level text/summary versioning with deferred regeneration#383
harshrathod0585 wants to merge 4 commits into
VectifyAI:mainfrom
harshrathod0585:feat/node-summary-versioning

Conversation

@harshrathod0585

@harshrathod0585 harshrathod0585 commented Aug 3, 2026

Copy link
Copy Markdown

Follow-up to #314. Implements the version and staleness split discussed in #316.

Each markdown node now carries text_version and summary_version. text_version bumps when the section hash changes, and summary_version records which text_version the stored summary was generated from, so a node is stale when the two differ.

update() no longer calls the model. It diffs section hashes, bumps text_version on 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, because build_tree_from_nodes creates fresh node dicts and never copied the summary field, 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 because get_document_structure strips the body text and serves titles and summaries only. The second bug was that reconciling on read calls _save_doc, which drops structure from 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 how test_incremental_update.py already 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant