Skip to content

feat: introduce PageIndex SDK with Collection-based local/cloud API#272

Open
KylinMountain wants to merge 71 commits into
mainfrom
dev
Open

feat: introduce PageIndex SDK with Collection-based local/cloud API#272
KylinMountain wants to merge 71 commits into
mainfrom
dev

Conversation

@KylinMountain

@KylinMountain KylinMountain commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Turn PageIndex into a proper Python SDK. The repo currently exposes a tree-generation CLI (run_pageindex.py) and a legacy cloud HTTP wrapper (pageindex_sdk 0.2.x). This PR introduces a unified PageIndexClient with a Collection-based API that works in both local self-hosted mode (your LLM key) and cloud-managed mode (PageIndex API key), while keeping the legacy SDK callable on the same client for backward compatibility.

What's in the SDK

Public surface (from pageindex import PageIndexClient):

  • PageIndexClient(api_key=...) — auto-detects cloud vs local
  • client.collection(name)Collection
  • Collection.add / list_documents / get_document / get_document_structure / get_page_content / delete_document / query
  • col.query(question, doc_ids=..., stream=...)doc_ids accepts str | list[str] | None
  • Streaming queries via async iterator over QueryEvent

Two execution paths behind the same API:

  • Local: parser → index pipeline → SQLite storage → agent QA loop (OpenAI Agents SDK)
  • Cloud: thin client over /chat/completions/, /doc/... endpoints

Legacy 0.2.x compatibility on the same PageIndexClient: all 12 methods (submit_document, get_ocr, get_tree, chat_completions, document & folder management) preserved as @deprecated wrappers, same signatures and return shapes.

Highlights

  • feat: add PageIndex SDK with local/cloud dual-mode support #207 — Collection-based dual-mode SDK (local + cloud), pluggable parsers, agent QA, streaming
  • feat:compatible with Pageindex SDK #238 — legacy pageindex_sdk 0.2.x methods on PageIndexClient (submit_document, chat_completions, etc.), with stream-close fixes and @deprecated migration markers
  • fix: poll status=="completed" in cloud add_document #226 — cloud add_document polls status == "completed" instead of the unreliable retrieval_ready flag
  • Collection.query polish (latest):
    • doc_ids accepts str | list[str] | None; empty list rejected at both Collection and Backend layers
    • Scoped mode (when doc_ids is provided): drops list_documents from the agent's tool set, hard-enforces whitelist on doc_id args, and injects doc summaries into the user message inside <docs> with system-prompt guidance treating it as data, not instructions (prompt-injection mitigation)
    • Collection.list_documents() now exposes doc_description so the agent can route to the right doc
    • doc_ids=None over a multi-doc collection emits a UserWarning (cross-doc retrieval is experimental; PAGEINDEX_EXPERIMENTAL_MULTIDOC=1 to silence). Single-doc skipped, empty collection raises ValueError
    • Legacy PageIndexClient.list_documents docstring spells out the return-shape difference vs Collection.list_documents(); clearer error when legacy methods are called after api_key=""
    • Agents SDK tracing upload disabled by default (PAGEINDEX_AGENTS_TRACING=1 re-enables)
  • Examples: examples/local_demo.py, examples/cloud_demo.py, examples/demo_query_modes.py (5 query-mode cases)
  • README: new "SDK Usage" section covering install, local/cloud quick start, streaming, multi-document collections
  • Packaging: switch to Poetry, bump to 0.3.0.dev1, add dist/ to gitignore

Test plan

  • pytest tests/ — 101 passed, 2 skipped
  • examples/demo_legacy_sdk.py — all 7 legacy methods green against api.pageindex.ai
  • examples/demo_query_modes.py — all 5 Collection.query modes (single/multi/scoped × 2, env-var silencing) green
  • Manual verify install from a built wheel
  • Verify CHANGELOG / docs reflect 0.3.0 surface before tagging

0.2.x behavior notes

Compatibility regressions found in review are fixed (dea211b, 0f593c6): page_index() / ConfigLoader read config.yaml again (explicit args > YAML > IndexConfig field defaults, so if_add_doc_description stays off by default on the legacy path), ConfigLoader(default_path=...) is honored (missing file raises FileNotFoundError), pageindex.utils.* / pageindex.page_index_md.* attribute access works again (lazily bound — plain import pageindex stays warning-free), and client.BASE_URL reassignment after construction routes requests again.

Three 0.2.x behaviors are intentionally not preserved:

  • Markdown page specs are exact-match. get_page_content(..., "3,8") on a Markdown doc returns the nodes at lines 3 and 8, mirroring the PDF path — 0.2.x returned everything in the [3, 8] envelope. Use "3-8" to request a range.
  • Page specs are capped at 1000 pages/lines per call. Larger spans (e.g. "1-1500") return an actionable error instead of expanding — split into chunks ("1-1000", "1001-1500"). The spec is LLM/caller-reachable, so unbounded expansion was a DoS vector (a "1-2000000000" spec previously allocated billions of ints).
  • from pageindex import * is narrowed to __all__. The old star surface was an accident of chained star-imports (it even leaked json/os/yaml). Explicit forms are unaffected: from pageindex import extract_json, pageindex.extract_json, and from pageindex.utils import ... all keep working.

https://claude.ai/code/session_014B4HZkjdSiZXDmJtH5Jexn

KylinMountain and others added 6 commits April 8, 2026 20:21
The cloud backend previously polled tree_resp["retrieval_ready"]
as the ready signal. Empirically this flag is not a reliable
indicator — docs can reach status=="completed" without
retrieval_ready flipping, causing col.add() to wait until the 10
min timeout before giving up on otherwise-successful uploads.

The cloud API's canonical ready signal is status=="completed";
switch the poll to check that instead.
* feat:compatible with Pageindex SDK

* corner cases fixed

* fix: mock behavior of old SDK

* fix: close streaming response and warn on empty api_key

