feat(collection): doc_ids accepts str|list, design cleanups

- 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.
This commit is contained in:
mountain 2026-05-15 17:03:17 +08:00
parent d7b36aaf3f
commit a47c36a3f5
13 changed files with 322 additions and 45 deletions

View file

@ -1,4 +1,4 @@
from pageindex.agent import AgentRunner, SYSTEM_PROMPT
from pageindex.agent import AgentRunner, OPEN_SYSTEM_PROMPT, SCOPED_SYSTEM_PROMPT
from pageindex.backend.protocol import AgentTools
@ -8,7 +8,13 @@ def test_agent_runner_init():
assert runner._model == "gpt-4o"
def test_system_prompt_has_tool_instructions():
assert "list_documents" in SYSTEM_PROMPT
assert "get_document_structure" in SYSTEM_PROMPT
assert "get_page_content" in SYSTEM_PROMPT
def test_open_prompt_has_tool_instructions():
assert "list_documents" in OPEN_SYSTEM_PROMPT
assert "get_document_structure" in OPEN_SYSTEM_PROMPT
assert "get_page_content" in OPEN_SYSTEM_PROMPT
def test_scoped_prompt_omits_list_documents():
assert "list_documents" not in SCOPED_SYSTEM_PROMPT
assert "get_document_structure" in SCOPED_SYSTEM_PROMPT
assert "get_page_content" in SCOPED_SYSTEM_PROMPT

View file

@ -13,6 +13,32 @@ def test_cloud_client_is_pageindex_client():
assert isinstance(client, PageIndexClient)
def test_empty_api_key_legacy_method_error_is_specific(tmp_path, caplog):
"""Empty api_key falls back to local mode; legacy methods raise a clear error."""
import warnings
from pageindex.errors import PageIndexAPIError
client = PageIndexClient(api_key="", storage_path=str(tmp_path / "pi"))
# Empty api_key → local mode; legacy methods should explain why
with warnings.catch_warnings():
warnings.simplefilter("ignore", PendingDeprecationWarning)
with pytest.raises(PageIndexAPIError, match="empty string"):
client.submit_document("some.pdf")
def test_none_api_key_legacy_method_error_is_generic(tmp_path):
"""api_key=None → local mode; legacy methods raise generic error (not 'empty')."""
import warnings
from pageindex.errors import PageIndexAPIError
client = PageIndexClient(api_key=None, model="gpt-4o", storage_path=str(tmp_path / "pi"))
with warnings.catch_warnings():
warnings.simplefilter("ignore", PendingDeprecationWarning)
with pytest.raises(PageIndexAPIError) as exc_info:
client.submit_document("some.pdf")
assert "empty" not in str(exc_info.value)
def test_collection_default_name(tmp_path):
client = LocalClient(model="gpt-4o", storage_path=str(tmp_path / "pi"))
col = client.collection()

View file

@ -1,3 +1,5 @@
import pytest
from pageindex.backend.cloud import CloudBackend, API_BASE
@ -11,6 +13,7 @@ def test_api_base_url():
assert "pageindex.ai" in API_BASE
def test_get_retrieve_model_is_none():
def test_query_rejects_empty_doc_ids():
backend = CloudBackend(api_key="pi-test")
assert backend.get_agent_tools("col").function_tools == []
with pytest.raises(ValueError, match="cannot be empty"):
backend.query("col", "q", doc_ids=[])

View file

@ -82,3 +82,15 @@ def test_query_env_var_silences_warning(col, monkeypatch, recwarn):
col._backend.query.return_value = "answer"
col.query("what?")
assert not any(issubclass(w.category, UserWarning) for w in recwarn)
def test_query_accepts_str_doc_id(col):
"""str gets normalized to [str] internally."""
col._backend.query.return_value = "answer"
col.query("what?", doc_ids="d1")
col._backend.query.assert_called_once_with("papers", "what?", ["d1"])
def test_query_rejects_empty_list(col):
with pytest.raises(ValueError, match="cannot be empty"):
col.query("what?", doc_ids=[])

View file

@ -111,7 +111,8 @@ def test_wrap_with_doc_context_single(populated_backend):
docs = populated_backend._scoped_docs("papers", ["d1"])
wrapped = wrap_with_doc_context(docs, "what is this?")
assert "d1: alpha.pdf — About alpha." in wrapped
assert "specified the following document:" in wrapped
assert "specified the following document" in wrapped
assert "<docs>" in wrapped and "</docs>" in wrapped
assert "User question: what is this?" in wrapped
@ -121,10 +122,22 @@ def test_wrap_with_doc_context_multi(populated_backend):
wrapped = wrap_with_doc_context(docs, "compare them")
assert "d1: alpha.pdf — About alpha." in wrapped
assert "d2: beta.pdf — About beta." in wrapped
assert "specified the following documents:" in wrapped
assert "specified the following documents" in wrapped
assert "<docs>" in wrapped and "</docs>" in wrapped
assert "User question: compare them" in wrapped
def test_scoped_docs_raises_on_missing(populated_backend):
with pytest.raises(DocumentNotFoundError, match="nonexistent"):
populated_backend._scoped_docs("papers", ["d1", "nonexistent"])
def test_normalize_doc_ids():
assert LocalBackend._normalize_doc_ids("d1") == ["d1"]
assert LocalBackend._normalize_doc_ids(["d1", "d2"]) == ["d1", "d2"]
assert LocalBackend._normalize_doc_ids(None) is None
def test_normalize_doc_ids_rejects_empty_list():
with pytest.raises(ValueError, match="cannot be empty"):
LocalBackend._normalize_doc_ids([])