feat: introduce PageIndex SDK with Collection-based local/cloud API#272
feat: introduce PageIndex SDK with Collection-based local/cloud API#272KylinMountain wants to merge 55 commits into
Conversation
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>
Code reviewFound 1 issue:
Lines 1 to 7 in 595895c Lines 22 to 33 in 595895c 🤖 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.
- 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.
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.
| self._init_local(model, retrieve_model, storage_path, storage, index_config) | ||
|
|
||
|
|
||
| class CloudClient(PageIndexClient): |
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
…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
- 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  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
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e00d360273
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code review 发现的问题这次 review 主要发现 4 个需要处理的问题:
验证情况:
|
代码审查经 5 个独立审查代理交叉验证,共发现 12 个问题,按置信度排序如下: 问题 1(置信度 90)—
|
rejojer
left a comment
There was a problem hiding this comment.
Code Review Findings
Reviewed 66 changed files at max effort (64 agents, 56 candidates found, 46 survived adversarial verification, 15 reported).
🔴 Critical — Crashes / Data Corruption
1. pageindex/index/utils.py:157 — LLM retry exhaustion now crashes the pipeline
llm_completion / llm_acompletion changed from returning "" on max-retry exhaustion to raising RuntimeError. None of the ~12 callers (toc_transformer, extract_toc_content, toc_detector_single_page, etc.) handle this exception. A transient LLM outage lasting longer than 10 retries now crashes the entire page_index() call instead of continuing with partial/degraded output.
2. pageindex/storage/sqlite.py:214 — Thread-safety bug in close()
close() only deletes the calling thread's self._local.conn attribute. Other threads' threading.local() conn attributes survive — if those threads later call _get_conn(), hasattr(self._local, 'conn') is True and returns the closed connection, causing sqlite3.ProgrammingError: Cannot operate on a closed database.
3. pageindex/backend/local.py:259 — Empty-list doc_ids bypasses security scope
set(doc_ids) if doc_ids else None treats doc_ids=[] as falsy (= None = open mode). Calling get_agent_tools(collection, doc_ids=[]) directly gives the agent list_documents with no scope restriction — access to every document instead of none.
4. pageindex/index/utils.py:132 — Sync LLM calls bypass concurrency semaphore
llm_completion (sync path) never acquires _llm_semaphore — only llm_acompletion (async) is bounded. With if_add_doc_description=True (new default), generate_doc_description uses sync llm_completion. Multiple threads indexing concurrently run uncapped, exceeding max_concurrency and hitting provider rate limits.
5. pageindex/index/utils.py:55 — Semaphore replacement orphans in-flight permits
set_max_concurrency() replaces the Semaphore object. In-flight coroutines still hold permits on the old (discarded) semaphore and will release() on it, not the new one. Effective concurrency briefly exceeds the intended cap.
🟠 Backward-Incompatible Changes (silent breakage for existing users)
6. pageindex/index/utils.py:875 — config.yaml no longer loaded from disk
ConfigLoader no longer reads config.yaml. Users who customized it (e.g. changing model or toc_check_page_num) will have their overrides silently ignored — the system uses IndexConfig's hardcoded defaults instead.
7. pageindex/config.py:29 — if_add_doc_description default flipped from False to True
Legacy callers using page_index() without explicitly passing this flag now get doc descriptions generated (extra LLM calls, increased cost/latency, different output shape with doc_description key).
8. pageindex/retrieve.py:46 — Markdown page-content retrieval semantics changed
Old code: range-based matching (all nodes with line_num in [min, max]). New code: exact-match only. get_page_content(docs, doc_id, '5,20') previously returned all nodes between lines 5–20; now returns only nodes at exactly line 5 and line 20, silently dropping everything in between.
🟡 Cloud Backend Issues
9. pageindex/backend/cloud.py:157 — list_collections missing 403/404 handling
Does not handle _FOLDER_UNAVAILABLE for plans without folder support, unlike create_collection, get_or_create_collection, _get_folder_id, and delete_collection which all gracefully return empty results.
10. pageindex/backend/cloud.py:208 — doc_type hardcoded as 'pdf'
get_document and list_documents always return doc_type='pdf' regardless of actual file type. Downstream code branching on doc_type (page-number vs line-number semantics) misclassifies Markdown docs.
11. pageindex/cloud_api.py:88 — Python bool capitalization in URL query params
get_tree interpolates Python True/False into URL, producing summary=True (capital T) instead of summary=true. If the API is case-sensitive (common), summaries are silently omitted. The newer CloudBackend correctly passes lowercase 'true'.
🟡 Parser Issue
12. pageindex/parser/markdown.py:38 — Code fence type not tracked
A backtick-opened fence can be "closed" by a tilde line (and vice versa), violating CommonMark spec. Lines inside the still-open fence are then incorrectly parsed as headings.
💡 Cleanup / Performance
13. pageindex/backend/cloud.py:187 — add_document blocks thread up to 10 minutes
Uses time.sleep(5) in a 120-iteration polling loop. In web server or async contexts, this starves the event loop / thread pool.
14. pageindex/backend/cloud.py:239 — get_page_content fetches all pages then filters client-side
For a 500-page PDF requesting pages 5–7, the entire OCR result is downloaded and filtered locally, wasting bandwidth and memory.
15. pageindex/client.py:53 — BASE_URL defined three times
PageIndexClient.BASE_URL, LegacyCloudAPI.BASE_URL, and cloud.API_BASE are independent copies of 'https://api.pageindex.ai'. Changing the URL for staging or migration requires updating all three.
Automated review by Claude Code — 64 agents, max effort.
Python's $ anchor matches just before a final newline, so a $-anchored
re.match(r'^[a-zA-Z0-9_-]{1,128}$', name) accepted "papers\n". In local
mode get_or_create_collection() then hit SQLite's CHECK via
INSERT OR IGNORE, silently created no row, and returned a Collection that
failed later on add(). Switch all three duplicated validators (local,
cloud, sqlite backends) to re.fullmatch() so the whole string must match.
The local get_page_content agent tool only converted DocumentNotFoundError
into a JSON error; a malformed page spec ("all", "5-") let parse_pages'
ValueError surface as the agent SDK's generic non-fatal tool-failure text
("An error occurred... invalid literal for int()"), which the model can't
act on. Catch (ValueError, AttributeError) and return the same actionable
"Invalid pages format: ... Use '5-7', '3,8', or '12'" message the legacy
retrieval tool already gives, so the model can retry with a valid range.
Cloud OCR page results carry an `images` list per page, but the page reconstruction only kept `page` and `content`, dropping images for cloud callers of collection.get_page_content(). The local backend preserves them and the PageContent contract / SDK prompts expect them (so the downstream UI can render figures). Pass `images` through, omitting it when empty to mirror the local backend's shape. Verified against the real OCR endpoint: per-page keys are page_index/markdown/images.
chat_completions(stream=True, stream_metadata=True) used stream_metadata only to pick the raw dict-chunk parser locally, never adding it to the request payload. The wire request didn't match the caller's intent and relied on the server sending metadata chunks unconditionally. Forward the flag (mirroring the modern CloudBackend, which always sends it) so the request is correct and robust if the server ever gates metadata behind it. Verified against the real API that the server currently emits block_metadata regardless, so this is a latent-correctness fix, not a behavior change today.
The _no_sleep autouse fixture patches time.sleep on the shared time module object, so the SlowResponse pacing (`import time; time.sleep(0.002)`) was a no-op — the background thread raced to drain all 1000 chunks before the consumer's early break propagated, failing `assert not drained_all.is_set()` intermittently. Capture the real sleep at import (before the fixture patches) and pace with it, restoring the 2s drain vs ms-teardown margin the test needs. Production stop logic was correct; only the test's pacing was broken.
The source-install path documented in the README (pip install -r requirements.txt) was missing runtime deps the new SDK imports on `import pageindex` — pydantic (config), typing-extensions (client), requests (cloud/legacy API) — plus openai and httpx[socks]. A user following that path hit ModuleNotFoundError before they could use even the legacy APIs. Add them (openai-agents stays an optional install, matching the README's agentic-demo section). Verified in a clean venv: `import pageindex` and the legacy/core APIs now work with only requirements.txt installed.
delete_document unconditionally called response.json(), so a successful
DELETE that returns 200 with an empty body (the documented examples don't
consume one, and REST APIs commonly return no content for deletes) would
raise JSONDecodeError even though the document was already deleted. Return
{} when the response has no content, else parse the JSON body as before.
|
The sync-concurrency fix marked the scoped-override semaphore for release before it was actually acquired, so a coroutine cancelled while polling for a scoped permit (or a sync acquire interrupted mid-wait) ran the finally and released a permit it never held — inflating the scoped cap for later calls (the mirror of the ceiling-leak fix). Only bind the release guard after the acquire succeeds, in both the async and sync semaphores. Regression test included: cancelling a waiter leaves the scoped permit count at 1, not 2.
llm_completion/llm_acompletion raised RuntimeError once retries were
exhausted, which propagated through the unprotected sync TOC-detection
chain (find_toc_pages -> toc_transformer -> toc_extractor -> ...) and
aborted the whole document index — a regression vs. the pre-SDK behavior
that returned "" and let callers degrade (extract_json('') -> {} ->
.get(default), falling back to no-TOC indexing).
Restore the empty-result contract ("" / ("", "error") with
return_finish_reason), now logged at WARNING so the failure is visible
rather than silent. The async gather sites keep return_exceptions=True to
absorb any non-LLM error. A single persistently-failing call no longer
blocks indexing the rest of the document.
OPEN_SYSTEM_PROMPT and SCOPED_SYSTEM_PROMPT told the agent to call
get_document(doc_id) "to confirm status and page/line count", but neither
backend returns a page/line count and the local backend has no status field
(get_document returns doc_name/doc_type/doc_description). The agent would
hunt for fields that don't exist, degrading QA. Align both prompts with the
demo's wording ("confirm the document's name and type"). Regression test
asserts the prompts no longer reference the non-existent page/line count.
…e base URL - local get_agent_tools: `set(doc_ids) if doc_ids is not None else None` so doc_ids=[] is a scope of nothing (reject all), not open mode. The public query path already guarded []; this hardens direct callers. - legacy get_tree: send summary=true/false (lowercase) instead of Python's capitalized True/False, matching the modern CloudBackend and the API. - markdown parser: track the opening fence character so a ```-fence isn't closed by a ~~~ line (CommonMark), keeping '#'-lines inside it out of headings. - dedupe the cloud base URL: single API_BASE in cloud_api, referenced by CloudBackend and PageIndexClient (was three independent copies). Regression tests for each.
✅ 已修复
✅ 已修复
✅ 已修复
✅ 已修复
🟢 有意为之,不改:新 SDK 的默认,README 已同步("on by default")。
✅ 已修复
🟢 不修:推 tag 需仓库写权限,且不带来超出"能推 tag 者已有权限"的提权;触发器是
🕒 暂不处理:预留但未实现的枚举值,无功能影响。
🕒 推迟:等云端 workspace API 定稿后在后续 PR 处理。
✅ 已修复
✅ 已修复
⚪ 确认误报,无需处理。 |
|
Thanks for the max-effort pass — went through all 15 against current
✅ Fixed
🕒 Low-impact edge, deferred.
✅ Fixed
✅ Fixed
🟢 Accepted tradeoff — resizing happens only on an explicit
🟢 Intentional — the new SDK deprecates
🟢 Intentional SDK default, documented in the README ("on by default").
🟢 Not a regression — matches
🕒 Deferred — part of the cloud folder/workspace surface that's waiting on the finalized API; will align in the follow-up PR.
🕒 Deferred — same cloud-workspace follow-up; will surface the real type once the API exposes it.
✅ Fixed
✅ Fixed
🕒 By design for the synchronous "index and wait" call; use
🕒 The cloud OCR endpoint returns the whole document (no page-range parameter), so filtering is client-side by necessity. Low priority pending an API-side range filter.
✅ Fixed |
|
To use Codex here, create a Codex account and connect to github. |
- index/utils.py: fix an asyncio deadlock in _sync_llm_semaphore. Sync LLM calls (check_toc / process_no_toc / toc_transformer) run on the event-loop thread nested inside the async meta_processor, and its blocking ceiling acquire could wait forever for a permit held by async _llm_semaphore holders that can only release it once the (now-frozen) loop runs. Take the slot non-blocking when on a running loop; keep the blocking acquire off-loop. - index/utils.py: bound parse_pages ranges before materializing range() into the list, so a huge span like '1-2000000000' is rejected up front instead of exhausting memory before the 1000-page cap is ever checked (DoS). - storage/sqlite.py: bump a generation counter on close() so a thread that cached a connection in thread-local storage reconnects on its next call instead of reusing a closed handle (ProgrammingError). - backend/cloud.py: after connect, bail out of the SSE background thread if the consumer already abandoned the stream, instead of draining it in the background. Adds regression tests for each fix.
Max-effort 复审 — 15 项发现(已逐条对照当前
|
- clamp LLM-derived page indices in _get_text_of_pages and get_text_of_pdf_pages_with_labels; dedupe get_text_of_pdf_pages - guard _normalize_tree and folders/documents iterations against explicit nulls in cloud API responses - coerce cloud OCR page numbers to int before filtering in get_page_content - folder cache: raise on missing folder id instead of caching None; stop caching name-not-found so later lookups can succeed - defang doc_id in agent doc-context prompt - append api-key hint to 401 errors (request, legacy and streaming paths) - remove stale legacy JSON workspace sample data unreadable by the SQLite storage
|
跟进 之前的 max-effort 复审:
|
| 复审条目 | 修复 |
|---|---|
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._request、LegacyCloudAPI._request、query_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_documents、doc_ids 标准化 ×4、_validate_collection_name ×3。均为重复代码/多余请求,不影响正确性。
另外验证中发现一个与条目 4 同类的既有问题供参考:page_index.py 的 check_title_appearance 用 LLM 输出直接索引 page_list,无 clamp(上界越界被 gather(return_exceptions=True) 吞掉,下界负索引会静默读错页)。未在本 commit 处理。
|
补充一个复审时漏判的兼容性问题:config.yaml 的移除应该做兼容,和 api_key / workspace 两个"不做兼容"的定案不是一类。 为什么这个不一样前两个定案的依据是"未文档化、仓库内零调用、发现它得翻源码"。config.yaml 不满足这个条件:
建议方案
即 CLI 侧完全向后兼容,SDK 侧保持新架构不妥协。 顺带两个相关的小观察(无需行动,release notes 提一句即可):
|
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.
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_sdk0.2.x). This PR introduces a unifiedPageIndexClientwith 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 localclient.collection(name)→CollectionCollection.add/list_documents/get_document/get_document_structure/get_page_content/delete_document/querycol.query(question, doc_ids=..., stream=...)—doc_idsacceptsstr | list[str] | NoneQueryEventTwo execution paths behind the same API:
/chat/completions/,/doc/...endpointsLegacy 0.2.x compatibility on the same
PageIndexClient: all 12 methods (submit_document,get_ocr,get_tree,chat_completions, document & folder management) preserved as@deprecatedwrappers, same signatures and return shapes.Highlights
pageindex_sdk0.2.x methods onPageIndexClient(submit_document,chat_completions, etc.), with stream-close fixes and@deprecatedmigration markersadd_documentpollsstatus == "completed"instead of the unreliableretrieval_readyflagdoc_idsacceptsstr | list[str] | None; empty list rejected at both Collection and Backend layersdoc_idsis provided): dropslist_documentsfrom 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 exposesdoc_descriptionso the agent can route to the right docdoc_ids=Noneover a multi-doc collection emits aUserWarning(cross-doc retrieval is experimental;PAGEINDEX_EXPERIMENTAL_MULTIDOC=1to silence). Single-doc skipped, empty collection raisesValueErrorPageIndexClient.list_documentsdocstring spells out the return-shape difference vsCollection.list_documents(); clearer error when legacy methods are called afterapi_key=""PAGEINDEX_AGENTS_TRACING=1re-enables)examples/local_demo.py,examples/cloud_demo.py,examples/demo_query_modes.py(5 query-mode cases)dist/to gitignoreTest plan
pytest tests/— 101 passed, 2 skippedexamples/demo_legacy_sdk.py— all 7 legacy methods green againstapi.pageindex.aiexamples/demo_query_modes.py— all 5 Collection.query modes (single/multi/scoped × 2, env-var silencing) green