Skip to content

Commit 0069c97

Browse files
feat: node-level text/summary versioning with deferred regeneration
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.
1 parent d9a954b commit 0069c97

4 files changed

Lines changed: 331 additions & 43 deletions

File tree

pageindex/client.py

Lines changed: 100 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
remove_fields,
2323
hash_text,
2424
compute_section_hashes,
25-
find_ancestors,
25+
walk_with_paths,
2626
write_node_id,
2727
format_structure,
2828
)
@@ -132,7 +132,30 @@ def index(self, file_path: str, mode: str = "auto") -> str:
132132
# Compute hashes from the raw file to enable incremental update().
133133
_md_content = open(file_path, encoding='utf-8').read()
134134
_node_list, _md_lines = extract_nodes_from_markdown(_md_content)
135+
135136
_flat_nodes = extract_node_text_content(_node_list, _md_lines)
137+
_new_hashes = compute_section_hashes(_flat_nodes)
138+
# Re-indexing reuses the doc_id, so versions must carry forward:
139+
# a reader holding version N must never see it go backwards.
140+
if doc_id in self.documents and self.workspace:
141+
self._ensure_doc_loaded(doc_id)
142+
_prev = self.documents.get(doc_id, {})
143+
_old_versions = {
144+
p: n.get('text_version', 1)
145+
for p, n in walk_with_paths(_prev.get('structure') or [])
146+
}
147+
_old_hashes = _prev.get('section_hashes') or {}
148+
for _p, _n in walk_with_paths(result['structure']):
149+
_old_tv = _old_versions.get(_p)
150+
if _old_tv is None:
151+
_tv = 1
152+
elif _old_hashes.get(_p) != _new_hashes.get(_p):
153+
_tv = _old_tv + 1
154+
else:
155+
_tv = _old_tv
156+
# index() regenerates every summary, so nothing is left stale.
157+
_n['text_version'] = _tv
158+
_n['summary_version'] = _tv
136159
self.documents[doc_id] = {
137160
'id': doc_id,
138161
'type': 'md',
@@ -142,7 +165,7 @@ def index(self, file_path: str, mode: str = "auto") -> str:
142165
'line_count': result.get('line_count', 0),
143166
'structure': result['structure'],
144167
'file_hash': hash_text(_md_content),
145-
'section_hashes': compute_section_hashes(_flat_nodes),
168+
'section_hashes': _new_hashes,
146169
}
147170
else:
148171
raise ValueError(f"Unsupported file format for: {file_path}")
@@ -286,53 +309,40 @@ def update(self, doc_id: str) -> dict:
286309
deleted = old_keys - new_keys
287310
changed = {p for p in new_keys & old_keys if new_hashes[p] != old_hashes[p]}
288311

289-
# Dirty sections plus the ancestors of each (roll-up summaries).
290312
dirty = changed | added
291-
to_summarize = set(dirty)
292-
for path in dirty:
293-
to_summarize.update(find_ancestors(path))
294-
295-
# Reuse cached summaries for clean sections. The persisted tree has no
296-
# title_path, so rebuild it to match the keys used by new_nodes.
297-
old_summary_map = {}
298-
299-
def _collect_summaries(nodes, prefix=''):
300-
for n in nodes:
301-
path = f"{prefix} > {n['title']}" if prefix else n['title']
302-
old_summary_map[path] = n.get('summary') or n.get('prefix_summary', '')
303-
_collect_summaries(n.get('nodes', []), path)
304-
305-
_collect_summaries(doc.get('structure', []))
306-
307-
async def _identity(val):
308-
return val
309-
310-
async def _regenerate():
311-
tasks = {}
312-
for path, node in {n['title_path']: n for n in new_nodes}.items():
313-
if path in to_summarize:
314-
tasks[path] = get_node_summary(node, summary_token_threshold=200, model=self.model)
315-
else:
316-
tasks[path] = _identity(old_summary_map.get(path, ''))
317-
return {path: await coro for path, coro in tasks.items()}
318313

319-
try:
320-
asyncio.get_running_loop()
321-
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
322-
summaries = pool.submit(asyncio.run, _regenerate()).result()
323-
except RuntimeError:
324-
summaries = asyncio.run(_regenerate())
314+
# Carry summaries and versions forward from the old tree.
315+
old_by_path = dict(walk_with_paths(doc.get('structure', [])))
316+
old_summary_map = {
317+
p: n.get('summary') or n.get('prefix_summary', '')
318+
for p, n in old_by_path.items()
319+
}
325320

321+
# No LLM work here. Bump text_version on dirty sections and leave
322+
# summary_version behind, marking the summary stale; regeneration is
323+
# deferred to read time (see _reconcile_summaries). Repeated updates
324+
# between two reads therefore cost one regeneration, not one each.
326325
for node in new_nodes:
327-
node['summary'] = summaries.get(node['title_path'], '')
326+
path = node['title_path']
327+
old = old_by_path.get(path)
328+
node['summary'] = old_summary_map.get(path, '')
329+
if old is None:
330+
# Newly added: no summary yet, so it starts out stale.
331+
node['text_version'] = 1
332+
node['summary_version'] = 0
333+
else:
334+
old_tv = old.get('text_version', 1)
335+
node['text_version'] = old_tv + 1 if path in dirty else old_tv
336+
node['summary_version'] = old.get('summary_version', old_tv)
328337

