Skip to content

Commit 40936e6

Browse files
committed
fix(cloud-import): stop windowed page fetch on a short window, not the tree's max index
_fetch_cloud_pages bounded its loop by the tree's max page index (_max_page_index). When the cloud tree under-reports the page count — a real case (e.g. a paper whose tree stops a couple pages short of the references) — the loop exited before fetching a later window and silently DROPPED pages. At the exact 1000 boundary this was an off-by-one (a 1001-page doc whose tree maxes at 1000 lost page 1001); more generally any >1000-page doc with an under-reporting tree was truncated. Drop the max-index bound: request fixed 1000-page windows and stop as soon as a window comes back SHORT (PageIndex page numbers are sequential, so a short window means we've passed the last page). The common ≤1000-page doc stays a single request, a larger doc fetches every page, and an under-reported tree no longer truncates. Remove the now-unused _max_page_index. Tests updated + a full-window-triggers-next-fetch regression.
1 parent 47b4616 commit 40936e6

2 files changed

Lines changed: 42 additions & 39 deletions

File tree

openkb/indexer.py

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -213,55 +213,35 @@ def index_long_document(
213213
# this many pages (``parse_pages`` raises "Page range too large (max 1000)"),
214214
# so cloud page fetches are windowed in chunks of this size.
215215
_CLOUD_PAGE_WINDOW = 1000
216+
# Safety bound on the windowed fetch (in pages) in case a backend never returns
217+
# a short window — caps the loop at _CLOUD_PAGE_MAX / _CLOUD_PAGE_WINDOW calls.
218+
_CLOUD_PAGE_MAX = 1_000_000
216219

217220

218-
def _max_page_index(structure: list, default: int = 0) -> int:
219-
"""Largest start/end page index across the (possibly nested) tree.
220-
221-
Cloud tree nodes carry ``start_index``/``end_index`` page numbers; this
222-
bounds the windowed cloud page fetch. Returns ``default`` (``0`` = unknown)
223-
when no integer indices are present, leaving the fetch to stop on the first
224-
empty window.
225-
"""
226-
best = 0
227-
228-
def _walk(nodes: list) -> None:
229-
nonlocal best
230-
for node in nodes or []:
231-
if not isinstance(node, dict):
232-
continue
233-
for key in ("end_index", "start_index"):
234-
val = node.get(key)
235-
if isinstance(val, int):
236-
best = max(best, val)
237-
_walk(node.get("nodes"))
238-
239-
_walk(structure)
240-
return best or default
241-
242-
243-
def _fetch_cloud_pages(col, doc_id: str, max_page: int) -> list[dict[str, Any]]:
221+
def _fetch_cloud_pages(col, doc_id: str) -> list[dict[str, Any]]:
244222
"""Fetch all OCR pages of a cloud doc, windowing around the 1000-page cap.
245223
246224
``get_page_content`` returns the whole document and uses its ``pages`` arg
247225
only as a client-side filter that ``parse_pages`` caps at 1000 pages — so a
248-
single ``"1-<N>"`` request fails for any doc over 1000 pages or whose tree
249-
exposes no integer indices. Request fixed ``1000``-page windows instead and
250-
concatenate; each window over-covers its range, so an off-by-one or 0-based
251-
``max_page`` never truncates the last page. ``max_page`` (0 = unknown) bounds
252-
the loop; otherwise it stops at the first empty window. A wide safety bound
253-
prevents an unbounded loop if the backend never returns an empty window.
226+
single ``"1-<N>"`` request fails for any doc over 1000 pages. Request fixed
227+
``1000``-page windows and stop as soon as a window comes back SHORT (fewer
228+
than a full window): PageIndex page numbers are sequential, so a short window
229+
means we've passed the last page. This is what makes the common (≤1000-page)
230+
doc a single request, while still fetching every page of a larger one — and,
231+
unlike bounding the loop by the tree's max page index, it never truncates a
232+
doc whose tree under-reports its page count (a real case: a paper whose tree
233+
stops a couple pages short of the references). A wide safety bound guards
234+
against a backend that never narrows the window.
254235
"""
255236
pages: list[dict[str, Any]] = []
256-
upper = max_page if max_page and max_page > 0 else 1_000_000
257237
start = 1
258-
while start <= upper:
238+
while start <= _CLOUD_PAGE_MAX:
259239
window = _normalize_page_content(
260240
col.get_page_content(doc_id, f"{start}-{start + _CLOUD_PAGE_WINDOW - 1}")
261241
)
262-
if not window:
263-
break
264242
pages.extend(window)
243+
if len(window) < _CLOUD_PAGE_WINDOW:
244+
break
265245
start += _CLOUD_PAGE_WINDOW
266246
return pages
267247

@@ -303,7 +283,7 @@ def import_cloud_document(doc_id: str, kb_dir: Path, path_key: str) -> CloudImpo
303283
"structure": structure,
304284
}
305285

306-
all_pages = _fetch_cloud_pages(col, doc_id, _max_page_index(structure))
286+
all_pages = _fetch_cloud_pages(col, doc_id)
307287
if not all_pages:
308288
raise RuntimeError(
309289
f"No page content returned from PageIndex Cloud for doc_id={doc_id}"

tests/test_indexer.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,17 +357,40 @@ def fake_get(doc_id, rng):
357357
col = MagicMock()
358358
col.get_page_content.side_effect = fake_get
359359

360-
pages = _fetch_cloud_pages(col, "doc", 0) # 0 = unknown bound → loop until empty
360+
pages = _fetch_cloud_pages(col, "doc")
361361
assert len(pages) == 1500
362362
assert pages[0]["page"] == 1 and pages[-1]["page"] == 1500
363363
ranges = [c.args[1] for c in col.get_page_content.call_args_list]
364-
assert ranges == ["1-1000", "1001-2000", "2001-3000"]
364+
# Full first window → fetch the next; the short 2nd window (500<1000) stops it.
365+
assert ranges == ["1-1000", "1001-2000"]
365366
# Every requested window spans exactly 1000 pages → parse_pages never raises.
366367
for r in ranges:
367368
a, b = (int(x) for x in r.split("-"))
368369
assert b - a + 1 == 1000
369370

370371

372+
def test_fetch_cloud_pages_full_window_triggers_next_fetch():
373+
"""A doc whose pages exactly fill the first window must still fetch the next
374+
one. Regression: bounding the loop by the tree's max page index dropped the
375+
straggler page(s) of a doc whose tree under-reported its page count.
376+
"""
377+
from openkb.indexer import _fetch_cloud_pages
378+
379+
def fake_get(doc_id, rng):
380+
start = int(rng.split("-")[0])
381+
if start == 1:
382+
return [{"page": p, "content": "x"} for p in range(1, 1001)] # full window
383+
if start == 1001:
384+
return [{"page": 1001, "content": "x"}] # one straggler past the window
385+
return []
386+
387+
col = MagicMock()
388+
col.get_page_content.side_effect = fake_get
389+
390+
pages = _fetch_cloud_pages(col, "doc")
391+
assert [p["page"] for p in pages] == list(range(1, 1002)) # page 1001 NOT dropped
392+
393+
371394
def test_import_cloud_document_no_indices_avoids_oversized_range(kb_dir, monkeypatch):
372395
"""A cloud tree with no integer page indices must NOT request a 100000-page
373396
range (parse_pages rejects >1000); it windows from page 1 instead.

0 commit comments

Comments
 (0)