2026-05-11 21:06:23 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- 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
2026-07-07 12:06:58 +08:00
|
|
|
import urllib.parse
|
2026-05-11 21:06:23 +08:00
|
|
|
from typing import Any, Iterator
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
|
2026-07-13 18:48:55 +08:00
|
|
|
from .errors import AUTH_HINT, PageIndexAPIError
|
2026-05-11 21:06:23 +08:00
|
|
|
|
2026-07-10 11:17:20 +08:00
|
|
|
# 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
|
|
|
|
|
# only has to happen here.
|
|
|
|
|
API_BASE = "https://api.pageindex.ai"
|
|
|
|
|
|
2026-05-11 21:06:23 +08:00
|
|
|
|
|
|
|
|
class LegacyCloudAPI:
|
|
|
|
|
"""Compatibility layer for the pageindex 0.2.x cloud SDK API."""
|
|
|
|
|
|
2026-07-10 11:17:20 +08:00
|
|
|
BASE_URL = API_BASE
|
2026-05-11 21:06:23 +08:00
|
|
|
|
|
|
|
|
def __init__(self, api_key: str, base_url: str | None = None):
|
|
|
|
|
self.api_key = api_key
|
|
|
|
|
self.base_url = base_url or self.BASE_URL
|
|
|
|
|
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- 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
2026-07-07 12:06:58 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def _enc(value: str) -> str:
|
|
|
|
|
"""URL-encode a path segment (ids may contain / ? # or spaces)."""
|
|
|
|
|
return urllib.parse.quote(str(value), safe="")
|
|
|
|
|
|
2026-05-11 21:06:23 +08:00
|
|
|
def _headers(self) -> dict[str, str]:
|
|
|
|
|
return {"api_key": self.api_key}
|
|
|
|
|
|
|
|
|
|
def _request(self, method: str, path: str, error_prefix: str, **kwargs) -> requests.Response:
|
2026-07-07 10:05:38 +08:00
|
|
|
# Always bound the request so a dead connection can't hang callers
|
|
|
|
|
# forever. Streamed responses get a longer read timeout since it
|
|
|
|
|
# applies between chunks, not to the whole response.
|
|
|
|
|
kwargs.setdefault("timeout", 120 if kwargs.get("stream") else 30)
|
2026-05-11 21:06:23 +08:00
|
|
|
try:
|
|
|
|
|
response = requests.request(
|
|
|
|
|
method,
|
|
|
|
|
f"{self.base_url}{path}",
|
|
|
|
|
headers=self._headers(),
|
|
|
|
|
**kwargs,
|
|
|
|
|
)
|
|
|
|
|
except requests.RequestException as e:
|
|
|
|
|
raise PageIndexAPIError(f"{error_prefix}: {e}") from e
|
|
|
|
|
|
|
|
|
|
if response.status_code != 200:
|
2026-07-13 18:48:55 +08:00
|
|
|
msg = f"{error_prefix}: {response.text}"
|
|
|
|
|
if response.status_code == 401:
|
|
|
|
|
msg += f" — {AUTH_HINT}"
|
|
|
|
|
raise PageIndexAPIError(msg)
|
2026-05-11 21:06:23 +08:00
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
def submit_document(
|
|
|
|
|
self,
|
|
|
|
|
file_path: str,
|
|
|
|
|
mode: str | None = None,
|
|
|
|
|
beta_headers: list[str] | None = None,
|
|
|
|
|
folder_id: str | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
data: dict[str, Any] = {"if_retrieval": True}
|
|
|
|
|
if mode is not None:
|
|
|
|
|
data["mode"] = mode
|
|
|
|
|
if beta_headers is not None:
|
|
|
|
|
data["beta_headers"] = json.dumps(beta_headers)
|
|
|
|
|
if folder_id is not None:
|
|
|
|
|
data["folder_id"] = folder_id
|
|
|
|
|
|
|
|
|
|
with open(file_path, "rb") as f:
|
|
|
|
|
response = self._request(
|
|
|
|
|
"POST",
|
|
|
|
|
"/doc/",
|
|
|
|
|
"Failed to submit document",
|
|
|
|
|
files={"file": f},
|
|
|
|
|
data=data,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def get_ocr(self, doc_id: str, format: str = "page") -> dict[str, Any]:
|
|
|
|
|
if format not in ["page", "node", "raw"]:
|
|
|
|
|
raise ValueError("Format parameter must be 'page', 'node', or 'raw'")
|
|
|
|
|
|
|
|
|
|
response = self._request(
|
|
|
|
|
"GET",
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- 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
2026-07-07 12:06:58 +08:00
|
|
|
f"/doc/{self._enc(doc_id)}/?type=ocr&format={format}",
|
2026-05-11 21:06:23 +08:00
|
|
|
"Failed to get OCR result",
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def get_tree(self, doc_id: str, node_summary: bool = False) -> dict[str, Any]:
|
|
|
|
|
response = self._request(
|
|
|
|
|
"GET",
|
2026-07-10 11:17:20 +08:00
|
|
|
# Lowercase the bool: a Python f-string renders True/False with a
|
|
|
|
|
# capital letter, but the API expects summary=true/false (the modern
|
|
|
|
|
# CloudBackend sends lowercase). A case-sensitive server would
|
|
|
|
|
# otherwise silently drop node summaries.
|
|
|
|
|
f"/doc/{self._enc(doc_id)}/?type=tree&summary={'true' if node_summary else 'false'}",
|
2026-05-11 21:06:23 +08:00
|
|
|
"Failed to get tree result",
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def is_retrieval_ready(self, doc_id: str) -> bool:
|
2026-07-07 10:12:51 +08:00
|
|
|
"""Return whether retrieval is ready for ``doc_id``.
|
|
|
|
|
|
2026-07-07 15:48:49 +08:00
|
|
|
Faithfully matches the 0.2.x cloud SDK: API errors are swallowed and
|
|
|
|
|
reported as "not ready" (False) so existing
|
|
|
|
|
``while not is_retrieval_ready(...)`` polling loops behave identically.
|
|
|
|
|
Note this can loop forever on a permanent error (revoked key, deleted
|
|
|
|
|
doc) — that is the legacy contract; guard the loop yourself if needed.
|
2026-07-07 10:12:51 +08:00
|
|
|
"""
|
2026-07-07 15:48:49 +08:00
|
|
|
try:
|
|
|
|
|
result = self.get_tree(doc_id)
|
|
|
|
|
return result.get("retrieval_ready", False)
|
|
|
|
|
except PageIndexAPIError:
|
|
|
|
|
return False
|
2026-05-11 21:06:23 +08:00
|
|
|
|
|
|
|
|
def submit_query(self, doc_id: str, query: str, thinking: bool = False) -> dict[str, Any]:
|
|
|
|
|
payload = {
|
|
|
|
|
"doc_id": doc_id,
|
|
|
|
|
"query": query,
|
|
|
|
|
"thinking": thinking,
|
|
|
|
|
}
|
|
|
|
|
response = self._request(
|
|
|
|
|
"POST",
|
|
|
|
|
"/retrieval/",
|
|
|
|
|
"Failed to submit retrieval",
|
|
|
|
|
json=payload,
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def get_retrieval(self, retrieval_id: str) -> dict[str, Any]:
|
|
|
|
|
response = self._request(
|
|
|
|
|
"GET",
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- 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
2026-07-07 12:06:58 +08:00
|
|
|
f"/retrieval/{self._enc(retrieval_id)}/",
|
2026-05-11 21:06:23 +08:00
|
|
|
"Failed to get retrieval result",
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def chat_completions(
|
|
|
|
|
self,
|
|
|
|
|
messages: list[dict[str, str]],
|
|
|
|
|
stream: bool = False,
|
|
|
|
|
doc_id: str | list[str] | None = None,
|
|
|
|
|
temperature: float | None = None,
|
|
|
|
|
stream_metadata: bool = False,
|
|
|
|
|
enable_citations: bool = False,
|
|
|
|
|
) -> dict[str, Any] | Iterator[str] | Iterator[dict[str, Any]]:
|
|
|
|
|
payload: dict[str, Any] = {
|
|
|
|
|
"messages": messages,
|
|
|
|
|
"stream": stream,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if doc_id is not None:
|
|
|
|
|
payload["doc_id"] = doc_id
|
|
|
|
|
if temperature is not None:
|
|
|
|
|
payload["temperature"] = temperature
|
|
|
|
|
if enable_citations:
|
|
|
|
|
payload["enable_citations"] = enable_citations
|
2026-07-09 19:57:43 +08:00
|
|
|
# Forward stream_metadata so the wire request matches the caller's intent
|
|
|
|
|
# (and stays correct if the server ever gates metadata chunks behind it),
|
|
|
|
|
# mirroring the modern CloudBackend which always sends it. It only affects
|
|
|
|
|
# streaming responses, where it selects the raw dict-chunk parser below.
|
|
|
|
|
if stream_metadata:
|
|
|
|
|
payload["stream_metadata"] = stream_metadata
|
2026-05-11 21:06:23 +08:00
|
|
|
|
|
|
|
|
response = self._request(
|
|
|
|
|
"POST",
|
|
|
|
|
"/chat/completions/",
|
|
|
|
|
"Failed to get chat completion",
|
|
|
|
|
json=payload,
|
|
|
|
|
stream=stream,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if stream:
|
|
|
|
|
if stream_metadata:
|
|
|
|
|
return self._stream_chat_response_raw(response)
|
|
|
|
|
return self._stream_chat_response(response)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def _stream_chat_response(self, response: requests.Response) -> Iterator[str]:
|
|
|
|
|
try:
|
|
|
|
|
for line in response.iter_lines():
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
line = line.decode("utf-8")
|
|
|
|
|
if not line.startswith("data: "):
|
|
|
|
|
continue
|
|
|
|
|
data = line[6:]
|
|
|
|
|
if data == "[DONE]":
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
chunk = json.loads(data)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
continue
|
|
|
|
|
choices = chunk.get("choices") or []
|
|
|
|
|
if not choices:
|
|
|
|
|
continue
|
|
|
|
|
content = choices[0].get("delta", {}).get("content", "")
|
|
|
|
|
if content:
|
|
|
|
|
yield content
|
|
|
|
|
except requests.RequestException as e:
|
|
|
|
|
raise PageIndexAPIError(f"Failed to stream chat completion: {e}") from e
|
|
|
|
|
finally:
|
|
|
|
|
response.close()
|
|
|
|
|
|
|
|
|
|
def _stream_chat_response_raw(self, response: requests.Response) -> Iterator[dict[str, Any]]:
|
|
|
|
|
try:
|
|
|
|
|
for line in response.iter_lines():
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
line = line.decode("utf-8")
|
|
|
|
|
if not line.startswith("data: "):
|
|
|
|
|
continue
|
|
|
|
|
data = line[6:]
|
|
|
|
|
if data == "[DONE]":
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
yield json.loads(data)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
continue
|
|
|
|
|
except requests.RequestException as e:
|
|
|
|
|
raise PageIndexAPIError(f"Failed to stream chat completion: {e}") from e
|
|
|
|
|
finally:
|
|
|
|
|
response.close()
|
|
|
|
|
|
|
|
|
|
def get_document(self, doc_id: str) -> dict[str, Any]:
|
|
|
|
|
response = self._request(
|
|
|
|
|
"GET",
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- 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
2026-07-07 12:06:58 +08:00
|
|
|
f"/doc/{self._enc(doc_id)}/metadata/",
|
2026-05-11 21:06:23 +08:00
|
|
|
"Failed to get document metadata",
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def delete_document(self, doc_id: str) -> dict[str, Any]:
|
|
|
|
|
response = self._request(
|
|
|
|
|
"DELETE",
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- 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
2026-07-07 12:06:58 +08:00
|
|
|
f"/doc/{self._enc(doc_id)}/",
|
2026-05-11 21:06:23 +08:00
|
|
|
"Failed to delete document",
|
|
|
|
|
)
|
2026-07-09 20:18:44 +08:00
|
|
|
# A successful DELETE may come back with an empty body (the documented
|
|
|
|
|
# examples don't consume one, and REST APIs commonly return no content
|
|
|
|
|
# for deletes). Don't let json() raise JSONDecodeError on success —
|
|
|
|
|
# the document is already gone; return an empty dict.
|
|
|
|
|
return response.json() if response.content else {}
|
2026-05-11 21:06:23 +08:00
|
|
|
|
|
|
|
|
def list_documents(
|
|
|
|
|
self,
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
offset: int = 0,
|
|
|
|
|
folder_id: str | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
if limit < 1 or limit > 100:
|
|
|
|
|
raise ValueError("limit must be between 1 and 100")
|
|
|
|
|
if offset < 0:
|
|
|
|
|
raise ValueError("offset must be non-negative")
|
|
|
|
|
|
|
|
|
|
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
|
|
|
if folder_id is not None:
|
|
|
|
|
params["folder_id"] = folder_id
|
|
|
|
|
|
|
|
|
|
response = self._request(
|
|
|
|
|
"GET",
|
|
|
|
|
"/docs/",
|
|
|
|
|
"Failed to list documents",
|
|
|
|
|
params=params,
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def create_folder(
|
|
|
|
|
self,
|
|
|
|
|
name: str,
|
|
|
|
|
description: str | None = None,
|
|
|
|
|
parent_folder_id: str | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
payload: dict[str, Any] = {"name": name}
|
|
|
|
|
if description is not None:
|
|
|
|
|
payload["description"] = description
|
|
|
|
|
if parent_folder_id is not None:
|
|
|
|
|
payload["parent_folder_id"] = parent_folder_id
|
|
|
|
|
|
|
|
|
|
response = self._request(
|
|
|
|
|
"POST",
|
|
|
|
|
"/folder/",
|
|
|
|
|
"Failed to create folder",
|
|
|
|
|
json=payload,
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def list_folders(self, parent_folder_id: str | None = None) -> dict[str, Any]:
|
|
|
|
|
params = {}
|
|
|
|
|
if parent_folder_id is not None:
|
|
|
|
|
params["parent_folder_id"] = parent_folder_id
|
|
|
|
|
|
|
|
|
|
response = self._request(
|
|
|
|
|
"GET",
|
|
|
|
|
"/folders/",
|
|
|
|
|
"Failed to list folders",
|
|
|
|
|
params=params,
|
|
|
|
|
)
|
|
|
|
|
return response.json()
|