329338
# Rebuild the tree with fresh node ids.
330339
new_structure = build_tree_from_nodes(new_nodes)
331340
split_summary_fields(new_structure)
332341
write_node_id(new_structure)
333342
new_structure = format_structure(
334343
new_structure,
335-
order=['title', 'node_id', 'line_num', 'summary', 'prefix_summary', 'text', 'nodes'],
344+
order=['title', 'node_id', 'line_num', 'text_version', 'summary_version',
345+
'summary', 'prefix_summary', 'text', 'nodes'],
336346
)
337347

338348
doc['structure'] = new_structure
@@ -360,10 +370,61 @@ def get_document(self, doc_id: str) -> str:
360370
"""Return document metadata JSON."""
361371
return get_document(self.documents, doc_id)
362372

373+
def _reconcile_summaries(self, doc_id: str) -> int:
374+
"""Regenerate summaries whose text has moved on, and return how many.
375+
376+
A node is stale when summary_version != text_version. update() only
377+
bumps text_version, so this is where deferred regeneration is paid --
378+
on the first read after an edit, batched across every stale node.
379+
"""
380+
if self.workspace:
381+
self._ensure_doc_loaded(doc_id)
382+
doc = self.documents.get(doc_id)
383+
if not doc or doc.get('type') != 'md':
384+
return 0
385+
386+
stale = [
387+
n for _, n in walk_with_paths(doc.get('structure', []))
388+
if n.get('summary_version') != n.get('text_version')
389+
]
390+
if not stale:
391+
return 0
392+
393+
async def _run():
394+
return await asyncio.gather(*(
395+
get_node_summary(n, summary_token_threshold=200, model=self.model)
396+
for n in stale
397+
))
398+
399+
try:
400+
asyncio.get_running_loop()
401+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
402+
summaries = pool.submit(asyncio.run, _run()).result()
403+
except RuntimeError:
404+
summaries = asyncio.run(_run())
405+
406+
for node, summary in zip(stale, summaries):
407+
key = 'prefix_summary' if node.get('nodes') else 'summary'
408+
node.pop('prefix_summary' if key == 'summary' else 'summary', None)
409+
node[key] = summary
410+
node['summary_version'] = node['text_version']
411+
412+
if self.workspace:
413+
# _save_doc evicts structure from memory for lazy reload; pull it
414+
# back so callers see the tree we just reconciled, not an empty one.
415+
self._save_doc(doc_id)
416+
self._ensure_doc_loaded(doc_id)
417+
return len(stale)
418+
363419
def get_document_structure(self, doc_id: str) -> str:
364-
"""Return document tree structure JSON (without text fields)."""
420+
"""Return document tree structure JSON (without text fields).
421+
422+
Stale summaries are regenerated first, so a reader never sees a
423+
summary that describes text the document no longer contains.
424+
"""
365425
if self.workspace:
366426
self._ensure_doc_loaded(doc_id)
427+
self._reconcile_summaries(doc_id)
367428
return get_document_structure(self.documents, doc_id)
368429

369430
def get_page_content(self, doc_id: str, pages: str) -> str:

pageindex/page_index_md.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,11 @@ def build_tree_from_nodes(node_list):
230230
'line_num': node['line_num'],
231231
'nodes': []
232232
}
233-
# Callers that summarize before building the tree (e.g. incremental
234-
# update) would otherwise have their summaries dropped here.
235-
if 'summary' in node:
236-
tree_node['summary'] = node['summary']
233+
# Callers that summarize or version nodes before building the tree
234+
# (e.g. incremental update) would otherwise have those fields dropped.
235+
for field in ('summary', 'text_version', 'summary_version'):
236+
if field in node:
237+
tree_node[field] = node[field]
237238
node_counter += 1
238239

239240
while stack and stack[-1][1] >= current_level:

pageindex/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,19 @@ def compute_section_hashes(node_list: list) -> dict:
723723
return {node["title_path"]: hash_text(node.get("text", "")) for node in node_list}
724724

725725

726+
def walk_with_paths(nodes, prefix=""):
727+
"""Yield (title_path, node) for every node in a tree.
728+
729+
The persisted tree stores no title_path; this reconstructs it with the
730+
same ' > ' join used by extract_node_text_content, so tree nodes can be
731+
matched against the flat node list and section_hashes keys.
732+
"""
733+
for node in nodes:
734+
path = f"{prefix} > {node['title']}" if prefix else node["title"]
735+
yield path, node
736+
yield from walk_with_paths(node.get("nodes", []), path)
737+
738+
726739
def find_ancestors(title_path: str) -> list:
727740
"""Return ancestor title paths from root to immediate parent."""
728741
parts = title_path.split(" > ")

0 commit comments

Comments
 (0)