2026-04-06 22:51:04 +08:00
import litellm
import logging
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
import os
import textwrap
2026-04-06 22:51:04 +08:00
import time
import json
import copy
import re
import asyncio
fix(index): bound LLM concurrency at the leaf, not per gather call
The previous approach (bounded_gather building a fresh semaphore per call)
did NOT compose: the indexing call graph nests gathers
(tree_parser -> process_large_node_recursively -> recurse, plus each node's
check_title gather), and each level got its own independent cap. Peak
in-flight LLM calls grew ~N^depth, so a deep/wide document still exhausted
file descriptors (Errno 24) — the exact failure the cap was meant to
prevent — while the flat summary phase was over-serialized. Naively sharing
one semaphore across gather levels would instead deadlock (a parent holds a
slot while awaiting children that need slots).
Move the throttle to the single chokepoint every LLM call funnels through,
llm_acompletion: one shared semaphore per event loop, acquired only around
the litellm.acompletion network call. This gives a true global cap that
composes across any nesting and can't deadlock (a parent awaiting children
holds no slot). bounded_gather is gone; the call sites revert to plain
asyncio.gather.
Also:
- Reject bool in max_concurrency validation (bool is an int subclass, so
set_max_concurrency(True) / IndexConfig(max_concurrency=True) previously
became Semaphore(1) and silently serialized). Shared _validate_max_concurrency
+ a pydantic field_validator.
- Guard check_title_appearance_in_start_concurrent against an out-of-range
or 0 physical_index (LLM can emit one): it was dereferenced during task
construction, outside the gather's return_exceptions protection, aborting
the whole build; 0 silently wrapped to the last page. Now marked 'no'.
- Propagate contextvars into agent.py's worker-thread run (mirrors
pipeline._run_async) so ContextVar settings stay consistent.
- Tests rewritten to cover the nested case the old flat tests missed, the
leaf-level throttle in llm_acompletion, bool rejection, and the
out-of-range physical_index guard.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-08 11:26:36 +08:00
import threading
2026-04-06 22:51:04 +08:00
import PyPDF2
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
import pymupdf
import yaml
from datetime import datetime
from io import BytesIO
from pathlib import Path
from pprint import pprint
2026-07-08 10:31:37 +08:00
# Aliased with a leading underscore so `from .utils import *` (used by the
# page_index modules) doesn't export a name `config` that would shadow the real
# `pageindex.config` submodule for those modules.
from types import SimpleNamespace as _config
2026-04-06 22:51:04 +08:00
2026-07-09 22:12:19 +08:00
from contextlib import asynccontextmanager , contextmanager
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
2026-07-09 22:12:19 +08:00
from . . config import (
get_llm_params ,
get_max_concurrency ,
_max_concurrency_scope_semaphore ,
_process_wide_max_concurrency ,
)
refactor(sdk): typed returns, protocol contract, parser layering
Engineering-quality cleanups from the SDK review (no behavior change):
- Return-type discoverability: add pageindex/types.py with TypedDicts
(DocumentInfo, DocumentDetail, PageContent) and annotate Collection /
Backend methods with them; add docstrings to every public Collection
method (including the get_page_content `pages` spec). Exported from the
package. Zero runtime cost — these are plain dicts.
- Backend protocol as a real contract:
* query_stream is an async generator, so the protocol now declares it
as `def ... -> AsyncIterator[QueryEvent]` (not `async def`, which
typed it as a coroutine and never matched the implementations).
* custom-parser support is expressed as a runtime_checkable
SupportsParserRegistration capability protocol; the client uses
isinstance(...) instead of hasattr(...) duck-typing.
- Parser layering: move count_tokens into a leaf module pageindex/tokens.py
so parser/* imports it from there instead of reaching back into
pageindex.index (a reverse dependency). index.utils re-exports it for
backward compatibility.
Adds tests/test_architecture.py enforcing: parser never imports index,
count_tokens is a single shared leaf, the capability protocol works,
both backends satisfy Backend, and the TypedDicts are exported.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 12:15:34 +08:00
from . . tokens import count_tokens # re-exported for backward compat
2026-06-25 16:47:27 +08:00
2026-04-06 22:51:04 +08:00
logger = logging . getLogger ( __name__ )
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
# TRUE process-wide ceiling on concurrent in-flight LLM calls, shared across
# EVERY thread and event loop (a plain threading.Semaphore, not an
# asyncio.Semaphore — those are bound to the loop that created them, so one per
# loop would let N concurrently-indexing threads each get their own full-size
# cap and multiply the effective bound by N). Resized lazily when the
# process-wide default changes; resizing isn't perfectly atomic against
# in-flight acquires, which is fine since it only happens on an explicit
# set_max_concurrency() config change, not on the hot path.
_PROCESS_LLM_SEMAPHORE : threading . Semaphore | None = None
_PROCESS_LLM_SEMAPHORE_SIZE : int | None = None
_PROCESS_LLM_SEMAPHORE_LOCK = threading . Lock ( )
def _process_ceiling_semaphore ( ) - > threading . Semaphore :
global _PROCESS_LLM_SEMAPHORE , _PROCESS_LLM_SEMAPHORE_SIZE
size = _process_wide_max_concurrency ( )
with _PROCESS_LLM_SEMAPHORE_LOCK :
if _PROCESS_LLM_SEMAPHORE is None or _PROCESS_LLM_SEMAPHORE_SIZE != size :
_PROCESS_LLM_SEMAPHORE = threading . Semaphore ( size )
_PROCESS_LLM_SEMAPHORE_SIZE = size
return _PROCESS_LLM_SEMAPHORE
@asynccontextmanager
async def _llm_semaphore ( ) :
""" Bound concurrent in-flight LLM calls to a TRUE process-wide ceiling,
optionally narrowed further by an active max_concurrency_scope ( ) override .
2026-07-08 10:31:37 +08:00
fix(index): bound LLM concurrency at the leaf, not per gather call
The previous approach (bounded_gather building a fresh semaphore per call)
did NOT compose: the indexing call graph nests gathers
(tree_parser -> process_large_node_recursively -> recurse, plus each node's
check_title gather), and each level got its own independent cap. Peak
in-flight LLM calls grew ~N^depth, so a deep/wide document still exhausted
file descriptors (Errno 24) — the exact failure the cap was meant to
prevent — while the flat summary phase was over-serialized. Naively sharing
one semaphore across gather levels would instead deadlock (a parent holds a
slot while awaiting children that need slots).
Move the throttle to the single chokepoint every LLM call funnels through,
llm_acompletion: one shared semaphore per event loop, acquired only around
the litellm.acompletion network call. This gives a true global cap that
composes across any nesting and can't deadlock (a parent awaiting children
holds no slot). bounded_gather is gone; the call sites revert to plain
asyncio.gather.
Also:
- Reject bool in max_concurrency validation (bool is an int subclass, so
set_max_concurrency(True) / IndexConfig(max_concurrency=True) previously
became Semaphore(1) and silently serialized). Shared _validate_max_concurrency
+ a pydantic field_validator.
- Guard check_title_appearance_in_start_concurrent against an out-of-range
or 0 physical_index (LLM can emit one): it was dereferenced during task
construction, outside the gather's return_exceptions protection, aborting
the whole build; 0 silently wrapped to the last page. Now marked 'no'.
- Propagate contextvars into agent.py's worker-thread run (mirrors
pipeline._run_async) so ContextVar settings stay consistent.
- Tests rewritten to cover the nested case the old flat tests missed, the
leaf-level throttle in llm_acompletion, bool rejection, and the
out-of-range physical_index guard.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-08 11:26:36 +08:00
Acquired only around the leaf ` ` litellm . acompletion ` ` call in
2026-07-09 22:12:19 +08:00
` ` llm_acompletion ` ` — sync calls use ` ` _sync_llm_semaphore ` ` below — so the
cap holds no matter how deeply the indexing gathers nest
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
( ` ` tree_parser ` ` → ` ` process_large_node_recursively ` ` → … ) AND no matter how
many threads are each running their own indexing job concurrently . Bounding
at the leaf rather than at each gather call site is also deadlock - free : a
parent coroutine awaiting its children holds no slot , so children can
always acquire one .
The process ceiling ( threading . Semaphore , shared cross - thread ) is sized from
the process - wide default only ; a narrower max_concurrency_scope ( ) override
2026-07-09 22:12:19 +08:00
is enforced as a second , nested context - local restriction — it can only
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
* tighten * the effective cap for its own call tree , never widen it past the
ceiling . Without the outer bound a many - node document opens one socket per
node at once and exhausts the process file - descriptor limit ( Errno 24 ) .
fix(index): bound LLM concurrency at the leaf, not per gather call
The previous approach (bounded_gather building a fresh semaphore per call)
did NOT compose: the indexing call graph nests gathers
(tree_parser -> process_large_node_recursively -> recurse, plus each node's
check_title gather), and each level got its own independent cap. Peak
in-flight LLM calls grew ~N^depth, so a deep/wide document still exhausted
file descriptors (Errno 24) — the exact failure the cap was meant to
prevent — while the flat summary phase was over-serialized. Naively sharing
one semaphore across gather levels would instead deadlock (a parent holds a
slot while awaiting children that need slots).
Move the throttle to the single chokepoint every LLM call funnels through,
llm_acompletion: one shared semaphore per event loop, acquired only around
the litellm.acompletion network call. This gives a true global cap that
composes across any nesting and can't deadlock (a parent awaiting children
holds no slot). bounded_gather is gone; the call sites revert to plain
asyncio.gather.
Also:
- Reject bool in max_concurrency validation (bool is an int subclass, so
set_max_concurrency(True) / IndexConfig(max_concurrency=True) previously
became Semaphore(1) and silently serialized). Shared _validate_max_concurrency
+ a pydantic field_validator.
- Guard check_title_appearance_in_start_concurrent against an out-of-range
or 0 physical_index (LLM can emit one): it was dereferenced during task
construction, outside the gather's return_exceptions protection, aborting
the whole build; 0 silently wrapped to the last page. Now marked 'no'.
- Propagate contextvars into agent.py's worker-thread run (mirrors
pipeline._run_async) so ContextVar settings stay consistent.
- Tests rewritten to cover the nested case the old flat tests missed, the
leaf-level throttle in llm_acompletion, bool rejection, and the
out-of-range physical_index guard.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-08 11:26:36 +08:00
"""
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
ceiling_sem = _process_ceiling_semaphore ( )
2026-07-09 16:33:09 +08:00
# A blocking ceiling_sem.acquire() run via asyncio.to_thread() would be
# unsafe under cancellation: the worker thread can't be interrupted, so if
# this coroutine is cancelled (Ctrl-C, an outer timeout) while the thread
# is still parked inside acquire(), the thread can go on to actually
# acquire a permit *after* we've already unwound — leaking it forever,
# since the matching finally: release() below never runs for that attempt.
# Poll with the non-blocking form instead: each check returns immediately
# (no OS-level wait), so it's safe to call straight from the event loop
# thread and there's no window for a background acquire to succeed after
# we've already given up on it.
while not ceiling_sem . acquire ( False ) :
await asyncio . sleep ( 0.05 )
2026-07-09 23:08:39 +08:00
# Only set once the permit is actually held, so a cancellation while polling
# for it doesn't make the finally release a permit we never acquired (which
# would inflate the scoped cap — the mirror of the ceiling leak fixed above).
2026-07-09 22:12:19 +08:00
scoped_sem = None
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
try :
effective = get_max_concurrency ( )
ceiling = _process_wide_max_concurrency ( )
if effective < ceiling :
2026-07-09 23:08:39 +08:00
candidate = _max_concurrency_scope_semaphore ( )
if candidate is not None :
while not candidate . acquire ( False ) :
2026-07-09 22:12:19 +08:00
await asyncio . sleep ( 0.05 )
2026-07-09 23:08:39 +08:00
scoped_sem = candidate
yield
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
finally :
2026-07-09 22:12:19 +08:00
if scoped_sem is not None :
scoped_sem . release ( )
ceiling_sem . release ( )
@contextmanager
def _sync_llm_semaphore ( ) :
""" Synchronous companion to ``_llm_semaphore`` for ``llm_completion``.
It uses the same process - wide ceiling so sync and async LLM calls share one
real cap . A scoped override can only narrow that cap for the active context .
"""
ceiling_sem = _process_ceiling_semaphore ( )
ceiling_sem . acquire ( )
2026-07-09 23:08:39 +08:00
# Only set once the permit is actually held (mirrors _llm_semaphore): guards
# against releasing a permit we never acquired if acquire() is interrupted.
2026-07-09 22:12:19 +08:00
scoped_sem = None
try :
effective = get_max_concurrency ( )
ceiling = _process_wide_max_concurrency ( )
if effective < ceiling :
2026-07-09 23:08:39 +08:00
candidate = _max_concurrency_scope_semaphore ( )
if candidate is not None :
candidate . acquire ( )
scoped_sem = candidate
2026-07-09 22:12:19 +08:00
yield
finally :
if scoped_sem is not None :
scoped_sem . release ( )
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
ceiling_sem . release ( )
2026-07-08 10:31:37 +08:00
2026-04-06 22:51:04 +08:00
def llm_completion ( model , prompt , chat_history = None , return_finish_reason = False ) :
if model :
model = model . removeprefix ( " litellm/ " )
max_retries = 10
messages = list ( chat_history ) + [ { " role " : " user " , " content " : prompt } ] if chat_history else [ { " role " : " user " , " content " : prompt } ]
for i in range ( max_retries ) :
try :
2026-07-09 22:12:19 +08:00
# Hold a concurrency slot only around the actual network call, not
# retry backoff, so sync completions obey the same cap as async ones.
with _sync_llm_semaphore ( ) :
response = litellm . completion (
model = model ,
messages = messages ,
# Per-call litellm kwargs (default temperature=0, drop_params=True);
# configure via config.set_llm_params(...) — never the litellm global.
* * get_llm_params ( ) ,
)
2026-04-06 22:51:04 +08:00
content = response . choices [ 0 ] . message . content
if return_finish_reason :
finish_reason = " max_output_reached " if response . choices [ 0 ] . finish_reason == " length " else " finished "
return content , finish_reason
return content
except Exception as e :
logger . warning ( " Retrying LLM completion ( %d / %d ) " , i + 1 , max_retries )
logger . error ( f " Error: { e } " )
if i < max_retries - 1 :
time . sleep ( 1 )
else :
logger . error ( ' Max retries reached for prompt: ' + prompt )
raise RuntimeError ( f " LLM call failed after { max_retries } retries " ) from e
async def llm_acompletion ( model , prompt ) :
if model :
model = model . removeprefix ( " litellm/ " )
max_retries = 10
messages = [ { " role " : " user " , " content " : prompt } ]
for i in range ( max_retries ) :
try :
fix(index): bound LLM concurrency at the leaf, not per gather call
The previous approach (bounded_gather building a fresh semaphore per call)
did NOT compose: the indexing call graph nests gathers
(tree_parser -> process_large_node_recursively -> recurse, plus each node's
check_title gather), and each level got its own independent cap. Peak
in-flight LLM calls grew ~N^depth, so a deep/wide document still exhausted
file descriptors (Errno 24) — the exact failure the cap was meant to
prevent — while the flat summary phase was over-serialized. Naively sharing
one semaphore across gather levels would instead deadlock (a parent holds a
slot while awaiting children that need slots).
Move the throttle to the single chokepoint every LLM call funnels through,
llm_acompletion: one shared semaphore per event loop, acquired only around
the litellm.acompletion network call. This gives a true global cap that
composes across any nesting and can't deadlock (a parent awaiting children
holds no slot). bounded_gather is gone; the call sites revert to plain
asyncio.gather.
Also:
- Reject bool in max_concurrency validation (bool is an int subclass, so
set_max_concurrency(True) / IndexConfig(max_concurrency=True) previously
became Semaphore(1) and silently serialized). Shared _validate_max_concurrency
+ a pydantic field_validator.
- Guard check_title_appearance_in_start_concurrent against an out-of-range
or 0 physical_index (LLM can emit one): it was dereferenced during task
construction, outside the gather's return_exceptions protection, aborting
the whole build; 0 silently wrapped to the last page. Now marked 'no'.
- Propagate contextvars into agent.py's worker-thread run (mirrors
pipeline._run_async) so ContextVar settings stay consistent.
- Tests rewritten to cover the nested case the old flat tests missed, the
leaf-level throttle in llm_acompletion, bool rejection, and the
out-of-range physical_index guard.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-08 11:26:36 +08:00
# Hold a concurrency slot only around the actual network call — not
# across retry backoff — so the cap counts real in-flight requests.
async with _llm_semaphore ( ) :
response = await litellm . acompletion (
model = model ,
messages = messages ,
* * get_llm_params ( ) , # per-call kwargs; never the litellm global
)
2026-04-06 22:51:04 +08:00
return response . choices [ 0 ] . message . content
except Exception as e :
logger . warning ( " Retrying async LLM completion ( %d / %d ) " , i + 1 , max_retries )
logger . error ( f " Error: { e } " )
if i < max_retries - 1 :
await asyncio . sleep ( 1 )
else :
logger . error ( ' Max retries reached for prompt: ' + prompt )
raise RuntimeError ( f " LLM call failed after { max_retries } retries " ) from e
def extract_json ( content ) :
try :
# First, try to extract JSON enclosed within ```json and ```
start_idx = content . find ( " ```json " )
if start_idx != - 1 :
start_idx + = 7 # Adjust index to start after the delimiter
end_idx = content . rfind ( " ``` " )
json_content = content [ start_idx : end_idx ] . strip ( )
else :
# If no delimiters, assume entire content could be JSON
json_content = content . strip ( )
# Clean up common issues that might cause parsing errors
json_content = json_content . replace ( ' None ' , ' null ' ) # Replace Python None with JSON null
json_content = json_content . replace ( ' \n ' , ' ' ) . replace ( ' \r ' , ' ' ) # Remove newlines
json_content = ' ' . join ( json_content . split ( ) ) # Normalize whitespace
# Attempt to parse and return the JSON object
return json . loads ( json_content )
except json . JSONDecodeError as e :
logging . error ( f " Failed to extract JSON: { e } " )
# Try to clean up the content further if initial parsing fails
try :
# Remove any trailing commas before closing brackets/braces
json_content = json_content . replace ( ' ,] ' , ' ] ' ) . replace ( ' ,} ' , ' } ' )
return json . loads ( json_content )
except Exception :
logging . error ( " Failed to parse JSON even after cleanup " )
return { }
except Exception as e :
logging . error ( f " Unexpected error while extracting JSON: { e } " )
return { }
def get_json_content ( response ) :
start_idx = response . find ( " ```json " )
if start_idx != - 1 :
start_idx + = 7
response = response [ start_idx : ]
end_idx = response . rfind ( " ``` " )
if end_idx != - 1 :
response = response [ : end_idx ]
json_content = response . strip ( )
return json_content
def write_node_id ( data , node_id = 0 ) :
if isinstance ( data , dict ) :
data [ ' node_id ' ] = str ( node_id ) . zfill ( 4 )
node_id + = 1
for key in list ( data . keys ( ) ) :
if ' nodes ' in key :
node_id = write_node_id ( data [ key ] , node_id )
elif isinstance ( data , list ) :
for index in range ( len ( data ) ) :
node_id = write_node_id ( data [ index ] , node_id )
return node_id
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
def remove_fields ( data , fields = None , max_len = None ) :
2026-04-06 22:51:04 +08:00
fields = fields or [ " text " ]
if isinstance ( data , dict ) :
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
return { k : remove_fields ( v , fields , max_len )
2026-04-06 22:51:04 +08:00
for k , v in data . items ( ) if k not in fields }
elif isinstance ( data , list ) :
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
return [ remove_fields ( item , fields , max_len ) for item in data ]
elif isinstance ( data , str ) :
return data [ : max_len ] + ' ... ' if max_len is not None and len ( data ) > max_len else data
2026-04-06 22:51:04 +08:00
return data
def structure_to_list ( structure ) :
if isinstance ( structure , dict ) :
nodes = [ ]
nodes . append ( structure )
if ' nodes ' in structure :
nodes . extend ( structure_to_list ( structure [ ' nodes ' ] ) )
return nodes
elif isinstance ( structure , list ) :
nodes = [ ]
for item in structure :
nodes . extend ( structure_to_list ( item ) )
return nodes
def get_nodes ( structure ) :
if isinstance ( structure , dict ) :
structure_node = copy . deepcopy ( structure )
structure_node . pop ( ' nodes ' , None )
nodes = [ structure_node ]
for key in list ( structure . keys ( ) ) :
if ' nodes ' in key :
nodes . extend ( get_nodes ( structure [ key ] ) )
return nodes
elif isinstance ( structure , list ) :
nodes = [ ]
for item in structure :
nodes . extend ( get_nodes ( item ) )
return nodes
def get_leaf_nodes ( structure ) :
if isinstance ( structure , dict ) :
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
# .get() — clean_node deletes the 'nodes' key on leaf nodes, so direct
# indexing raises KeyError on a standard tree (issue #330 / #331).
if not structure . get ( ' nodes ' ) :
2026-04-06 22:51:04 +08:00
structure_node = copy . deepcopy ( structure )
structure_node . pop ( ' nodes ' , None )
return [ structure_node ]
else :
leaf_nodes = [ ]
for key in list ( structure . keys ( ) ) :
if ' nodes ' in key :
leaf_nodes . extend ( get_leaf_nodes ( structure [ key ] ) )
return leaf_nodes
elif isinstance ( structure , list ) :
leaf_nodes = [ ]
for item in structure :
leaf_nodes . extend ( get_leaf_nodes ( item ) )
return leaf_nodes
async def generate_node_summary ( node , model = None ) :
prompt = f """ You are given a part of a document, your task is to generate a description of the partial document about what are main points covered in the partial document.
Partial Document Text : { node [ ' text ' ] }
Directly return the description , do not include any other text .
"""
response = await llm_acompletion ( model , prompt )
return response
async def generate_summaries_for_structure ( structure , model = None ) :
nodes = structure_to_list ( structure )
tasks = [ generate_node_summary ( node , model = model ) for node in nodes ]
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
# return_exceptions=True: one node's summary failing (e.g. a transient LLM
# error) must not abort summarization for the whole document — fall back
# to the node's own raw text so retrieval still has something usable.
raw_summaries = await asyncio . gather ( * tasks , return_exceptions = True )
summaries = [
node . get ( ' text ' , ' ' ) if isinstance ( s , Exception ) else s
for node , s in zip ( nodes , raw_summaries )
]
2026-04-06 22:51:04 +08:00
for node , summary in zip ( nodes , summaries ) :
node [ ' summary ' ] = summary
return structure
def generate_doc_description ( structure , model = None ) :
prompt = f """ Your are an expert in generating descriptions for a document.
You are given a structure of a document . Your task is to generate a one - sentence description for the document , which makes it easy to distinguish the document from other documents .
Document Structure : { structure }
Directly return the description , do not include any other text .
"""
response = llm_completion ( model , prompt )
return response
def list_to_tree ( data ) :
def get_parent_structure ( structure ) :
""" Helper function to get the parent structure code """
if not structure :
return None
parts = str ( structure ) . split ( ' . ' )
return ' . ' . join ( parts [ : - 1 ] ) if len ( parts ) > 1 else None
# First pass: Create nodes and track parent-child relationships
nodes = { }
root_nodes = [ ]
for item in data :
structure = item . get ( ' structure ' )
node = {
' title ' : item . get ( ' title ' ) ,
' start_index ' : item . get ( ' start_index ' ) ,
' end_index ' : item . get ( ' end_index ' ) ,
' nodes ' : [ ]
}
nodes [ structure ] = node
# Find parent
parent_structure = get_parent_structure ( structure )
if parent_structure :
# Add as child to parent if parent exists
if parent_structure in nodes :
nodes [ parent_structure ] [ ' nodes ' ] . append ( node )
else :
root_nodes . append ( node )
else :
# No parent, this is a root node
root_nodes . append ( node )
# Helper function to clean empty children arrays
def clean_node ( node ) :
if not node [ ' nodes ' ] :
del node [ ' nodes ' ]
else :
for child in node [ ' nodes ' ] :
clean_node ( child )
return node
# Clean and return the tree
return [ clean_node ( node ) for node in root_nodes ]
def post_processing ( structure , end_physical_index ) :
# First convert page_number to start_index in flat list
for i , item in enumerate ( structure ) :
item [ ' start_index ' ] = item . get ( ' physical_index ' )
if i < len ( structure ) - 1 :
if structure [ i + 1 ] . get ( ' appear_start ' ) == ' yes ' :
item [ ' end_index ' ] = structure [ i + 1 ] [ ' physical_index ' ] - 1
else :
item [ ' end_index ' ] = structure [ i + 1 ] [ ' physical_index ' ]
else :
item [ ' end_index ' ] = end_physical_index
tree = list_to_tree ( structure )
if len ( tree ) != 0 :
return tree
else :
### remove appear_start
for node in structure :
node . pop ( ' appear_start ' , None )
node . pop ( ' physical_index ' , None )
return structure
def reorder_dict ( data , key_order ) :
if not key_order :
return data
return { key : data [ key ] for key in key_order if key in data }
def format_structure ( structure , order = None ) :
if not order :
return structure
if isinstance ( structure , dict ) :
if ' nodes ' in structure :
structure [ ' nodes ' ] = format_structure ( structure [ ' nodes ' ] , order )
if not structure . get ( ' nodes ' ) :
structure . pop ( ' nodes ' , None )
structure = reorder_dict ( structure , order )
elif isinstance ( structure , list ) :
structure = [ format_structure ( item , order ) for item in structure ]
return structure
def create_clean_structure_for_description ( structure ) :
"""
Create a clean structure for document description generation ,
excluding unnecessary fields like ' text ' .
"""
if isinstance ( structure , dict ) :
clean_node = { }
# Only include essential fields for description
for key in [ ' title ' , ' node_id ' , ' summary ' , ' prefix_summary ' ] :
if key in structure :
clean_node [ key ] = structure [ key ]
# Recursively process child nodes
if ' nodes ' in structure and structure [ ' nodes ' ] :
clean_node [ ' nodes ' ] = create_clean_structure_for_description ( structure [ ' nodes ' ] )
return clean_node
elif isinstance ( structure , list ) :
return [ create_clean_structure_for_description ( item ) for item in structure ]
else :
return structure
def _get_text_of_pages ( page_list , start_page , end_page ) :
""" Concatenate text from page_list for pages [start_page, end_page] (1-indexed). """
text = " "
for page_num in range ( start_page - 1 , end_page ) :
text + = page_list [ page_num ] [ 0 ]
return text
def add_node_text ( node , page_list ) :
""" Recursively add ' text ' field to each node from page_list content.
Each node must have ' start_index ' and ' end_index ' ( 1 - indexed page numbers ) .
page_list is [ ( page_text , token_count ) , . . . ] .
"""
if isinstance ( node , dict ) :
start_page = node . get ( ' start_index ' )
end_page = node . get ( ' end_index ' )
if start_page is not None and end_page is not None :
node [ ' text ' ] = _get_text_of_pages ( page_list , start_page , end_page )
if ' nodes ' in node :
add_node_text ( node [ ' nodes ' ] , page_list )
elif isinstance ( node , list ) :
for item in node :
add_node_text ( item , page_list )
def remove_structure_text ( data ) :
if isinstance ( data , dict ) :
data . pop ( ' text ' , None )
if ' nodes ' in data :
remove_structure_text ( data [ ' nodes ' ] )
elif isinstance ( data , list ) :
for item in data :
remove_structure_text ( item )
return data
# ── Functions migrated from retrieve.py ──────────────────────────────────────
def parse_pages ( pages : str ) - > list [ int ] :
""" Parse a pages string like ' 5-7 ' , ' 3,8 ' , or ' 12 ' into a sorted list of ints. """
result = [ ]
for part in pages . split ( ' , ' ) :
part = part . strip ( )
if ' - ' in part :
start , end = int ( part . split ( ' - ' , 1 ) [ 0 ] . strip ( ) ) , int ( part . split ( ' - ' , 1 ) [ 1 ] . strip ( ) )
if start > end :
raise ValueError ( f " Invalid range ' { part } ' : start must be <= end " )
result . extend ( range ( start , end + 1 ) )
else :
result . append ( int ( part ) )
result = [ p for p in result if p > = 1 ]
result = sorted ( set ( result ) )
if len ( result ) > 1000 :
raise ValueError ( f " Page range too large: { len ( result ) } pages (max 1000) " )
return result
def get_pdf_page_content ( file_path : str , page_nums : list [ int ] ) - > list [ dict ] :
""" Extract text for specific PDF pages (1-indexed), opening the PDF once. """
with open ( file_path , ' rb ' ) as f :
pdf_reader = PyPDF2 . PdfReader ( f )
total = len ( pdf_reader . pages )
valid_pages = [ p for p in page_nums if 1 < = p < = total ]
return [
{ ' page ' : p , ' content ' : pdf_reader . pages [ p - 1 ] . extract_text ( ) or ' ' }
for p in valid_pages
]
def get_md_page_content ( structure : list , page_nums : list [ int ] ) - > list [ dict ] :
"""
For Markdown documents , ' pages ' are line numbers .
2026-07-08 17:33:41 +08:00
Return only the nodes whose line_num is one of ` ` page_nums ` ` ( exact match ) ,
mirroring the PDF path . A non - contiguous spec like [ 5 , 100 ] returns just
those two lines , not the whole [ 5 , 100 ] range .
2026-04-06 22:51:04 +08:00
"""
if not page_nums :
return [ ]
2026-07-08 17:33:41 +08:00
wanted = set ( page_nums )
2026-04-06 22:51:04 +08:00
results = [ ]
seen = set ( )
def _traverse ( nodes ) :
for node in nodes :
ln = node . get ( ' line_num ' )
2026-07-08 17:33:41 +08:00
if ln in wanted and ln not in seen :
2026-04-06 22:51:04 +08:00
seen . add ( ln )
results . append ( { ' page ' : ln , ' content ' : node . get ( ' text ' , ' ' ) } )
if node . get ( ' nodes ' ) :
_traverse ( node [ ' nodes ' ] )
_traverse ( structure )
results . sort ( key = lambda x : x [ ' page ' ] )
return results
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
# ─────────────────────────────────────────────────────────────────────
# Legacy 0.2.x / OSS utility API — kept here so this module is the single
# source of truth for the indexing pipeline. Previously duplicated in the
# top-level pageindex/utils.py (now a deprecation shim re-exporting this).
# ─────────────────────────────────────────────────────────────────────
async def call_llm ( prompt , api_key , model = " gpt-4.1 " , temperature = 0 ) :
""" Call an LLM to generate a response to a prompt.
Kept for compatibility with the pageindex 0.2 . x SDK utility API .
"""
import openai
2026-07-07 11:34:13 +08:00
async with openai . AsyncOpenAI ( api_key = api_key ) as client :
response = await client . chat . completions . create (
model = model ,
messages = [ { " role " : " user " , " content " : prompt } ] ,
temperature = temperature ,
)
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
return response . choices [ 0 ] . message . content . strip ( )
def is_leaf_node ( data , node_id ) :
# Helper function to find the node by its node_id
def find_node ( data , node_id ) :
if isinstance ( data , dict ) :
if data . get ( ' node_id ' ) == node_id :
return data
for key in data . keys ( ) :
if ' nodes ' in key :
result = find_node ( data [ key ] , node_id )
if result :
return result
elif isinstance ( data , list ) :
for item in data :
result = find_node ( item , node_id )
if result :
return result
return None
# Find the node with the given node_id
node = find_node ( data , node_id )
# Check if the node is a leaf node
if node and not node . get ( ' nodes ' ) :
return True
return False
def get_last_node ( structure ) :
return structure [ - 1 ]
def extract_text_from_pdf ( pdf_path ) :
pdf_reader = PyPDF2 . PdfReader ( pdf_path )
###return text not list
text = " "
for page_num in range ( len ( pdf_reader . pages ) ) :
page = pdf_reader . pages [ page_num ]
text + = page . extract_text ( )
return text
def get_pdf_title ( pdf_path ) :
pdf_reader = PyPDF2 . PdfReader ( pdf_path )
meta = pdf_reader . metadata
title = meta . title if meta and meta . title else ' Untitled '
return title
def get_text_of_pages ( pdf_path , start_page , end_page , tag = True ) :
pdf_reader = PyPDF2 . PdfReader ( pdf_path )
text = " "
for page_num in range ( start_page - 1 , end_page ) :
page = pdf_reader . pages [ page_num ]
page_text = page . extract_text ( )
if tag :
text + = f " <start_index_ { page_num + 1 } > \n { page_text } \n <end_index_ { page_num + 1 } > \n "
else :
text + = page_text
return text
def get_first_start_page_from_text ( text ) :
start_page = - 1
start_page_match = re . search ( r ' <start_index_( \ d+)> ' , text )
if start_page_match :
start_page = int ( start_page_match . group ( 1 ) )
return start_page
def get_last_start_page_from_text ( text ) :
start_page = - 1
# Find all matches of start_index tags
start_page_matches = re . finditer ( r ' <start_index_( \ d+)> ' , text )
# Convert iterator to list and get the last match if any exist
matches_list = list ( start_page_matches )
if matches_list :
start_page = int ( matches_list [ - 1 ] . group ( 1 ) )
return start_page
def sanitize_filename ( filename , replacement = ' - ' ) :
# In Linux, only '/' and '\0' (null) are invalid in filenames.
# Null can't be represented in strings, so we only handle '/'.
return filename . replace ( ' / ' , replacement )
def get_pdf_name ( pdf_path ) :
# Extract PDF name
if isinstance ( pdf_path , str ) :
pdf_name = os . path . basename ( pdf_path )
elif isinstance ( pdf_path , BytesIO ) :
pdf_reader = PyPDF2 . PdfReader ( pdf_path )
meta = pdf_reader . metadata
pdf_name = meta . title if meta and meta . title else ' Untitled '
pdf_name = sanitize_filename ( pdf_name )
return pdf_name
class JsonLogger :
def __init__ ( self , file_path ) :
# Extract PDF name for logger name
pdf_name = get_pdf_name ( file_path )
current_time = datetime . now ( ) . strftime ( " % Y % m %d _ % H % M % S " )
self . filename = f " { pdf_name } _ { current_time } .json "
os . makedirs ( " ./logs " , exist_ok = True )
# Initialize empty list to store all messages
self . log_data = [ ]
def log ( self , level , message , * * kwargs ) :
if isinstance ( message , dict ) :
self . log_data . append ( message )
else :
self . log_data . append ( { ' message ' : message } )
# Add new message to the log data
# Write entire log data to file
with open ( self . _filepath ( ) , " w " ) as f :
json . dump ( self . log_data , f , indent = 2 )
def info ( self , message , * * kwargs ) :
self . log ( " INFO " , message , * * kwargs )
def error ( self , message , * * kwargs ) :
self . log ( " ERROR " , message , * * kwargs )
def debug ( self , message , * * kwargs ) :
self . log ( " DEBUG " , message , * * kwargs )
def exception ( self , message , * * kwargs ) :
kwargs [ " exception " ] = True
self . log ( " ERROR " , message , * * kwargs )
def _filepath ( self ) :
return os . path . join ( " logs " , self . filename )
def add_preface_if_needed ( data ) :
if not isinstance ( data , list ) or not data :
return data
if data [ 0 ] [ ' physical_index ' ] is not None and data [ 0 ] [ ' physical_index ' ] > 1 :
preface_node = {
" structure " : " 0 " ,
" title " : " Preface " ,
" physical_index " : 1 ,
}
data . insert ( 0 , preface_node )
return data
def get_page_tokens ( pdf_path , model = None , pdf_parser = " PyPDF2 " ) :
if pdf_parser == " PyPDF2 " :
pdf_reader = PyPDF2 . PdfReader ( pdf_path )
page_list = [ ]
for page_num in range ( len ( pdf_reader . pages ) ) :
page = pdf_reader . pages [ page_num ]
page_text = page . extract_text ( )
token_length = litellm . token_counter ( model = model , text = page_text )
page_list . append ( ( page_text , token_length ) )
return page_list
elif pdf_parser == " PyMuPDF " :
if isinstance ( pdf_path , BytesIO ) :
pdf_stream = pdf_path
doc = pymupdf . open ( stream = pdf_stream , filetype = " pdf " )
elif isinstance ( pdf_path , str ) and os . path . isfile ( pdf_path ) and pdf_path . lower ( ) . endswith ( " .pdf " ) :
doc = pymupdf . open ( pdf_path )
page_list = [ ]
for page in doc :
page_text = page . get_text ( )
token_length = litellm . token_counter ( model = model , text = page_text )
page_list . append ( ( page_text , token_length ) )
return page_list
else :
raise ValueError ( f " Unsupported PDF parser: { pdf_parser } " )
def get_text_of_pdf_pages ( pdf_pages , start_page , end_page ) :
text = " "
for page_num in range ( start_page - 1 , end_page ) :
text + = pdf_pages [ page_num ] [ 0 ]
return text
def get_text_of_pdf_pages_with_labels ( pdf_pages , start_page , end_page ) :
text = " "
for page_num in range ( start_page - 1 , end_page ) :
text + = f " <physical_index_ { page_num + 1 } > \n { pdf_pages [ page_num ] [ 0 ] } \n <physical_index_ { page_num + 1 } > \n "
return text
def get_number_of_pages ( pdf_path ) :
pdf_reader = PyPDF2 . PdfReader ( pdf_path )
num = len ( pdf_reader . pages )
return num
def clean_structure_post ( data ) :
if isinstance ( data , dict ) :
data . pop ( ' page_number ' , None )
data . pop ( ' start_index ' , None )
data . pop ( ' end_index ' , None )
if ' nodes ' in data :
clean_structure_post ( data [ ' nodes ' ] )
elif isinstance ( data , list ) :
for section in data :
clean_structure_post ( section )
return data
def print_toc ( tree , indent = 0 ) :
for node in tree :
print ( ' ' * indent + node [ ' title ' ] )
if node . get ( ' nodes ' ) :
print_toc ( node [ ' nodes ' ] , indent + 1 )
def print_json ( data , max_len = 40 , indent = 2 ) :
def simplify_data ( obj ) :
if isinstance ( obj , dict ) :
return { k : simplify_data ( v ) for k , v in obj . items ( ) }
elif isinstance ( obj , list ) :
return [ simplify_data ( item ) for item in obj ]
elif isinstance ( obj , str ) and len ( obj ) > max_len :
return obj [ : max_len ] + ' ... '
else :
return obj
simplified = simplify_data ( data )
print ( json . dumps ( simplified , indent = indent , ensure_ascii = False ) )
def check_token_limit ( structure , limit = 110000 ) :
list = structure_to_list ( structure )
for node in list :
num_tokens = count_tokens ( node [ ' text ' ] , model = None )
if num_tokens > limit :
print ( f " Node ID: { node [ ' node_id ' ] } has { num_tokens } tokens " )
print ( " Start Index: " , node [ ' start_index ' ] )
print ( " End Index: " , node [ ' end_index ' ] )
print ( " Title: " , node [ ' title ' ] )
print ( " \n " )
def convert_physical_index_to_int ( data ) :
if isinstance ( data , list ) :
for i in range ( len ( data ) ) :
# Check if item is a dictionary and has 'physical_index' key
if isinstance ( data [ i ] , dict ) and ' physical_index ' in data [ i ] :
if isinstance ( data [ i ] [ ' physical_index ' ] , str ) :
if data [ i ] [ ' physical_index ' ] . startswith ( ' <physical_index_ ' ) :
data [ i ] [ ' physical_index ' ] = int ( data [ i ] [ ' physical_index ' ] . split ( ' _ ' ) [ - 1 ] . rstrip ( ' > ' ) . strip ( ) )
elif data [ i ] [ ' physical_index ' ] . startswith ( ' physical_index_ ' ) :
data [ i ] [ ' physical_index ' ] = int ( data [ i ] [ ' physical_index ' ] . split ( ' _ ' ) [ - 1 ] . strip ( ) )
elif isinstance ( data , str ) :
if data . startswith ( ' <physical_index_ ' ) :
data = int ( data . split ( ' _ ' ) [ - 1 ] . rstrip ( ' > ' ) . strip ( ) )
elif data . startswith ( ' physical_index_ ' ) :
data = int ( data . split ( ' _ ' ) [ - 1 ] . strip ( ) )
# Check data is int
if isinstance ( data , int ) :
return data
else :
return None
return data
def convert_page_to_int ( data ) :
for item in data :
if ' page ' in item and isinstance ( item [ ' page ' ] , str ) :
try :
item [ ' page ' ] = int ( item [ ' page ' ] )
except ValueError :
# Keep original value if conversion fails
pass
return data
def add_node_text_with_labels ( node , pdf_pages ) :
if isinstance ( node , dict ) :
start_page = node . get ( ' start_index ' )
end_page = node . get ( ' end_index ' )
node [ ' text ' ] = get_text_of_pdf_pages_with_labels ( pdf_pages , start_page , end_page )
if ' nodes ' in node :
add_node_text_with_labels ( node [ ' nodes ' ] , pdf_pages )
elif isinstance ( node , list ) :
for index in range ( len ( node ) ) :
add_node_text_with_labels ( node [ index ] , pdf_pages )
return
class ConfigLoader :
""" Legacy 0.2.x config helper. Defaults now come from IndexConfig — the
old ` ` config . yaml ` ` no longer ships . Prefer ` ` pageindex . IndexConfig ` ` .
"""
def __init__ ( self , default_path = None ) :
from . . config import IndexConfig
self . _default_dict = IndexConfig ( ) . model_dump ( )
def _validate_keys ( self , user_dict ) :
unknown_keys = set ( user_dict ) - set ( self . _default_dict )
if unknown_keys :
raise ValueError ( f " Unknown config keys: { unknown_keys } " )
2026-07-08 10:31:37 +08:00
def load ( self , user_opt = None ) - > _config :
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
""" Merge user options over IndexConfig defaults, returning a namespace. """
if user_opt is None :
user_dict = { }
2026-07-08 10:31:37 +08:00
elif isinstance ( user_opt , _config ) :
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
user_dict = vars ( user_opt )
elif isinstance ( user_opt , dict ) :
user_dict = user_opt
else :
raise TypeError ( " user_opt must be dict, config(SimpleNamespace) or None " )
self . _validate_keys ( user_dict )
merged = { * * self . _default_dict , * * user_dict }
fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).
- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
(untrusted: unsanitized filename / LLM-generated from document content) before
inserting them into the <docs>...</docs> block, so embedded content can never
form a literal </docs> that closes the delimiter early and escapes the
untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
per-field transform, doesn't touch the (cacheable) static system prompt.
- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
coercion instead of surviving as a truthy non-empty string — page_index_main's
bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
in this PR) were silently inverting caller intent and firing unwanted billed
LLM calls.
- verify_toc, process_large_node_recursively, tree_parser,
generate_summaries_for_structure, generate_summaries_for_structure_md: added
return_exceptions=True to their asyncio.gather calls (llm_completion/
llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
PR), each with a degrade path matching the pattern already used by sibling
hardened gathers in the same files. One transient LLM failure no longer
aborts the whole document's indexing.
- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
shared across every thread/event loop) instead of one asyncio.Semaphore per
event loop -- concurrently indexing N documents on N threads no longer
multiplies the effective cap by N. A max_concurrency_scope() override is
layered as a second, nested, per-loop restriction that can only tighten the
effective cap within the ceiling, never widen past it.
- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
unlike max_concurrency which already had ContextVar scoping. Added
llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
wired into build_index() the same way max_concurrency already was, so
concurrent indexing jobs with different llm kwargs don't leak into each
other.
Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:12 +08:00
# Route through IndexConfig so legacy 'yes'/'no' string overrides get
# pydantic's bool coercion (a bare 'no' is otherwise a truthy string —
# page_index_main's `if opt.if_add_node_summary:` checks would silently
# invert the caller's intent).
from . . config import IndexConfig
validated = IndexConfig ( * * merged )
return _config ( * * validated . model_dump ( ) )
refactor(index): dedupe the copied indexing pipeline behind deprecation shims
The new SDK copied the legacy indexing pipeline into pageindex/index/
instead of moving it, leaving two divergent copies of page_index.py /
page_index_md.py / utils.py. They had already drifted (the legacy copy
still compared IndexConfig booleans against 'yes' — a separate fix),
and every pipeline change had to be applied twice.
Make pageindex/index/ the single source of truth (same pattern as the
LegacyCloudAPI shim for the 0.2.x cloud SDK):
- pageindex/index/utils.py absorbs the 27 legacy-only helpers/classes
(get_page_tokens, convert_page_to_int, ConfigLoader, PDF text helpers,
...) so it's the sole utils module. Reconciled the diverged funcs:
kept the modern versions, backported the #331 get_leaf_nodes .get()
fix, and restored remove_fields' max_len parameter (superset).
- index/page_index*.py now import `from .utils import *`;
index/legacy_utils.py (a re-export of the old top-level utils) deleted.
- Top-level page_index.py / page_index_md.py / utils.py become thin
re-export shims that emit PendingDeprecationWarning. The md_to_tree
shim coerces legacy 'yes'/'no' string flags to bool (the canonical
version is boolean-typed).
- ConfigLoader no longer reads the deleted config.yaml; it builds
defaults from IndexConfig (was an unconditional FileNotFoundError).
- __init__.py and retrieve.py import from pageindex.index.* directly so
`import pageindex` does not trip the shims.
Adds tests/test_legacy_shims.py pinning the contract: clean top-level
import doesn't warn, legacy submodule imports warn, symbols still
resolve, shim and canonical share one implementation, the #331 fix and
ConfigLoader-without-yaml both hold, and the md_to_tree coercion works.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:41:29 +08:00
def create_node_mapping ( tree , include_page_ranges = False , max_page = None ) :
""" Create a mapping of node_id to node for quick lookup.
The optional page - range arguments are kept for compatibility with the
pageindex 0.2 . x SDK utility API .
"""
def get_all_nodes ( nodes ) :
if isinstance ( nodes , dict ) :
return [ nodes ] + [
child_node
for child in nodes . get ( ' nodes ' , [ ] )
for child_node in get_all_nodes ( child )
]
elif isinstance ( nodes , list ) :
return [
child_node
for item in nodes
for child_node in get_all_nodes ( item )
]
return [ ]
all_nodes = get_all_nodes ( tree )
if not include_page_ranges :
return { node [ " node_id " ] : node for node in all_nodes if node . get ( " node_id " ) }
mapping = { }
for i , node in enumerate ( all_nodes ) :
if not node . get ( " node_id " ) :
continue
start_page = node . get ( " page_index " , node . get ( " start_index " ) )
if node . get ( " end_index " ) is not None :
end_page = node . get ( " end_index " )
elif i + 1 < len ( all_nodes ) :
next_node = all_nodes [ i + 1 ]
end_page = next_node . get ( " page_index " , next_node . get ( " start_index " ) )
else :
end_page = max_page
mapping [ node [ " node_id " ] ] = {
" node " : node ,
" start_index " : start_page ,
" end_index " : end_page ,
}
return mapping
def print_tree ( tree , exclude_fields = None , indent = None ) :
if exclude_fields is None :
exclude_fields = [ ' text ' , ' page_index ' ]
if isinstance ( exclude_fields , int ) :
indent = exclude_fields
exclude_fields = None
if indent is None and exclude_fields is not None :
cleaned_tree = remove_fields ( copy . deepcopy ( tree ) , exclude_fields , max_len = 40 )
pprint ( cleaned_tree , sort_dicts = False , width = 100 )
return
indent = indent or 0
for node in tree :
summary = node . get ( ' summary ' ) or node . get ( ' prefix_summary ' , ' ' )
summary_str = f " — { summary [ : 60 ] } ... " if summary else " "
print ( ' ' * indent + f " [ { node . get ( ' node_id ' , ' ? ' ) } ] { node . get ( ' title ' , ' ' ) } { summary_str } " )
if node . get ( ' nodes ' ) :
print_tree ( node [ ' nodes ' ] , exclude_fields = exclude_fields , indent = indent + 1 )
def print_wrapped ( text , width = 100 ) :
for line in text . splitlines ( ) :
print ( textwrap . fill ( line , width = width ) )