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.

client.search.automatic(workspace_id, *, query, **options)

ParameterTypeRequiredDescription
workspace_idstrYesThe workspace.
querystrYesThe question, up to 4,096 characters.
contextstrNoBackground the answer takes as given, up to 20,000 characters. Default: none.
path_prefixstrNoSearch only the files under this folder. Default: the whole workspace.
pathslist[str]NoSearch exactly these files, by path, as well as any under path_prefix.
top_kintNoHow many evidences a fact answer cites, 1 to 50. Default: 6.
mode"auto" / "fact" / "agent"Noauto lets the document service choose; fact and agent skip the routing step. Default: "auto".
output_schemadict | type[BaseModel]NoA JSON Schema the answer must follow, or a Pydantic model class.
wait_secondsintNo0 to 60. Default: None, which returns at once.
idempotency_keystrNoUp 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.

automatic_search.py
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)

ParameterTypeRequiredDescription
workspace_idstrYesThe workspace.
querystrYesThis turn's question, up to 4,096 characters.
contextstrNoAs on automatic search.
path_prefixstrNoAs on automatic search. Changing it starts a fresh conversation.
pathslist[str]NoAs on automatic search. Changing it starts a fresh conversation.
session_idstrNoContinue a conversation: an earlier turn's answer.session_id. Default: start one.
allow_clarificationboolNoLet the agent ask back when a question is ambiguous. Default: True.
clarification_answerslist[dict]NoAnswers to the last turn's clarifying questions, in order, each {"question", "answer"}. Needs session_id; without it, NaceError is raised before any request.
output_schemadict | type[BaseModel]NoA JSON Schema, or a Pydantic model class, for this turn's answer only.
wait_secondsintNo0 to 60. Default: None, which returns at once.
idempotency_keystrNoUp 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.

qa.py
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"])
structured_answer.py
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.

AnswerEvidencesGround 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 turnresult["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.

search_evidences.py
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.

read_grounding.py
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 the component as sheet for 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.
ground_agent_answer.py
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")

On this page