Workspaces
Search
client.search answers questions over the ingested files of a workspace, and cites the files it read. Each call creates a job: read its result with jobs.wait. A file that isn't ingested isn't searched. Automatic searches are limited to 60 per account every 10 minutes; past that, a call raises NaceRateLimitError (rate_limited) once its retries are spent. See Search for what it answers.
context works the same on both: pass text the answer should take as given for this call, or leave it out for none. output_schema is a JSON Schema dict, or a Pydantic model class, whose model_json_schema() is sent; the answer's structured_answer then holds the parsed value.
Automatic search
client.search.automatic(workspace_id, *, query, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
workspace_id | str | Yes | The workspace. |
query | str | Yes | The question, up to 4,096 characters. |
context | str | No | Background the answer takes as given, up to 20,000 characters. Default: none. |
path_prefix | str | No | Search only the files under this folder. Default: the whole workspace. |
paths | list[str] | No | Search exactly these files, by path, as well as any under path_prefix. |
top_k | int | No | How many evidences a fact answer cites, 1 to 50. Default: 6. |
mode | "auto" / "fact" / "agent" | No | auto lets the document service choose; fact and agent skip the routing step. Default: "auto". |
output_schema | dict | type[BaseModel] | No | A JSON Schema the answer must follow, or a Pydantic model class. |
wait_seconds | int | No | 0 to 60. Default: None, which returns at once. |
idempotency_key | str | No | Up to 200 characters. Default: one minted per call. |
It returns a DocumentJob. Its result has result_type automatic_search, the routing it took, and output: a fact answer or an agent answer. One question, one answer, with no follow-ups. See Automatic search.
job = client.search.automatic(workspace_id, query="What were total liabilities at year end?", path_prefix="reports/2025")
done = client.jobs.wait(job.job_id)
print(done.result["routing"]["final"], done.result["output"]["result_type"])QA
client.search.qa(workspace_id, *, query, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
workspace_id | str | Yes | The workspace. |
query | str | Yes | This turn's question, up to 4,096 characters. |
context | str | No | As on automatic search. |
path_prefix | str | No | As on automatic search. Changing it starts a fresh conversation. |
paths | list[str] | No | As on automatic search. Changing it starts a fresh conversation. |
session_id | str | No | Continue a conversation: an earlier turn's answer.session_id. Default: start one. |
allow_clarification | bool | No | Let the agent ask back when a question is ambiguous. Default: True. |
clarification_answers | list[dict] | No | Answers to the last turn's clarifying questions, in order, each {"question", "answer"}. Needs session_id; without it, NaceError is raised before any request. |
output_schema | dict | type[BaseModel] | No | A JSON Schema, or a Pydantic model class, for this turn's answer only. |
wait_seconds | int | No | 0 to 60. Default: None, which returns at once. |
idempotency_key | str | No | Up to 200 characters. Default: one minted per call. |
It returns a DocumentJob. Its result is an agent answer: result_type automatic_agent, with answer and catalog. A turn sent while the last one still runs raises NaceConflictError (session_busy), and a session_id that isn't a conversation in this workspace raises NaceNotFoundError (session_not_found). See QA.
first = client.jobs.wait(client.search.qa(workspace_id, query="What was total freight spend in FY2024?").job_id)
session_id = first.result["answer"]["session_id"]
follow_up = client.jobs.wait(client.search.qa(workspace_id, query="And for FY2023?", session_id=session_id).job_id)
print(follow_up.result["answer"]["answer"])from pydantic import BaseModel
class Liabilities(BaseModel):
total: float
currency: str
done = client.jobs.wait(client.search.automatic(workspace_id, query="Total liabilities at year end?", output_schema=Liabilities).job_id)
print(done.result["output"])Evidences and where they are
Every answer cites the passages it rests on, as evidences. Where they sit depends on the route that answered, so read routing.final, not the mode you sent: a search in auto mode can go either way, and a QA turn always comes from the agent.
| Answer | Evidences | Ground jobs |
|---|---|---|
Automatic search, routing.final == "fact" | result["output"]["evidences"] | result["output"]["grounding_job_ids"]: jobs the search already ran to locate the quotes. Read them. |
Automatic search, routing.final == "agent" | result["output"]["answer"]["evidences"] | None (grounding_job_ids is None). Ground the quotes yourself. |
| QA turn | result["answer"]["evidences"] | None. Ground the quotes yourself. |
Each evidence is a dict with source_path and source_file_id (the cited file), page and component (a sheet or section; either may be None), quote (the passage as it appears in the file) and relevance (why it matters). See Evidences.
done = client.jobs.wait(client.search.automatic(workspace_id, query="What were total liabilities at year end?").job_id)
result = done.result
fact = result["routing"]["final"] == "fact"
answer = result["output"] if fact else result["output"]["answer"]
for number, evidence in enumerate(answer["evidences"], start=1):
print(number, evidence["source_path"], evidence["page"], evidence["quote"])Read the Ground jobs of a fact answer
A fact answer lists, in grounding_job_ids, Ground jobs the search ran over its cited files. Read each with jobs.wait; one may still be running when the answer arrives. In each job's result, a target's id is the 1-based position of its evidence in evidences ("3"), or "3#1" for one part of a quote stitched together from several places. A target whose status is found has the location in matches[0]["location"]; see Ground for its keys.
locations = {}
for job_id in answer["grounding_job_ids"] or []:
ground = client.jobs.wait(job_id)
for target in ground.result["targets"]:
if target["status"] == "found":
number = int(target["id"].split("#")[0])
locations.setdefault(number, target["matches"][0]["location"])
for number, evidence in enumerate(answer["evidences"], start=1):
print(number, evidence["quote"], locations.get(number, "not located"))Ground an agent answer yourself
An agent answer, from an automatic search the service sent to the agent or from a QA turn, runs no Ground jobs: its quotes come without locations. To locate them, call client.documents.ground once per cited file, with a workspace_file source naming the workspace and the evidence's source_file_id, and up to 30 quotes as targets. Each is a Ground job, billed like any other.
- Give each target the evidence's position as its
id, so you can match the results back. - Pass the page as a hint when the evidence has one (
"page 12"), and thecomponentassheetfor a spreadsheet. - Split a stitched quote. An agent may join parts of a passage with
…; the whole quote then matches nowhere. Send each part as its own target.
import re
from collections import defaultdict
from nace_sdk import WorkspaceFileSource
targets = defaultdict(list)
for number, evidence in enumerate(answer["evidences"], start=1):
if not evidence.get("source_file_id"):
continue
# A quote stitched with "…" matches nowhere whole: look for each part (of 6 characters or more).
parts = [part for part in re.split(r"\s*(?:…|\.{3,})\s*", evidence["quote"]) if len(part) >= 6]
if len(parts) < 2:
parts = [evidence["quote"]]
for index, text in enumerate(parts):
target = {"id": str(number) if len(parts) == 1 else f"{number}#{index}", "text": text}
if evidence.get("page") is not None:
target["hint"] = f"page {evidence['page']}"
targets[evidence["source_file_id"]].append(target)
for file_id, quotes in targets.items():
for start in range(0, len(quotes), 30):
job = client.documents.ground(
WorkspaceFileSource(workspace_id=workspace_id, file_id=file_id),
targets=quotes[start : start + 30],
options={"max_matches": 1},
)
ground = client.jobs.wait(job.job_id)
for target in ground.result["targets"]:
match = target["matches"][0] if target["status"] == "found" else None
print(target["id"], match["location"] if match else "not located")