- LegacyCloudAPI: close response in `finally` for both _stream_chat_response
  variants so abandoned iterators no longer leak the TCP connection.
- PageIndexClient: emit a warning instead of silently falling back to local
  when api_key is the empty string, surfacing typical env-var-unset misconfig.
- FakeResponse: add close()/closed to match the real requests.Response API.
- Add unit coverage for stream close (both paths) and the empty-api_key warning.
- Add scripts/e2e_legacy_sdk.py to smoke-test the legacy SDK contract end-to-end
  against api.pageindex.ai.

* chore: mark legacy SDK methods with @deprecated and docstring pointers

- Decorate the 12 PageIndexClient cloud-SDK compat methods with
  @typing_extensions.deprecated(..., category=PendingDeprecationWarning):
  - IDE/type-checkers render them with a strikethrough hint
  - runtime warnings stay silent by default (no spam for existing callers),
    surfaceable via `python -W default::PendingDeprecationWarning`
- Add a one-line docstring on each pointing to the Collection-based equivalent.
- Promote typing-extensions to a direct dependency (was transitive via litellm).

---------

Co-authored-by: XinyanZhou <xinyanzhou@XinyanZhoudeMacBook-Pro.local>
Co-authored-by: saccharin98 <xinyanzhou938@gmail.com>
Co-authored-by: mountain <kose2livs@gmail.com>
Comment thread pageindex/index/page_index.py Fixed
Comment thread pageindex/client.py Fixed
Comment thread pageindex/client.py Fixed
Comment thread pageindex/parser/pdf.py Fixed
Comment thread pageindex/parser/protocol.py Fixed
Comment thread pageindex/parser/protocol.py Fixed
Comment thread pageindex/storage/protocol.py Fixed
Comment thread pageindex/storage/protocol.py Fixed
Comment thread pageindex/storage/protocol.py Fixed
@KylinMountain

Copy link
Copy Markdown
Collaborator Author

Code review

Found 1 issue:

  1. pyproject.toml declares no direct pydantic dependency, but pageindex/config.py imports from pydantic import BaseModel in production code. Pydantic is currently pulled in transitively via litellm, but a future litellm release (or a constrained resolver) could break installs with ModuleNotFoundError: pydantic. Add pydantic to [tool.poetry.dependencies].

