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

@ -12,10 +12,11 @@ def _multidoc_acked() -> bool:
_MULTIDOC_WARNING = (
"Querying the entire collection (no doc_ids) is experimental — selection "
"accuracy depends on auto-generated doc descriptions. Pass doc_ids=[...] "
"for reliable results, or set PAGEINDEX_EXPERIMENTAL_MULTIDOC=1 to silence "
"this warning."
"Querying the entire collection (no doc_ids) is experimental — a naive "
"first implementation that lets the agent pick docs from auto-generated "
"descriptions. Better cross-document retrieval is on the way. Pass "
"doc_ids=[...] for reliable results, or set "
"PAGEINDEX_EXPERIMENTAL_MULTIDOC=1 to silence this warning."
)
@ -66,22 +67,33 @@ class Collection:
def delete_document(self, doc_id: str) -> None:
self._backend.delete_document(self._name, doc_id)
def query(self, question: str, doc_ids: list[str] | None = None,
def query(self, question: str,
doc_ids: str | list[str] | None = None,
stream: bool = False) -> str | QueryStream:
"""Query documents in this collection.
- stream=False: returns answer string (sync)
- stream=True: returns async iterable of QueryEvent
``doc_ids`` can be a single doc id (``str``) or a list. ``None`` queries
the entire collection (experimental).
Usage:
answer = col.query("question", doc_ids=[doc_id])
async for event in col.query("question", doc_ids=[doc_id], stream=True):
answer = col.query("question", doc_ids=doc_id) # single
answer = col.query("question", doc_ids=[d1, d2]) # multi
async for event in col.query("question", doc_ids=doc_id, stream=True):
...
Passing doc_ids=None queries the entire collection this is
experimental; emits a UserWarning unless PAGEINDEX_EXPERIMENTAL_MULTIDOC
is set.
"""
if isinstance(doc_ids, str):
doc_ids = [doc_ids]
elif doc_ids == []:
raise ValueError(
"doc_ids cannot be empty; pass None to query the whole collection"
)
if doc_ids is None and not _multidoc_acked():
docs = self._backend.list_documents(self._name)
if not docs: