fix: address max-effort review findings

- 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
This commit is contained in:
Ray 2026-07-13 18:48:55 +08:00
parent 9ad54122bb
commit 3ff04d501a
7 changed files with 70 additions and 323 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,9 +0,0 @@
{
"12345678-abcd-4321-abcd-123456789abc": {
"type": "pdf",
"doc_name": "attention-residuals.pdf",
"doc_description": "This document introduces \"Attention Residuals\" (AttnRes) and its scalable variant \"Block AttnRes,\" novel mechanisms for replacing fixed residual accumulation in neural networks with learned, input-dependent depth-wise attention, addressing limitations of standard residual connections while optimizing memory, computation, and scalability for large-scale training and inference.",
"page_count": 21,
"path": "../documents/attention-residuals.pdf"
}
}

View file

@ -65,7 +65,7 @@ def wrap_with_doc_context(docs: list[dict], question: str) -> str:
"""
lines = []
for d in docs:
line = f"- {d['doc_id']}: {_defang_delimiters(d.get('doc_name', ''))}"
line = f"- {_defang_delimiters(str(d['doc_id']))}: {_defang_delimiters(d.get('doc_name', ''))}"
desc = d.get("doc_description") or ""
if desc:
line += f"{_defang_delimiters(desc)}"

View file

@ -14,7 +14,7 @@ import requests
from typing import AsyncIterator
from ..cloud_api import API_BASE # single source of truth for the cloud base URL
from ..errors import CloudAPIError, DocumentNotFoundError, PageIndexError
from ..errors import AUTH_HINT, CloudAPIError, DocumentNotFoundError, PageIndexError
from ..events import QueryEvent
logger = logging.getLogger(__name__)
@ -22,6 +22,13 @@ logger = logging.getLogger(__name__)
_INTERNAL_TOOLS = frozenset({"ToolSearch", "Read", "Grep", "Glob", "Bash", "Edit", "Write"})
def _as_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
class CloudBackend:
def __init__(self, api_key: str):
self._api_key = api_key
@ -75,8 +82,10 @@ class CloudBackend:
continue
if resp.status_code != 200:
body = resp.text[:500] if resp.text else ""
raise CloudAPIError(f"Cloud API error {resp.status_code}: {body}",
status_code=resp.status_code)
msg = f"Cloud API error {resp.status_code}: {body}"
if resp.status_code == 401:
msg += f"{AUTH_HINT}"
raise CloudAPIError(msg, status_code=resp.status_code)
return resp.json() if resp.content else {}
except requests.RequestException as e:
if attempt == retries - 1:
@ -102,11 +111,21 @@ class CloudBackend:
# ── Collection management (mapped to folders) ─────────────────────────
def _create_folder(self, name: str) -> str:
"""POST /folder/ and return the new folder id, never a falsy value."""
resp = self._request("POST", "/folder/", json={"name": name})
folder_id = resp.get("folder", {}).get("id")
if not folder_id:
raise PageIndexError(
f"Cloud API returned no folder id when creating {name!r} "
f"(response keys: {list(resp)})"
)
return folder_id
def create_collection(self, name: str) -> None:
self._validate_collection_name(name)
try:
resp = self._request("POST", "/folder/", json={"name": name})
self._folder_id_cache[name] = resp.get("folder", {}).get("id")
self._folder_id_cache[name] = self._create_folder(name)
except CloudAPIError as e:
if e.status_code in self._FOLDER_UNAVAILABLE:
self._warn_folder_upgrade()
@ -118,12 +137,11 @@ class CloudBackend:
self._validate_collection_name(name)
try:
data = self._request("GET", "/folders/")
for folder in data.get("folders", []):
for folder in data.get("folders", []) or []:
if folder.get("name") == name:
self._folder_id_cache[name] = folder["id"]
return
resp = self._request("POST", "/folder/", json={"name": name})
self._folder_id_cache[name] = resp.get("folder", {}).get("id")
self._folder_id_cache[name] = self._create_folder(name)
except CloudAPIError as e:
if e.status_code in self._FOLDER_UNAVAILABLE:
self._warn_folder_upgrade()
@ -148,16 +166,15 @@ class CloudBackend:
self._folder_id_cache[name] = None
return None
raise
for folder in data.get("folders", []):
for folder in data.get("folders", []) or []:
if folder.get("name") == name:
self._folder_id_cache[name] = folder["id"]
return folder["id"]
self._folder_id_cache[name] = None
return None
def list_collections(self) -> list[str]:
data = self._request("GET", "/folders/")
return [f["name"] for f in data.get("folders", [])]
return [f["name"] for f in data.get("folders", []) or []]
def delete_collection(self, name: str) -> None:
folder_id = self._get_folder_id(name)
@ -243,23 +260,29 @@ class CloudBackend:
from ..index.utils import parse_pages
page_nums = set(parse_pages(pages))
all_pages = resp.get("pages", resp.get("ocr", resp.get("result", [])))
if isinstance(all_pages, list):
return [
{"page": p.get("page", p.get("page_index")),
"content": p.get("content", p.get("markdown", "")),
# Cloud OCR pages carry an `images` list (empty on text-only
# pages). Preserve it — omitting when empty, mirroring the local
# backend — so cloud callers get the same PageContent shape and
# the SDK-prompted UI can render figures.
**({"images": p["images"]} if p.get("images") else {})}
for p in all_pages
if p.get("page", p.get("page_index")) in page_nums
]
return []
if not isinstance(all_pages, list):
return []
result = []
for p in all_pages:
page = _as_int(p.get("page", p.get("page_index")))
if page not in page_nums:
continue
entry = {"page": page,
"content": p.get("content", p.get("markdown", ""))}
# Cloud OCR pages carry an `images` list (empty on text-only
# pages). Preserve it — omitting when empty, mirroring the local
# backend — so cloud callers get the same PageContent shape and
# the SDK-prompted UI can render figures.
if p.get("images"):
entry["images"] = p["images"]
result.append(entry)
return result
@staticmethod
def _normalize_tree(nodes: list) -> list:
def _normalize_tree(nodes: list | None) -> list:
"""Normalize cloud tree nodes to match local schema."""
if not nodes:
return []
result = []
for node in nodes:
normalized = {
@ -290,7 +313,7 @@ class CloudBackend:
if folder_id:
params["folder_id"] = folder_id
data = self._request("GET", "/docs/", params=params)
batch = data.get("documents", [])
batch = data.get("documents", []) or []
docs.extend(
{
"doc_id": d.get("id", ""),
@ -405,10 +428,10 @@ class CloudBackend:
return
if resp.status_code != 200:
body = resp.text[:500] if resp.text else ""
raise CloudAPIError(
f"Cloud streaming error {resp.status_code}: {body}",
status_code=resp.status_code,
)
msg = f"Cloud streaming error {resp.status_code}: {body}"
if resp.status_code == 401:
msg += f"{AUTH_HINT}"
raise CloudAPIError(msg, status_code=resp.status_code)
current_tool_name = None
current_tool_args: list[str] = []

View file

@ -6,7 +6,7 @@ from typing import Any, Iterator
import requests
from .errors import PageIndexAPIError
from .errors import AUTH_HINT, PageIndexAPIError
# Single source of truth for the cloud API base URL — imported by the modern
# CloudBackend (as API_BASE) and PageIndexClient so a staging/migration change
@ -47,7 +47,10 @@ class LegacyCloudAPI:
raise PageIndexAPIError(f"{error_prefix}: {e}") from e
if response.status_code != 200:
raise PageIndexAPIError(f"{error_prefix}: {response.text}")
msg = f"{error_prefix}: {response.text}"
if response.status_code == 401:
msg += f"{AUTH_HINT}"
raise PageIndexAPIError(msg)
return response
def submit_document(

View file

@ -55,3 +55,10 @@ class FileTypeError(PageIndexError, ValueError):
instead of) a bare ValueError one.
"""
pass
AUTH_HINT = (
"api_key must be a PageIndex cloud API key (https://dash.pageindex.ai/api-keys). "
"For local mode, omit api_key and set your LLM provider key "
"(e.g. OPENAI_API_KEY) in the environment."
)

View file

@ -521,9 +521,9 @@ def create_clean_structure_for_description(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)."""
"""Concatenate text from page_list for pages [start_page, end_page] (1-indexed), clamped to the valid page range."""
text = ""
for page_num in range(start_page - 1, end_page):
for page_num in range(max(start_page, 1) - 1, min(end_page, len(page_list))):
text += page_list[page_num][0]
return text
@ -831,15 +831,12 @@ def get_page_tokens(pdf_path, model=None, pdf_parser="PyPDF2"):
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
return _get_text_of_pages(pdf_pages, start_page, end_page)
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):
for page_num in range(max(start_page, 1) - 1, min(end_page, len(pdf_pages))):
text += f"<physical_index_{page_num+1}>\n{pdf_pages[page_num][0]}\n<physical_index_{page_num+1}>\n"
return text