# pageindex/config.py
from __future__ import annotations
from pydantic import BaseModel
class IndexConfig(BaseModel):
"""Configuration for the PageIndex indexing pipeline.

PageIndex/pyproject.toml

Lines 22 to 33 in 595895c

[tool.poetry.dependencies]
python = ">=3.10"
litellm = ">=1.83.0"
pymupdf = ">=1.26.0"
PyPDF2 = ">=3.0.0"
python-dotenv = ">=1.0.0"
pyyaml = ">=6.0"
openai = ">=1.70.0"
openai-agents = ">=0.1.0"
requests = ">=2.28.0"
httpx = {extras = ["socks"], version = ">=0.28.1"}
typing-extensions = ">=4.9.0"

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

pageindex/config.py imports `from pydantic import BaseModel` in production
code, but pyproject.toml only pulled pydantic in transitively via litellm.
A future litellm release could drop or re-pin pydantic and break installs.

Pin to `>=2.5.0,<3.0.0` to match the v2-style BaseModel usage already in the
codebase, and to stay compatible with litellm's own pydantic constraint.
Filename is always built as `.png` regardless of the source ext, so the
variable was dead. Flagged by github-code-quality.
Comment thread pageindex/storage/protocol.py Fixed
- get_agent_tools branches on doc_ids:
  - scoped (doc_ids=[...]): drops list_documents and hard-enforces a
    whitelist on the remaining tools; system prompt switches to
    SCOPED_SYSTEM_PROMPT (no list_documents instruction); doc list +
    summaries are prepended to the user message via wrap_with_doc_context.
  - open (doc_ids=None): unchanged 4-tool agent loop.
- list_documents now exposes doc_description (sqlite + cloud).
- Collection.query emits UserWarning when doc_ids is None and the
  collection holds >1 documents; PAGEINDEX_EXPERIMENTAL_MULTIDOC=1
  silences it. Single-doc collections skip the warning; empty
  collections raise ValueError.
- Agents SDK tracing upload disabled by default (avoids SSL timeouts);
  PAGEINDEX_AGENTS_TRACING=1 re-enables it.
- README: new SDK Usage section covering local/cloud quick start,
  streaming, multi-doc as experimental, and runnable examples.
- Collection.query and Backend.query/query_stream accept doc_ids as
  str, list[str] or None. Single str is normalized to [str] inside each
  backend; bare [] is rejected with ValueError at both layers.
- wrap_with_doc_context wraps the scoped doc list in <docs>...</docs>
  and SCOPED_SYSTEM_PROMPT instructs the agent to treat that block as
  data, not instructions (defense against prompt injection via
  auto-generated doc_description).
- _require_cloud_api now distinguishes api_key="" from api_key=None;
  the former gives a targeted error pointing at the empty-string vs
  fall-back-to-local situation when legacy SDK methods are called.
- Legacy PageIndexClient.list_documents docstring spells out the
  return-shape difference vs collection.list_documents() to flag a
  silent migration footgun (paginated dict with id/name keys vs plain
  list[dict] with doc_id/doc_name keys).
- Remove dead CloudBackend.get_agent_tools stub (not on the Backend
  protocol; only ever returned an empty AgentTools()) and the
  SYSTEM_PROMPT alias (OPEN_/SCOPED_SYSTEM_PROMPT are the explicit
  names now).
- README quick start and streaming example now pass doc_ids; new
  multi-document section shows both str and list forms.
- examples/demo_query_modes.py exercises all five query-mode cases
  (single-doc, multi-doc with/without env var, scoped single, scoped
  multi) for manual verification.
Comment thread pageindex/client.py Fixed
Comment thread pageindex/client.py Fixed
@KylinMountain KylinMountain changed the title release: 0.3.0.dev — dual-mode SDK + legacy compatibility layer feat: introduce PageIndex SDK with Collection-based local/cloud API May 15, 2026
BukeLy
BukeLy previously approved these changes May 15, 2026
scripts/e2e_legacy_sdk.py becomes examples/demo_legacy_sdk.py to sit
alongside the other runnable demos (local/cloud/query-modes), and the
README's Runnable examples list now points at it. Docstring command
updated to the new path; the legacy script docstring also calls out
that it exercises the 0.2.x compatibility methods.

The scripts/ directory had no other entries and is removed.
…e global

llm_completion / llm_acompletion (pageindex/utils.py and
pageindex/index/utils.py) set `litellm.drop_params = True` on the litellm
module. litellm is a process-wide singleton, so this leaked into every other
library sharing it (e.g. a host app like OpenKB that exposes its own litellm
config) and could not be turned off.

Replace the hardcoded `temperature=0` + global `drop_params` with a single
PageIndex-owned per-call kwargs mechanism (config._LLM_PARAMS):

- defaults preserve behavior: {"temperature": 0, "drop_params": True};
- passed per call via **get_llm_params(), never writing litellm's globals, so
  nothing leaks into other litellm users in the same process;
- externally configurable: pageindex.set_llm_params(drop_params=False,
  temperature=1, num_retries=5, ...) or the PAGEINDEX_DROP_PARAMS env shortcut;
- model/messages are reserved (PageIndex supplies them) and rejected.
Comment thread pageindex/index/page_index.py Dismissed
Comment thread pageindex/client.py Dismissed
Comment thread pageindex/client.py
self._init_local(model, retrieve_model, storage_path, storage, index_config)


class CloudClient(PageIndexClient):
Comment thread pageindex/index/page_index_md.py Fixed
Comment thread pageindex/agent.py Dismissed
Comment thread pageindex/backend/cloud.py Fixed
Comment thread pageindex/storage/sqlite.py Fixed
Comment thread pageindex/storage/sqlite.py Fixed
Comment thread pageindex/parser/protocol.py Fixed
ChiragB254 and others added 4 commits July 3, 2026 15:48
list_to_tree() deletes the 'nodes' key from leaf nodes entirely via
clean_node(). Direct access via structure['nodes'] raises KeyError on
these nodes. Using structure.get('nodes') returns None (falsy) safely,
consistent with how 'nodes' is accessed elsewhere in the codebase.

Fixes #330
…ayer

Fixes the cloud/local contract mismatches from the PR #272 review
(verified against the official API docs — the cloud API has no
folder/collection endpoints publicly, GET /docs supports limit<=100
with offset):

- query_stream: emit a terminal answer_done event with the full answer
  (same contract as the local backend); raise CloudAPIError instead of
  disguising HTTP errors as answer events; move the initial connect
  inside try so a connection failure can no longer strand the consumer
  awaiting a sentinel that never arrives
- _request: rewind file objects before retrying so a transient 5xx/429
  during upload no longer re-sends an empty multipart body; carry the
  HTTP status on CloudAPIError (status_code) and keep the last status
  in the max-retries error
- list_documents: paginate with limit/offset instead of a hard-coded
  limit=100, so >100-doc collections are no longer silently truncated
  (whole-collection queries rely on this list)
- folders: treat only 403/404 as "folders unavailable" (warned via
  warnings.warn instead of an invisible logger.warning, matched on
  status_code instead of a "403" substring); transient errors now
  propagate instead of being permanently cached as folder_id=None
- error taxonomy parity: cloud doc endpoints map HTTP 404 to
  DocumentNotFoundError; local get_document raises DocumentNotFoundError
  instead of returning {}; local delete_document raises on missing
  doc_id instead of silently deleting nothing
- cloud get_document warns that include_text is not supported instead
  of silently ignoring it

Adds regression tests for each fix (11 new tests).

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
- local delete_collection: validate the collection name before rmtree.
  An unvalidated name like "../.." escaped files_dir and deleted
  arbitrary directories (path traversal).

- legacy page_index(): restore the node_id/summary/text/description
  enhancements. IndexConfig now carries booleans (pydantic coerces the
  legacy 'yes'/'no' strings at the boundary), but page_index_main still
  compared `opt.if_add_node_id == 'yes'` — always False — so every
  enhancement was silently skipped for legacy-API callers. Conditions
  now branch on the booleans, matching pageindex/index/page_index.py.

- LegacyCloudAPI._request: bound every request with a timeout
  (30s, 120s read timeout for streamed responses) so a dead connection
  can't hang legacy submit/poll/chat callers forever. The legacy
  contract tests pinned the missing timeout; updated to pin its
  presence instead.

Adds regression tests: path-traversal rejection, 'yes'/'no' -> bool
coercion, and timeout assertions.

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
- AgentRunner.run: offload to a worker-thread event loop when called
  from inside a running loop (Jupyter, FastAPI handlers) — mirrors
  pipeline._run_async; Runner.run_sync raised RuntimeError there.

- SQLiteStorage: create connections with check_same_thread=False so
  close() can actually close connections created by worker threads.
  Each thread still gets its own connection via threading.local; with
  the default True those closes raised ProgrammingError (silently
  swallowed) and leaked every worker connection.

- CloudBackend.query: non-streaming chat completions now use a 300s
  timeout and a single attempt. The default 30s ReadTimeout fired
  before generation finished and the retry loop re-billed the full
  server-side retrieval + generation up to three times. _request gains
  retries/timeout overrides; the exhausted-retry path also no longer
  sleeps before raising.

- MarkdownParser: content before the first heading (abstract/preamble)
  becomes a node instead of being silently dropped and unretrievable;
  a file with no headings at all yields a single document node instead
  of zero nodes (which pushed an empty page list into the pipeline).

- LegacyCloudAPI.is_retrieval_ready: API failures (revoked key, network
  down) now propagate as PageIndexAPIError instead of reading as
  "not ready", which turned polling loops into infinite loops.

Adds regression tests for each fix.

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
Comment thread pageindex/parser/protocol.py Fixed
…on 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
- legacy call_llm: open AsyncOpenAI via `async with` so the client (and
  its HTTP connection pool) is closed instead of leaked.
- LocalBackend.add_document: fail fast with CollectionNotFoundError when
  the collection doesn't exist, before the expensive parse + LLM index
  (previously the missing FK only tripped at save time, after paying for
  the LLM work). Also raise builtin FileNotFoundError for a missing path
  instead of FileTypeError (which now means only "unsupported extension").
- Collection.query(doc_ids=None): the empty-collection guard now always
  runs — previously it was skipped once PAGEINDEX_EXPERIMENTAL_MULTIDOC
  was set. A single list_documents call serves both the guard and the
  multi-doc warning (no separate call just to decide whether to warn).
- CloudBackend.query_stream: on early consumer break / GeneratorExit,
  signal the background SSE thread to stop and force-close the response,
  so it no longer drains the whole stream in the background.

Adds regression tests for each (client closed, fail-fast on unknown
collection, FileNotFoundError, empty-check under the multidoc env flag,
single list call, early-break thread stop).

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
Comment thread pageindex/backend/cloud.py Fixed
- SQLite dedup race: add UNIQUE(collection_name, file_hash) and switch
  save_document to a plain INSERT. add_document now catches the
  IntegrityError from a concurrent add of the same content, cleans up its
  managed files, and returns the winning doc_id — instead of two doc_ids
  for one file (each having paid for its own LLM indexing).

- PDF image paths: store the absolute path to each extracted image
  instead of a path relative to the indexing process's cwd. The
  ![image](...) references broke as soon as a query ran from a different
  directory.

- LegacyCloudAPI: URL-encode doc_id / retrieval_id path segments (added
  _enc()), matching CloudBackend. An id containing '/', '?', '#' or a
  space previously hit the wrong endpoint or produced a malformed URL.

Adds regression tests: UNIQUE enforcement, the add-race resolving to the
winner, absolute image paths, and encoded legacy URLs.

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
@rejojer

rejojer commented Jul 13, 2026

Copy link
Copy Markdown
Member

跟进 之前的 max-effort 复审3ff04d5 已推到 dev,修复了其中可直接修的项;两个兼容性问题经过考证后定案为不做兼容(理由见下)。

3ff04d5 修复的项

复审条目 修复
4 _get_text_of_pages 越界 循环范围 clamp 到 [1, len(page_list)];同模式的 get_text_of_pdf_pages_with_labels 一并 clamp
14–15 get_text_of_pdf_pages 重复 改为转调 _get_text_of_pages,越界修复只需一处
5 _normalize_tree(None) 开头 if not nodes: return [];验证时发现同类未防护点又补了 4 处——folders 迭代 ×3、documents 分页 ×1(API 对这些 key 返回显式 null 同样会 TypeError)
8 页码类型不匹配 _as_int() 强转后再比较,转不了则跳过;返回的 page 字段统一为 int(对齐 PageContent.page: int
9 folder 缓存存 None 新增 _create_folder():响应缺 folder.id 时 raise(PageIndexError,不会被 except CloudAPIError 吞掉);_get_folder_id 查不到名字不再缓存,folder 后建后可被发现。403/404 "plan 不支持" 的哨兵缓存保持原样
10 doc_id 未 defang wrap_with_doc_context 中 doc_id 过 _defang_delimiters
6(部分) 401 错误信息统一追加提示:"api_key 是 PageIndex cloud key,本地模式请设 LLM provider 的环境变量"。覆盖三条路径:CloudBackend._requestLegacyCloudAPI._requestquery_stream 的流式 POST。特意只挂 401 不挂 403——本代码库将 403 定义为 plan 限制(_FOLDER_UNAVAILABLE),key 正确的用户收到 key 提示是误导
7(收尾) 删除 examples/workspace/ 里旧 JSON 格式的示例数据(_meta.json + 文档 JSON)。dev 上两个 demo 的 storage_path 都指向该目录,旧文件是 SQLite 存储读不了的死数据,留着会让 demo 用户在空库旁边看到误导性的遗留文件

两个兼容性问题的定案

6. api_key 语义变更 → 不做旧语义兼容。 考证结论:api_key=<OpenAI key> 这个用法在整个仓库历史中(全部分支、全部 commit)从未出现在任何 README、demo、cookbook 或 docstring 中——唯一存在过的地方就是 main 上 client.py 的实现代码本身,仓库内调用者为零(demo 只传 workspace)。cookbook 里所有 api_key= 示例传的都是 PageIndex key(PyPI SDK 语义)。已发布的 0.2.x SDK 契约优先,开源侧未发版的隐藏行为不构成兼容义务。残留措施就是上面的 401 提示。

7. workspace 参数移除 → 不做兼容检测/迁移。 考证结论:JSON workspace 存储从未进过任何 PyPI 发行版,唯一使用者是仓库自己的 demo,而 dev 上的 demo 已改用 storage_path。为一个只有 demo 用过的参数在产品代码里加检测不值得;直接删掉遗留数据即可。建议 0.3.0 release notes 里加一句:"本地存储改为 SQLite,旧 demo 的 JSON workspace 数据不再可读,需重新索引"。

验证

每个修复有针对性单元测试(含 clamp 前后在合法输入上的逐一等价性比对);另跑了一轮独立的对抗式审查(其发现的流式 401 路径遗漏和 403 误导问题已在本 commit 中修正);全测试套件与修复前的 HEAD 对照运行,通过/失败完全一致(12 个失败为既有的 openai-agents/pydantic 环境不兼容,与本改动无关)。

未处理(可选清理项,留待后续)

复审条目 11–13:云端 query 的双重 list_documentsdoc_ids 标准化 ×4、_validate_collection_name ×3。均为重复代码/多余请求,不影响正确性。

另外验证中发现一个与条目 4 同类的既有问题供参考:page_index.pycheck_title_appearance 用 LLM 输出直接索引 page_list,无 clamp(上界越界被 gather(return_exceptions=True) 吞掉,下界负索引会静默读错页)。未在本 commit 处理。

@rejojer

rejojer commented Jul 13, 2026

Copy link
Copy Markdown
Member

补充一个复审时漏判的兼容性问题:config.yaml 的移除应该做兼容,和 api_key / workspace 两个"不做兼容"的定案不是一类。

为什么这个不一样

前两个定案的依据是"未文档化、仓库内零调用、发现它得翻源码"。config.yaml 不满足这个条件:

  • README 主推的用法就是 python3 run_pageindex.py --pdf_path ...——CLI 是这个开源仓库最大用户群的标准工作流,而 config.yaml 就是它的配置入口
  • 文件内容本身就是设计给用户改的——里面留着 # model: "anthropic/claude-sonnet-4-6" 这类注释掉的备选项,等于邀请编辑
  • 当前 dev 上的行为:手改过 config.yaml 的用户升级后,改动静默失效(不报错,按新默认值跑)。比如把 model 换成 claude 的用户会悄悄变回 gpt-4o,跑完看账单才发现

建议方案

  1. 恢复 pageindex/config.yaml 文件(默认值 + 注释,同 main)——老用户 git pull 时本地改动可正常合并,工作流不变
  2. run_pageindex.py 恢复读取:文件存在则作为 IndexConfig 的基础配置,命令行参数优先级更高。"yes"/"no" 字符串转换可复用现有 _coerce_bool,加载器约 20 行
  3. SDK(PageIndexClient)保持不读——库代码依赖 CWD 下的环境文件是坑(pip 用户没有这个文件,行为会随运行目录变化),显式传参的新设计是对的。如需文件配置可提供显式的 IndexConfig.from_yaml(path)

即 CLI 侧完全向后兼容,SDK 侧保持新架构不妥协。

顺带两个相关的小观察(无需行动,release notes 提一句即可):

  • if_add_doc_description 默认值从 CLI 的 "no" 变为 True(旧 client 路径本来就总传 'yes',SDK 用户无感知;CLI 用户每文档多一次 LLM 调用生成描述)
  • 若采纳上面的方案,此项也自动回到旧 CLI 行为,无需单独处理

run_pageindex.py users configure indexing by editing pageindex/config.yaml;
dev silently ignored those edits. Restore the file and load it as the
IndexConfig base when present, CLI args winning — same merge semantics and
package-relative path as the pre-0.3 ConfigLoader. The SDK stays
explicit-args-only.
@rejojer

rejojer commented Jul 13, 2026

Copy link
Copy Markdown
Member

Code review

Re-reviewed at max effort with Opus agents. Found 3 issues (all verified real, but edge-case / hardening severity rather than blocking):

  1. Non-idempotent upload is retried, risking a duplicate document and double billing. _request defaults to retries=3 and rewinds the file body (seek(0)) so the multipart re-sends, but add_document's POST /doc/ is non-idempotent — if the server commits the upload and the client then sees a timeout/5xx/429, the retry creates a second document. _request's own docstring says to pass retries=1 for non-idempotent, expensive calls, which query() does but this upload doesn't. Consider retries=1 (or an idempotency key / server-side content-hash dedup like the local backend).

with open(file_path, "rb") as f:
resp = self._request("POST", "/doc/", files={"file": f}, data=data)

(convention it diverges from:

def _request(self, method: str, path: str, retries: int = 3, **kwargs) -> dict:
"""HTTP helper. ``retries`` caps total attempts — pass 1 for
non-idempotent, expensive calls (e.g. chat completions) where a
retry would redo the full server-side work."""
)

  1. list_collections() doesn't degrade on folder-unavailable plans. It calls GET /folders/ with no _FOLDER_UNAVAILABLE (403/404) handling, so on a plan without folder support it raises CloudAPIError, whereas every sibling (create_collection, get_or_create_collection, _get_folder_id, delete_collection) catches (403, 404) and degrades gracefully. Returning [] would match the PR's own degradation model.

def list_collections(self) -> list[str]:
data = self._request("GET", "/folders/")
return [f["name"] for f in data.get("folders", []) or []]

  1. publish.yml interpolates a tag-derived $VERSION into shell/Python (command injection). VERSION="${GITHUB_REF_NAME#v}" is spliced into python -c "... Version('$VERSION')" and sed -i "...$VERSION..."; the PEP 440 Version(...) check is itself the injection point, so it provides no protection. Exploitation requires repo-write (tag push), but the job holds id-token: write + contents: write, so RCE here is high-blast-radius (PyPI publish / OIDC token). Pass the value via env: and reference it as os.environ / a quoted "$VERSION" argv instead of string-interpolating.

python -m pip install --upgrade build packaging
VERSION="${GITHUB_REF_NAME#v}"
echo "Publishing version: $VERSION"
# Fail early on a malformed tag instead of publishing a junk version.
python -c "from packaging.version import Version; Version('$VERSION')"
# The git tag is the single source of truth; overwrite the static
# placeholder in [tool.poetry] so the built artifacts carry $VERSION.
sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
grep '^version = ' pyproject.toml

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@KylinMountain

KylinMountain commented Jul 14, 2026 via email

Copy link
Copy Markdown
Collaborator Author

rejojer added 3 commits July 14, 2026 17:49
GET /folders/ is gated by the folder plan server-side, so on plans
without folder support list_collections raised CloudAPIError while
every sibling folder method (create_collection, get_or_create_collection,
delete_collection) degrades gracefully. Catch _FOLDER_UNAVAILABLE, emit
the one-time upgrade warning, and return []; transient errors still
propagate.
config.yaml was restored as the CLI config base, but the ConfigLoader
docstring and a test docstring still claimed it no longer ships, and the
--if-add-doc-description help said 'on by default' while the config.yaml
base sets it to 'no'.
- cloud: _get_folder_id raises CollectionNotFoundError when the folder is
  gone instead of silently falling back to the account-wide space; delete
  stays idempotent
- cloud: CloudBackend honors a base_url override (PageIndexClient.BASE_URL
  previously only reached the legacy API)
- cloud: query_stream runs doc-id listing and thread join off the event
  loop (asyncio.to_thread)
- cloud_api: non-streaming chat_completions gets a 300s read timeout
- local: unexplained IntegrityError is wrapped as CollectionNotFoundError/
  IndexingError instead of leaking raw sqlite3 errors
- index: check_title_appearance rejects below-range physical_index instead
  of wrapping to the last page; page_index_main coerces legacy 'yes'/'no'
  string flags
- config: correct max_concurrency doc and warn when a per-index value
  exceeds the process ceiling
- compat: restore pageindex.utils config alias; add legacy names back to
  __all__
Comment thread pageindex/index/utils.py Fixed
Comment thread pageindex/index/utils.py Fixed
Comment thread pageindex/index/utils.py Fixed
Comment thread pageindex/index/utils.py Fixed
Comment thread pageindex/index/utils.py Fixed
rejojer added 3 commits July 16, 2026 02:24
Both indexing paths and the CLI now use it; page_index_md re-exports it
for existing importers.
CloudBackend ignored the collection argument on get_document,
get_document_structure, get_page_content and delete_document, so a
doc_id addressed through the wrong collection was served or deleted
globally — deletion could destroy a document in another collection.

Add _require_document (mirroring LocalBackend): compare the doc's
folderId against the collection's folder and raise
DocumentNotFoundError on mismatch. get_document reuses its existing
metadata call, so no extra round-trip there; plans without folders
skip the check. Legacy client methods keep global-by-id semantics.
Pin the contract in the Backend protocol.
- process_toc_with_page_numbers: an unresolvable page offset (no
  TOC/body title matches) returned None and crashed with TypeError;
  now returns items without physical_index so meta_processor falls
  back to the no-page-number mode
- process_none_page_numbers: tolerate malformed add_page_number_to_toc
  output ({}, non-dict items, non-numeric physical_index tags) instead
  of KeyError/AttributeError/ValueError
- validate_and_truncate_physical_indices: invalidate non-int
  physical_index values instead of raising on str > int
- wrap_with_doc_context: doc_name=None no longer crashes _defang_delimiters
- build_tree_from_levels: level=0 is a valid level; stop coercing it
  to 1 via falsy-or, which flattened one level of tree depth
@rejojer

rejojer commented Jul 15, 2026

Copy link
Copy Markdown
Member

Code review

Re-reviewed at max effort with Opus agents (5 review dimensions; 6 candidate findings independently cross-scored, one cleared the confidence bar). Found 1 issue:

  1. Cloud doc-scoped operations ignore their collection argument and act on the doc_id globally, so a doc_id belonging to another collection is not rejected. delete_document is the data-loss case: col_A.delete_document(id_from_B) deletes collection B's document. LocalBackend guards this via _require_document (raising DocumentNotFoundError), so the two backends diverge on the same SDK call. get_document, get_document_structure, and get_page_content (lines 242, 266, 272) share the gap. This was raised in earlier reviews and deferred pending the finalized cloud workspace API, but delete_document is data-loss-class and can be guarded client-side now, as LocalBackend already does.

def delete_document(self, collection: str, doc_id: str) -> None:
self._doc_request(doc_id, "DELETE", f"/doc/{self._enc(doc_id)}/")

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread pageindex/storage/sqlite.py Fixed
Comment thread pageindex/backend/protocol.py Fixed
Comment thread pageindex/backend/protocol.py Fixed
Comment thread pageindex/backend/protocol.py Fixed
Comment thread pageindex/backend/protocol.py Fixed
@rejojer

rejojer commented Jul 16, 2026

Copy link
Copy Markdown
Member

Code review

Correction (edited): I retracted an earlier finding that questioned the meta.get("folderId") metadata key in the membership guard. I verified it against the cloud metadata endpoint — folderId is the field the server actually returns, so the guard is correct. The remaining findings stand.

Found 4 issues, cross-verified against current HEAD (6d42559) and ranked by impact. The many earlier review fixes on this PR all check out — these are what's left.

1. query/query_stream skip the membership check the sibling ops got. Commit 929b3df added _require_document to the four doc read/delete ops, but query/query_stream still pass caller-supplied doc_ids straight into the /chat/completions/ payload with no membership check. The local backend enforces scope for query too (via _scoped_docs, raising DocumentNotFoundError), so colA.query(q, doc_ids=[id_from_colB]) raises locally but silently answers from collection B's document on cloud — a backend parity gap and an incomplete version of the same fix (query_stream at L417 has the identical gap).

doc_id = doc_ids if doc_ids else self._get_all_doc_ids(collection)
if not doc_id:
raise ValueError("collection has no documents to query")
# A non-streaming completion returns nothing until generation
# finishes, so it needs far more than the default 30s. retries=1:
# retrying this non-idempotent call would redo the full server-side
# retrieval + generation (and bill it) on every attempt.
resp = self._request("POST", "/chat/completions/", retries=1, timeout=300, json={
"messages": [{"role": "user", "content": question}],
"doc_id": doc_id,
"stream": False,
})

2. Cloud add_document retries the non-idempotent upload (default retries=3). _request rewinds the file and re-POSTs /doc/ on 429/5xx/timeout. If the server accepted the upload and began indexing before the client saw a retryable error (e.g. a read timeout on a large PDF past the 30s default), the retry creates a duplicate document + redundant billed indexing — there's no cloud content-hash dedup (unlike local). query() already opts out with retries=1 for exactly this reason (L389); the upload should too. (Relatedly, Collection.add's docstring promises content-hash dedup unconditionally, which doesn't hold on the cloud path.)

with open(file_path, "rb") as f:
resp = self._request("POST", "/doc/", files={"file": f}, data=data)
doc_id = resp["doc_id"]

3. Local streaming emits multiple answer_done events, diverging from cloud's single-terminal contract. Both system prompts instruct the model to emit a reasoning sentence before each tool call, and stream_events maps every message_output_item to answer_done, so a multi-step local query yields several answer_done (one per reasoning step, plus the final answer). Cloud emits exactly one terminal answer_done carrying the full text (cloud.py:517-523). The cloud comment and commit 284ce00 ("align cloud/local backend contracts") assert these match, but they don't — and the shipped demo_query_modes.py::stream_and_collect prints [answer] on each answer_done, so on local it prints intermediate reasoning as separate answers.

elif item.type == "tool_call_output_item":
yield QueryEvent(type="tool_result", data=str(item.output))
elif item.type == "message_output_item":
text = ItemHelpers.text_message_output(item)
if text:
yield QueryEvent(type="answer_done", data=text)

4. if_add_doc_description default is inconsistent across surfaces. README.md:243 says "on by default", but the CLI is actually offrun_pageindex.py reads config.yaml, which sets "no" — and sibling commit fec44e1 in this same PR fixed the CLI --help to "off by default" while leaving the README stale (the other three flags in the table are correct). Separately, the IndexConfig class default is True (config.py:27) — the only flag whose class default diverges from config.yaml — so a direct page_index("x.pdf") now fires an extra billed description call and returns an extra key, unlike the old default-off behavior.

PageIndex/README.md

Lines 242 to 244 in 6d42559

--if-add-node-summary Add node summaries (on by default; disable with: --if-add-node-summary no)
--if-add-doc-description Add a document description (on by default; disable with: --if-add-doc-description no)
--if-add-node-text Add raw text to nodes (off by default; enable with: --if-add-node-text)

if_add_node_id: bool = True
if_add_node_summary: bool = True
if_add_doc_description: bool = True
if_add_node_text: bool = False

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

rejojer added 2 commits July 16, 2026 14:40
Cloud query()/query_stream() now run the same _require_document membership
guard the four doc-scoped ops received in 929b3df, so a doc_id from another
collection raises DocumentNotFoundError (matching the local backend's
_scoped_docs) instead of silently answering from another collection's
document. The guard runs after doc_ids normalization and before the
completion request; query_stream runs it via asyncio.to_thread. Folders
unavailable on the plan -> guard skips, as with the sibling ops.

Also rename the streaming QueryEvent types answer_delta/answer_done ->
text_delta/text_done: text_done fires once per assistant text message, which
is honest for the local agent loop's multi-message stream (the last one
before the stream ends carries the final answer). Corrects the cloud
backend's now-inaccurate "single terminal contract" comment. reasoning
stays reserved for future model-reasoning tokens. QueryEvent is unreleased
API (0.3.0.dev1), so the rename is not a breaking change.
The local SDK path builds IndexConfig directly (not from config.yaml), so
retrieve_model defaulting to None made agentic QA silently follow the cheaper
indexing model (gpt-4o) instead of the stronger gpt-5.4 the packaged config
and CLI use. Set the IndexConfig field default to gpt-5.4; retrieve_model=None
still follows model as an explicit escape hatch. Cloud path and the
index-only CLI are unaffected.
Comment thread pageindex/index/utils.py
ceiling_sem.release()


def llm_completion(model, prompt, chat_history=None, return_finish_reason=False):
Comment thread pageindex/index/utils.py



async def llm_acompletion(model, prompt):
Comment thread pageindex/index/utils.py
return data


def structure_to_list(structure):
Comment thread pageindex/index/utils.py
return nodes


def get_nodes(structure):
Comment thread pageindex/index/utils.py
return nodes


def get_leaf_nodes(structure):
@rejojer

rejojer commented Jul 16, 2026

Copy link
Copy Markdown
Member

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

main's changes landed on the top-level pageindex/page_index.py and
page_index_md.py, which are deprecation shims on dev (the pipeline now
lives in pageindex/index/). Resolving the conflict by keeping the shims
would silently drop main's work, so port it into index/ instead:

- page_index.py: prompt-injection hardening (_SYSTEM_HARDENING,
  _secure_doc_text and the redact/wrap helpers) applied to every prompt
  builder, plus physical_index validation (_validate_physical_indices,
  _validate_chunk_physical_indices) wired into toc_index_extractor,
  process_no_toc and process_toc_no_page_numbers, and TOC-identity
  validation (reject reordered/modified/count-mismatched LLM output).
  Combined with dev's existing robustness edits in the same functions.
- page_index_md.py: recognize whole-line **bold** as a level-1 heading,
  skip empty bold headings, use the stored node level.
- tests: retarget the ported tests at pageindex.index.* instead of the
  shim (underscore helpers aren't re-exported by the shim's `import *`,
  and patching the shim wouldn't intercept the real pipeline calls).

311 passed, 2 skipped.
The prompt-injection hardening ported from main did not fit dev's direction
(graceful degradation + content-faithful, reasoning-based index). Adjust it:

- process_toc_no_page_numbers: a count-mismatched or reordered/renamed LLM
  response no longer raises ValueError (which aborted the entire index at the
  uncaught top-level path and defeated the return_exceptions degradation the
  rest of the pipeline uses). Skip the untrusted chunk and continue; the
  accuracy check falls back to another mode when too little gets filled.
- _secure_doc_text: drop the keyword blocklist that replaced phrases like
  "act as"/"disregard" with [REDACTED]. Those occur in legitimate titles and
  prose, so redaction corrupted document content. Keep the <user_document>
  framing + _SYSTEM_HARDENING system instruction as the injection defense.
- _validate_chunk_physical_indices: tolerate non-list input (extract_json
  returns {} on parse failure and may return a JSON object) instead of
  crashing while iterating a dict.
- Remove _validate_physical_indices / _parse_physical_index: range validation
  is already done by validate_and_truncate_physical_indices in meta_processor
  and marker->int by convert_physical_index_to_int; the helpers duplicated both.

Tests updated to assert the skip-not-raise behavior and lock in no-redaction
and non-list tolerance. 314 passed, 2 skipped.
@rejojer

rejojer commented Jul 18, 2026

Copy link
Copy Markdown
Member

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

rejojer added 3 commits July 18, 2026 23:44
…ties

- cloud.py: replace bare resp['doc_id'] with .get() + CloudAPIError
- local.py: case-insensitive doc_type comparison for .PDF extensions
- utils.py: add else branch to get_pdf_name for non-str/BytesIO inputs
- utils.py: raise ValueError in get_page_tokens when PyMuPDF path invalid
…tics

PageIndexClient snapshotted BASE_URL into CloudBackend and LegacyCloudAPI
at construction, so reassigning client.BASE_URL afterwards was silently
ignored and requests kept going to the default endpoint. Pass a callable
resolved per request instead, so instance- and class-level reassignment
after construction work again alongside the pre-construction override.

Claude-Session: https://claude.ai/code/session_014B4HZkjdSiZXDmJtH5Jexn
…ccess

page_index() and ConfigLoader silently dropped config.yaml after the
refactor: defaults came from IndexConfig's hardcoded fields, flipping
if_add_doc_description on (an extra billed LLM call per document) and
discarding user-edited YAML including a custom default_path. Both now
resolve explicit args > YAML > IndexConfig field defaults via
IndexConfig.from_yaml, matching the CLI. The new SDK keeps its pure-code
config path.

import pageindex also lost the utils / page_index_md submodule
attributes (pageindex.utils.print_tree raised AttributeError). A module
__getattr__ now imports the shims lazily, so plain imports stay free of
deprecation warnings while first use of a legacy attribute binds the
module and warns.

Also trims non-essential comments from the BASE_URL fix.

Claude-Session: https://claude.ai/code/session_014B4HZkjdSiZXDmJtH5Jexn
@rejojer

rejojer commented Jul 18, 2026

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. README.md documents --if-add-doc-description as "on by default", but the CLI actually defaults it off. This PR's own run_pageindex.py --help for the same flag says "off by default", and the shipped pageindex/config.yaml sets if_add_doc_description: "no" — which wins when the flag is omitted, since unspecified flags are dropped from the overrides before IndexConfig.from_yaml(...). So python run_pageindex.py --pdf_path x.pdf does not generate a doc description, contradicting the README. The other three flags in this block match config.yaml; only this line is wrong.

PageIndex/README.md

Lines 242 to 244 in 0f593c6

--if-add-node-summary Add node summaries (on by default; disable with: --if-add-node-summary no)
--if-add-doc-description Add a document description (on by default; disable with: --if-add-doc-description no)
--if-add-node-text Add raw text to nodes (off by default; enable with: --if-add-node-text)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…mary threshold

- demos: use the post-cutoff attention-residuals paper (2603.15031) again so
  answers provably come from retrieval, and run the QA agent on retrieve_model
- client: re-expose model / retrieve_model as public attributes (0.2.x surface)
- pipeline: markdown summaries go back through generate_summaries_for_structure_md
  with the 200-token threshold (small nodes skip the LLM call)
- README: restore (yes/no, default: X) flag docs with correct defaults; note
  that retrieve_model drives agent QA
@rejojer

rejojer commented Jul 18, 2026

Copy link
Copy Markdown
Member

Notes on a0fb863 — restore pre-refactor behaviors

This commit restores a few behaviors from the pre-refactor codebase that changed as side effects of the SDK rewrite, plus two documentation fixes.

1. Demo paper back to 2603.15031 (attention-residuals) in all demos

The demo paper choice is deliberate: it postdates model training cutoffs, so a correct answer demonstrates that the content actually came from document retrieval. With 1706.03762 ("Attention Is All You Need"), models can answer the demo questions from parametric memory alone, so the demo can appear to work even when retrieval is broken. agentic_vectorless_rag_demo.py, local_demo.py, and cloud_demo.py now all use the attention-residuals paper.

2. Demo QA agent runs on retrieve_model again

Before the rewrite, agentic_vectorless_rag_demo.py built its agent with client.retrieve_model (default gpt-5.4). The rewritten demo passed the indexing model (gpt-4o-2024-11-20) to the agent instead. Indexing and retrieval are intentionally separate quality tiers — indexing is bulk structure extraction, agent QA is where answer quality is decided — so the demo reads client.retrieve_model again.

3. client.model / client.retrieve_model re-exposed as public attributes

The resolved values were previously inlined into the LocalBackend constructor call, leaving no public access (only the private client._backend.get_retrieve_model()). Restored the previous public attributes on the client; the backend reuses the same resolved value.

4. Markdown summaries: 200-token threshold restored

build_index() routed markdown (level_based) summaries through the unified generate_summaries_for_structure, which makes one LLM call per node. The legacy markdown summarizer (generate_summaries_for_structure_md) skips nodes under 200 tokens and reuses their text — a meaningful cost difference for markdown documents with many small sections — and produces the legacy summary / prefix_summary output shape. The level_based path now routes through the legacy summarizer again; the PDF path is unchanged (it never had a threshold).

5. README CLI flag docs

Restored the (yes/no, default: X) format with defaults matching config.yaml — notably --if-add-doc-description was documented as "on by default" while the effective CLI default is off (config.yaml: "no" wins when the flag is omitted). Since the flags are named --if-*, documenting them as valued yes/no options also reads more naturally; the bare-flag shorthand remains documented as a note. The quick start now mentions that agent QA uses retrieve_model (default gpt-5.4), which was previously not referenced anywhere in the README.

Tests: 320 passed, 2 skipped.

🤖 Generated with Claude Code

rejojer added 2 commits July 19, 2026 07:04
Full SDK test suite (29 files) backed up locally and preserved in git
history at a0fb863; recover with: git checkout a0fb863 -- tests/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants