Query, Streaming Query, and Search APIs
Use these endpoints to build cited chat and raw retrieval clients:
| Endpoint | Purpose | Response type |
|---|---|---|
POST /query | Run the full answer pipeline and return one cited answer as JSON. | application/json |
POST /query/stream | Run the same answer pipeline, then reveal the already-guarded result as Server-Sent Events. | text/event-stream |
POST /search | Return ranked source chunks only, with no answer synthesis. | application/json |
All routes are mounted at the API root. The public production edge proxies the complete FastAPI HTTP surface, including /healthz and /readyz. Documentation routes /docs, /redoc, and /openapi.json are available when api_docs_enabled=true, currently enabled in production. This page documents the three query and retrieval endpoints; see the other API reference pages for conversations, memory, ingestion, and document operations. There is no WebSocket query route.
Authentication and browser integration
Every route on this page requires the configured API-key header. The default header name is X-API-Key.
X-API-Key: <server-side API key>
Principal-bearing deployments may also receive trusted identity headers from an upstream gateway or backend:
| Header | Type | Meaning |
|---|---|---|
X-User-Subject | string | Stable authenticated user subject. |
X-User-Role | string | Authenticated role. Defaults to user only when the trusted resolver accepts fallback behavior. |
X-User-Unit | string | Optional organization unit for access filtering. |
X-User-Level | integer string | Optional position level for access filtering. |
Browser applications must not embed a shared API key and must not let users forge X-User-* identity headers. Put these calls behind a same-origin backend-for-frontend or an API gateway that injects the key and identity headers server-side. The API does not add app-level CORS behavior for browser cross-origin calls.
For the production edge, server-side clients send X-API-Key and a supplied X-User-Subject when identifying the caller. Caddy preserves that subject but strips caller-supplied X-User-Role, X-User-Unit, and X-User-Level. On subject-less requests it strips all identity headers and injects no identity; FastAPI applies its configured authentication policy. The key-only examples below do not identify an individual caller. Set the URL and read the credential without storing it in shell history before running the curl examples on this page:
export API_BASE='https://rag-aceh-ai-eng-neo.reviewstagepro.com'
read -rsp 'Production API key: ' API_KEY && export API_KEY && printf '\n'
Native EventSource cannot send X-API-Key or trusted identity headers, so streaming clients must use fetch() plus ReadableStream parsing, as shown below. Clear the credential when testing is complete with unset API_KEY API_BASE.
Shared concepts
Collection scoping
/query, /query/stream, and /search accept collection_ids to narrow retrieval to partner collections.
| Value | Behavior |
|---|---|
| Field omitted | Search all collections the caller may access. |
null | Same as omitted. |
[] | Same as omitted. |
| String array | Trim each entry, drop blank entries, deduplicate the remaining IDs in first-seen order, then restrict retrieval to the intersection of those IDs and the caller's access predicate. |
| Array containing only blank strings | Same as omitted: after normalization there is no collection scope, so all accessible collections are searched. |
| Nonblank unknown or forbidden ID | Produces no matching rows for that normalized ID. It is not a 403 and does not reveal whether the collection exists. |
Collection IDs are opaque stable strings. Examples include partner collection names such as JDIH, PPID, SatuData, and OpenData, but clients should treat them as configured IDs, not as an exhaustive enum.
Document scoping
/query, /query/stream, and /search accept document_ids to narrow retrieval to selected AI-engine documents. Each value is the canonical UUID in documents.id, not the partner ingestion registry's documentId.
Partners obtain this UUID from aiDocumentId in a post-persistence Kafka status event, stored_document_id in upload/ingestion status, document_id in /search results, or document_id in answer citations. Retain that value when a user selects a named regulation or other document for focused questioning.
| Value | Behavior |
|---|---|
| Field omitted | Search all documents the caller may access. |
null | Same as omitted. |
[] | Same as omitted. |
| UUID array | Canonicalize each UUID, deduplicate the remaining IDs in first-seen order, then restrict retrieval to their intersection with collection_ids when present and the caller's access predicate. |
| Malformed UUID | Request validation fails with 422; malformed IDs are client input errors rather than a no-match focus. |
| Well-formed unknown or forbidden UUID | Produces no matching rows. It is not a 403 and does not reveal whether the document exists or is accessible. |
A non-empty document focus is an answer-source guarantee: the Agentic RAG answer uses only retrieved chunks from the selected, visible indexed documents. It does not select SQL Query, OpenData, BPS, or Excel Query tools, because those can create live-data or raw-file synthetic grounding outside the selected document chunks.
Saved sources, an empty set, and MCP
A workspace or notebook keeps its own saved source set, and that record governs the answer. Two empty cases are worth separating:
| Saved source set | mcp_source_ids | Behavior |
|---|---|---|
Explicitly empty (whole_corpus: false, no collection or document) | A source (in the request, or the conversation's saved selection) | 200, answered from MCP alone: no document, sheet, or portal tool is offered, and no anchor retrieval runs. |
| Explicitly empty | [], or omitted/null with no saved conversation selection | 400 — "This workspace has no sources…" / "This notebook has no sources…". |
| Sources that no longer resolve (deleted or unreadable) | A source | 200, answered from MCP alone, with a fixed note that the saved documents are gone. |
| Holds documents | Any | Today's behavior: the saved document scope wins, and a selected public MCP source may add evidence as described for MCP queries. |
The empty-set MCP-only rule needs no new request field. A follow-up may omit mcp_source_ids; the conversation's saved MCP selection applies. If MCP resolution leaves no usable tool, the request returns 400 instead of answering from the corpus the empty set excluded. Governed MCP sources stay withheld under a source boundary.
On /query, /query/stream, /query/v2, and /query/v2/stream, the workspace Owner and any Partner may save the chat's MCP selection, setting or clearing it, exactly as they may replace the workspace's saved document set. The saved selection changes the sources for every member of the chat. A Viewer is refused with 403: a Viewer cannot ask in the chat at all. A personal chat's selection is saved by its owner.
Workspace upload inventory
For a chat belonging to a workspace, corpus_inventory distinguishes that
workspace's accessible indexed uploads from the sources selected for the answer.
It reports the exact uploaded, selected, and unselected counts, with bounded
title lists. For example, eleven indexed uploads with one selected are reported
as eleven uploaded, one selected, and ten unselected; unselected files cannot
supply document content until added under Sumber.
Whole-corpus selection still counts only this workspace's own uploads in that
workspace block; the separate source inventory counts all indexed documents in
the accessible answer scope. Files still ingesting or failed are excluded. A
query outside a workspace has no workspace upload block. Access uses the same
membership predicate as GET /workspaces/{id}/documents.
Counts and the title page share one database snapshot. The page covers at most 200 uploads; each selected/unselected list shows at most 20 neutralized titles, 120 characters per title and a 1,200-character bullet budget, with the omitted count stated explicitly. These lists expose inventory, without widening the selected content scope.
Upload-state questions use live corpus tools rather than conversation history; the narrow recall override and its exceptions are defined in the intent guard reference.
Shared citation object
Branch on kind when rendering citations. The base fields below describe a
kind: "chunk" citation; conditional access-scope fields appear only when that
source document is restricted.
| Field | Type | Required | Meaning |
|---|---|---|---|
kind | "chunk" | Yes | Indexed document passage. |
document_id | string (UUID) | Yes | Source document ID. |
document_title | string | Yes | Source document title. |
chunk_id | string (UUID) | Yes | Source chunk ID. |
chunk_ordinal | integer | Yes | Chunk ordinal within the document. |
page_number | integer or null | Yes | Source page number when known. |
section_path | string array | Yes | Hierarchical section path for the chunk. Empty when no section path is known. |
legal_reference | string or null | Yes | Legal citation/reference extracted for the chunk when known. |
char_start | integer or null | Yes | Start character offset within the source text when known. |
char_end | integer or null | Yes | End character offset within the source text when known. |
confidential | true | Conditional | Present only when the source document is confidential. Omitted for non-confidential sources. |
owning_org_unit | string | Conditional | Present only when the source has an owning organization unit restriction. |
min_position_level | integer | Conditional | Present only when the source requires a minimum position level. |
Example citation:
{
"kind": "chunk",
"document_id": "00000000-0000-0000-0000-000000000042",
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": "00000000-0000-0000-0000-000000000991",
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088,
"confidential": true,
"owning_org_unit": "Bappeda",
"min_position_level": 3
}
kind: "tool" identifies a tool read rather than an indexed chunk. It carries
tool + call and null document/chunk IDs. Portal reads may also carry
source_title, source_name, source_id, period, and source_url;
document_title is the ready-to-render source label.
Successful catalogued workbook reads (peek, describe, compute, rows)
carry the workbook title in source_title, its collection name in source_name
(or Spreadsheet when absent), and spreadsheet:<document-id> in source_id.
The workbook year supplies period when available; source_url is null.
document_title renders that source identity, including the year when available.
Workbook reads without a catalogue title and document ID retain the tool-name
fallback. Error and miss replies do not receive workbook citation metadata.
kind: "history" identifies conversation provenance. It carries turn_index
and null document_id / chunk_id, with no document title or section path.
turn_index is the original zero-based user exchange index, not an offset in
the bounded history window. A quotation of an assistant belongs to the exchange
started by that user turn and proves prior speech, not the quoted corpus fact.
History records remain in /query.claim_map and stored
messages[].metadata.synthesis.claims. Fresh flat /query.citations, SSE
citation frames, and stored source_citations contain only chunk/tool sources.
A verified pure-memory answer returns citations: [] with
provenance_complete: true; mixed answers retain their chunk/tool sources.
Older stored messages may still contain history in source_citations. Skip
these records in document cards, source numbering, and copy/print endnotes.
kind: "memory" identifies a sentence that restates one of the caller's long-term
memories (ADR-0047). It carries memory_id and null document_id / chunk_id;
citation_key is "memory". Like history, it appears only in /query.claim_map and
the stored claims, never in citations, SSE citation frames or source_citations.
Render it, if at all, as "from what you told me earlier" and link to GET /memories,
not as a document. It appears only when long-term memory is enabled.
POST /query JSON answer
POST /query runs the full cited answer flow and returns one JSON object. It creates a new conversation when conversation_id is omitted. When conversation_id is present, the new user turn is appended to that existing conversation and recent conversation context is used to answer follow-up questions. Unknown conversations and conversations owned by another subject return 404.
Successful responses include Cache-Control: no-transform. Public gateways and
other compliant intermediaries must forward the JSON representation without
adding gzip, zstd, or another transformation.
A Government Policy Guard refusal is still a successful 200 JSON response: answer contains the refusal text, and citations may be empty or contain the sources used before the refusal decision. Use /query/stream when the UI needs a distinct guard.blocked event.
Retrieval reuse within one answer
During one /query or /query/stream answer, search_documents retrieves each
identical search arm once and reuses its ranked result. Concurrent tool calls
share the same in-flight retrieval. The original-question arm runs the hybrid
path without its keyword leg (semantic retrieval, document-label and follow-up
nominations, rerank), preserving broad discovery alongside the agent's wording
without a keyword search of the whole sentence. When no other arm of the call keeps a
keyword leg, the original-question arm keeps its own.
Reuse requires identical formulation text, retrieval mode, principal/access fields, and collection/document scope, including exclusions and follow-up context. The store belongs to one answer: it is not shared across turns, conversations, users, or requests. Ranking, fusion, reranking, top-k, returned passages, and evidence de-duplication keep their existing behavior.
Failed, timed-out, or cancelled retrievals are not stored; a later call searches
again. Cancelling one caller leaves retrieval running for remaining waiters;
when no waiter remains, unfinished retrieval is cancelled and the caller returns
without waiting for the cancelled read to unwind. The existing tool budget and
timeout handling still applies. On the database server, the tool deadline bounds
each statement of the lexical read and the single semantic statement to the time
remaining (SET LOCAL statement_timeout, re-issued before every statement), and
no further statement starts once the deadline has passed. The embedding and
rerank calls are cancelled at the deadline, not bounded. A consumed arm emits a
search_documents.arm Langfuse span with reused, even if a sibling arm later
fails.
Documents indexed or changed mid-answer are not seen by an arm whose result is
already stored. A different formulation can retrieve again, and a new answer
starts with an empty store. Raw POST /search requests do not use this
within-answer store. Offline coverage is in
tests/unit/test_search_original_arm_reuse.py.
What the keyword search tells the agent about the match
The agent is instructed to use several focused searches of one to four distinctive terms instead of one long conjunction, reusing the bounded legal-query planner.
search_documents reports the lexical tier's decision before the passages. All-words fill
means that tier filled its candidate limit with passages containing every query term;
the reported count is a lower bound. PARTIAL means only the reported N passages
contained every term, so the tier fell short. NONE means no passage contained every
term. In both shortfall cases, the any_word_or fallback supplied KEYWORD results,
including passages matching only some terms. That fallback selects passages on the
query's less common terms only: a term the database statistics place in at least 5% of
passages by default (for example tahun or aceh) still ranks the passages it appears
in but does not add passages on its own; when the less common terms match nothing, every
term is used (ADR-0021). These labels do not describe term coverage
or relevance of every fused result. The agent rewrites its query rather than treating
partial matches as confirmation of the complete query. The response also states how many
passages were returned, and marks any passage cut at the tool's snippet budget.
If the planner omits the model's query as a bare instrument cue, or the backend supplies
no tier decision, the response omits the match label rather than guessing it.
The retrieval itself is unchanged: the fallback still runs on the same trigger, against
the same index, and the per-arm tier decision is still recorded in the answer trace
(lexical_arms[]). Only what the agent is told about it is new.
/query/v2: searching across documents, and inside one
On /query/v2 only, the agent may also call search_in_document(document_id, query). It searches one
document that an earlier search for the same question already returned, keeping the caller's
access and scope. Its first line says how many of that document's passages contain every term,
so the agent can settle "does this regulation say X?" in one call instead of reading it article
by article. A document_id that no search has returned is refused.
On /query/v2, a search counts as progress only when it returns a document not seen before
for the same question. Two tool rounds in a row that bring nothing new end the search phase,
and the agent answers with what it found and says which part is missing. A question scoped to
selected documents keeps the earlier rule, where any new passage counts as progress, because
reading those documents article by article is the job. That rule covers document search only: an OpenData or
BPS miss still falls back to the documents. For a question about a topic in a place, the agent
is asked to send two or three different searches in its first round (ADR-0051, amended
24 Oct 2026). A search that matched none of its terms is rewritten before the agent answers,
even when another search in the same round matched (amended 25 Oct 2026). /query is unchanged.
/query/v2: when an indexed regulation revokes or amends a source
A deploy can turn this on with ACEH_RAG_LEGAL_RELATIONS__SURFACE_ENABLED=true. It is off
by default and applies to /query/v2 only (ADR-0053, Proposed). When an indexed regulation
says it revokes, supersedes or amends a document a search returned, that passage's source
identity carries legal_status (revoked, superseded or amended). It also carries a
legal_status_basis that names who says so: "dicabut oleh <regulation> <Pasal> (sejak
<date>)", "digantikan oleh …", or "diubah oleh … (ketentuan lain tetap berlaku)". The
revoking clause, up to two per search, is added to the results as an ordinary citable
passage. The agent attributes the status to that regulation and cites its clause. For what
applies now it takes the rule from the newer regulation. For a past period it answers from
the old one and says when it was revoked. Nothing is filtered or demoted. A document no
indexed regulation speaks about stays legal_status="unknown". That is not a statement
that it is in force. The response and citation shapes are unchanged.
Named regulations
When a question names an instrument by type, number, and year, the answer service
accepts forms such as Qanun Aceh Nomor 6 Tahun 2014 and Qanun 6/2014, including
sentence-ending punctuation after the year. It resolves the primary citation at
the start of an indexed title, including hyphenated or underscored title slugs,
and rejects conflicting type, number, or year in a stored document-identity card.
Amendments and implementing regulations that only reference the named regulation
do not match its identity.
Citation numbers are compared case-insensitively, including letter suffixes.
Explicitly named jurisdictions distinguish instruments with the same number and
year. Resolution requires one source identity (source_key, or document ID when
absent) with exactly one non-superseded record. Reaching the four-match browse
limit is treated as ambiguous rather than choosing a document.
Before the first agent request, the service uses the stored normative
legal_structure card and indexed chunk references to retrieve explicit Pasal,
ayat, and angka evidence. Locators belong to their own law's citation, whether
the article comes before or after the law. Missing or partial matches, uncertain
cross-law ownership, and partially parsed item lists or article ranges retain
available exact evidence and trigger document-scoped search. Without an explicit
locator, the question goes directly to document-scoped search. Selecting exactly
one visible indexed document through document_ids uses the same retrieval path
when the question names no instrument.
Both paths also retain bounded closing/signature evidence, including neighboring chunks for split signature blocks, alongside article or ranked evidence. Named documents retain separate signature evidence and citation labels even when their closing text is identical. Closing chunks are located in the database and only their spans are read, rather than materializing the entire document.
Unknown, ambiguous, or unindexed anchors and empty scoped searches preserve ordinary retrieval; an anchor miss alone is not a not-found answer. The identity lookup is bounded: a lookup that hits its row cap or exceeds its 10-second deadline also falls back to ordinary retrieval rather than choosing a document. All reads, including fallback, retain the caller's access, collection, and document scope. The model can still search when preloaded evidence is insufficient. If required evidence cannot fit the prompt budget, the service returns its bounded budget refusal. Fresh scoped anchors satisfy the current-turn evidence requirement without a model-selected search; retained conversation passages alone do not.
The corpus_facts tool accepts a case-insensitive literal title substring filter
before aggregation, digit-shaped year strings, and recognized display-form
instrument types such as Peraturan Gubernur Aceh. Signatory requests normalize to
role-preserving groups, including requests using count/list/compare or an
explicit signatory grouping. Roster coverage reports promulgation-card status
and documents carrying readable officer names; missing or partial rosters are
reported as lower bounds, and an entirely unreadable roster is unavailable.
Ordinary document counts remain exact without requiring extracted fact cards.
The offline comparison is reproducible with
uv run python scripts/measure_named_anchor_retrieval.py; it runs the production
answer service with a fake agent, compares ordered
chunk identities and complete evidence text on one fixture, and fails on
non-named changes. The baseline disables only the pre-agent anchor resolution.
Evidence too large before generation
Required initial evidence that cannot fit returns HTTP 200 with
fallback_reason="context_budget_exhausted", a fixed context-capacity notice,
citations: [], and faithfulness_checked: false. The SSE faithfulness frame
and stored metadata.synthesis.faithfulness use checked: false. Display this
as verification not reached, not a rejected claim or a low-confidence generated
answer. Missing/null checked values on other paths make no new verification claim.
The existing false provenance_complete and true fallback_used values remain.
True time/model-call exhaustion retains its existing timeout reason and notice.
Refusal metadata includes synthesis.prompt_budget: initial_passages,
initial_source_chars, grounding_char_limit, the existing dynamic token pool,
and trimmed/dropped counters. The initial source count excludes source labels;
grounding_chars_used includes the framing actually recorded.
See the partner display contract.
Agent output limit
Every agent model round on /query and /query/stream has an output-token ceiling.
A round that reaches it was cut short, so its text is never published: the response is
HTTP 200 with fallback_reason="agent_output_limit", a fixed notice asking the reader to
ask about one part of the question, citations: [] and fallback_used: true. A round
whose input leaves too little of the model's window to write in is instead the
context_budget_exhausted refusal described above. It is not a verdict on the documents, and retrying the same question gives the same
result. The reason is distinct from query_budget_exhausted so operators can tell a
runaway decode from a spent budget.
Document follow-ups
On /query and /query/stream, a genuine follow-up that names no document can
carry the preceding document into retrieval. For example, after asking who signed
Peraturan Gubernur Aceh Nomor 60 Tahun 2013, “siapa sekretaris daerah aceh nya”
still reaches that regulation's passages, including when the model writes a shorter
search_documents query or retrieval expands it into several searches. The
document is carried by id, not by title: every search runs the model's and the
user's own wording, and each hybrid search ranks the inherited documents ahead of
its own document-label matches, so the bounded label-scoped lookup can admit
their passages. Appending the titles instead added about 60 lexemes on a six-title
follow-up, which defeated the all-words lexical tier and sent the any-word tier
across most of the corpus.
Clients using the shared answer service, including Studio and partner clients,
receive the same behavior.
For ordinary answers in caller-owned, unshared chats, the resolver sees bounded first and recent exchanges, not just the immediately preceding answer. Its strict verdict selects known exchange IDs; those exchanges' published citations bind current document IDs even when different documents have the same title. Inherited document ids are retrieval hints, not filters or access grants: the caller's access filter and source scope are rechecked on every passage they admit. Broad inherited title sets do not become mandatory named-document anchors. One unambiguous model-resolved document retains exact article and closing anchors; otherwise the current question (or explicit single-document scope) requests those anchors; that one title names the anchor document, but the anchor's searches run the question alone. Prior cited passages are restored separately; supplemental searches still nominate the selected documents. This prevents a broadly cited previous answer from forcing closing/signature scans across every cited regulation before generation.
Persisted chunk IDs and visible lengths rehydrate current indexed bodies, including read headers from published documents only within their persisted visible prefixes. Restoration never completes a source, chooses the longest prior version, or uses a public citation to enlarge a packet. Missing evidence requires fresh retrieval. Metadata's complete table snapshots do not bypass chunk eligibility.
The persisted metadata.synthesis.source_passages packet supplies document_id,
chunk_id, and a positive integer visible_chars; missing or invalid bounds require
fresh retrieval. Public citations supply identity hints only. Independently retrieved
fresh anchors keep their current bodies under the existing budget. Exports and shared
chats receive no restored packets; see the resume workflow
for the memory scope and conversation-only recall.
New request-local labels preserve normal citation, faithfulness, and Policy Guard checks. Assistant prose and previously computed values are not corpus evidence.
Already sufficient source passages do not require another search. Supplemental searches retain the caller's collection/document union and exclusions; source reuse does not restrict every future search to the earlier document.
A separate self-hosted semantic judge must return a strict boolean true and
known exchange IDs before sources are carried. First turns, questions naming their
own document, and named
document switches keep their searches unchanged. Independent questions such as
“berapa persentase penduduk miskin Aceh 2024” should receive no carry; live-data
tool keywords are not rewritten. The classifier has a five-second ceiling inside
the shared query budget (90 seconds by default), and is skipped when too little
time or too few model calls remain. Unavailable, malformed, nonboolean, or timed
out classification leaves the original query unchanged.
Search tool audit entries in stored assistant-message
metadata.synthesis.tool_call_audit retain proposed_arguments and
executed_arguments (now identical: no search query is rewritten), plus
followup_document_ids when the follow-up carried documents. At most six documents
are carried, most recent exchange first: round-2 follow-ups never carried more, and
each extra one lowers the query's own label hits. The answer trace's
followup_documents records each one, whether it reached the rerank pool, and whether
it was cited (ADR-0046). This does not add a top-level query response field.
Hermetic coverage is in tests/unit/test_followup_document_referent.py and
tests/unit/test_followup_source_continuity.py; it does not measure live-model
classification or answer quality. This is bounded source continuity and record
recall, not general semantic memory or autocompaction.
Answering figure and count questions
The request and response shape is unchanged, but the answer path handles figure-, count-, and quantity-shaped questions more directly than before:
- The faithfulness check that runs before an answer is returned is figure-aware (#209, #216). Numeric answers — short numbers, dates, and figures that appear differently across languages — are no longer dropped by a naive word-overlap check, so a grounded figure question is answered and cited rather than over-refused.
- The Planner selects the read-only SQL Query tool only when a structured data source is actually configured for the deployment (#223). When no structured source is configured, a quantity-shaped question is answered from the document corpus by hybrid retrieval instead of being routed to an unavailable tool — so it returns a cited answer rather than a refusal.
- OpenData and SatuData figures are read query-specifically from allow-listed CKAN portals and grounded with dataset identity plus read time.
- BPS Statistics reads authoritative indicators from the allow-listed BPS WebAPI when the BPS feature is enabled and keyed.
These are answer-quality behaviors inside the pipeline. They do not add or rename request fields. OpenData/SatuData and BPS values are live tool groundings only when their feature flags, allow-lists, and (for BPS) application key are configured; otherwise the tool returns an honest unavailable/not-enabled note.
Request fields
| Field | Type | Required | Constraints | Meaning |
|---|---|---|---|---|
query | string | Yes | Minimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400. | User question. |
user_id | string (UUID) | No | Must be a valid UUID when supplied; defaults to the all-zero sentinel UUID. | Development fallback subject only when trusted auth is not required; integrated clients should rely on trusted principal headers. |
conversation_id | string (UUID) or null | No | Must be non-empty when supplied. | Existing conversation to resume. Omit or set null to start a fresh conversation. |
collection_ids | string array or null | No | Omitted, null, [], or an array that normalizes to no nonblank IDs means all accessible collections; nonblank IDs are trimmed and deduplicated before scoping. | Optional retrieval scope. |
document_ids | UUID string array or null | No | Omitted, null, or [] means all accessible documents. Each supplied value must be a valid UUID; values are canonicalized and deduplicated before scoping. | Optional selected-document retrieval focus; intersects with collection_ids and access filtering. |
Request JSON
{
"query": "Ringkas aturan transparansi anggaran Aceh dan beri sumbernya.",
"user_id": "00000000-0000-0000-0000-000000000001",
"conversation_id": null,
"collection_ids": ["JDIH", "PPID"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}
Response fields
| Field | Type | Meaning |
|---|---|---|
answer | string | Final guarded answer or guard refusal text. |
conversation_id | string | Conversation ID for follow-up turns; timeout persistence exceptions follow ADR-0041. |
message_id | string | Stored assistant message ID; timeout persistence exceptions follow ADR-0041. |
trace_id | string or null | Opaque 32-character Langfuse trace ID stored with the assistant message. null means tracing was disabled or unavailable; the answer remains valid. |
history_truncated | boolean | true when some prior user exchanges are absent from the history window used for this answer. Derived from the stored exchange ordinals and actual row, character, and prompt-token trimming, including later evidence eviction and any retained first exchange. |
history_exchanges_in_view | optional non-negative integer | Number of prior question/answer pairs remaining in that window. Excludes the current question/answer; zero for a new chat. Refusals before prompt planning report the window actually loaded under the row and character caps. This is not the configured maximum. If history loading has not completed, the count is absent (never null) and history_truncated is false. |
citations | citation array | Source citations for the answer. See Shared citation object. |
claim_map | object array | Surviving claims and their labelled citation references, including conversation provenance; see Shared citation object. |
provenance_complete | boolean | Verification verdict for the published factual text; see the faithfulness gate contract. An empty flat citation list alone does not imply incomplete provenance. |
fallback_used | boolean | Whether the faithfulness/no-answer path used a fallback answer. |
fallback_reason | string | null | Publication reason; partial_answer is an opt-in published answer with fallback_used=false, distinct from a complete answer (null) and a refusal (production renders partial answers OFF — ACEH_RAG_SYNTHESIS__PARTIAL_ANSWERS_ENABLED=false, deployed ref 2cc9a417). The partial answer text includes the omitted-parts note. See the authoritative reason table and QueryResponse in src/aceh_rag/api/app.py for the full contract. |
dropped_claims_count | integer | Number of unsupported claims removed before the guarded answer was returned. |
claims_supported | integer | Claims the gate verified against a source passage, counted even when the answer is later withheld or refused. |
claims_total | integer | claims_supported plus the claims that failed against a source: every claim checked. Not claims_supported + dropped_claims_count; see Claim grounding rate. |
grounding_rate | number | null | Claim grounding rate: claims_supported / claims_total, from 0 to 1 at four decimals; null when no claim was checked. See Claim grounding rate. |
Claim grounding rate
grounding_rate is the share of the answer's checked claims that a cited source supports:
the claims the gate verified, out of those plus the claims that failed against a source. Show it as a
percentage with the counts, for example "78% · 7/9 klaim" (Bahasa Indonesia label:
Tingkat keterverifikasian klaim). When it is null nothing was checked (no_claims,
no_answer, empty_completion, a refusal before the gate): show "—", never 0%. A claim
that restates the user's earlier turn or a stored memory is verified against no source, so
it is in neither count, whether kept or dropped. An unused verbatim excerpt the gate drops
(surplus_excerpt) failed nothing, so it is not counted either; dropped_claims_count
still includes both, which is why claims_total can be less than
claims_supported + dropped_claims_count. The rate describes what the gate checked, not what is shown: when
an answer is withheld (coverage_incomplete) or replaced by a Policy Guard refusal, the
claims the gate verified still count, so the rate reads "1 of 2 verified" rather than 0.
A client may hide the badge on a refusal; the quality review reads it as root-cause evidence.
It is a per-answer runtime signal, not the faithfulness figure. The client's
faithfulness numbers come from the independent claim-level evaluation pass
(ADR-0042).
The same three fields are on the SSE faithfulness frame, on /query/v2, and stored in
metadata.synthesis.faithfulness of the assistant message.
:::note Contract change (3 Nov 2026)
claims_supported, claims_total and grounding_rate are additive: no existing field
changed.
:::
When history_truncated is true, show a separate notice beside the answer asking
the user to restate earlier points or start a new chat. Use
history_exchanges_in_view for the visible-question count; do not hard-code five.
The count can include a pinned first exchange, so it need not describe the
contiguous last N questions. These fields are display metadata, not answer
text, citations, a memory summary, or a faithfulness verdict.
With the default ten-message history cap, the sixth answer can still see all five prior exchanges; the seventh normally sees five of six and reports truncation. Character and token caps can make the notice appear sooner. Raising the server's history caps changes the notice without a frontend edit.
Unlabelled answers are not automatically published. no_claims now means the
model confirmed that the answer asserts nothing needing source verification; it
is shown and marked. Factual unlabelled answers are checked against the shown
citable passages and published with collective citations only when supported.
unverified_no_claims withholds the original text when classification or support
fails, including judge unavailability. Missing passage labels never skip verification.
unparseable_completion withholds invalid model output; the raw failure evidence
stays private, including on conversation replay. A format refusal prevents new
artifact creation and attachment for that turn. unresolved_citation withholds an
answer whose claims have no resolvable provenance. Studio explains both reasons
and unverified_no_claims in Bahasa Indonesia and English. The
gate reference
owns the structural boundary; its module docstring defines the prose/code rule.
That reference also owns the evidence-backed native output contract and the
no-evidence text path. The
inline answer contract
owns the labelled prose and exact citable-row rules.
Response JSON
{
"answer": "Aturan transparansi anggaran mewajibkan publikasi informasi anggaran dan pelaporan yang dapat diakses masyarakat [chunk:00000000-0000-0000-0000-000000000991].",
"conversation_id": "00000000-0000-0000-0000-000000000123",
"message_id": "00000000-0000-0000-0000-000000000456",
"trace_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"history_truncated": false,
"history_exchanges_in_view": 0,
"citations": [
{
"document_id": "00000000-0000-0000-0000-000000000042",
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": "00000000-0000-0000-0000-000000000991",
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088
}
],
"fallback_used": false,
"fallback_reason": null,
"dropped_claims_count": 0,
"claims_supported": 1,
"claims_total": 1,
"grounding_rate": 1.0
}
curl
curl --fail-with-body \
--request POST \
"$API_BASE/query" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'
POST /query/stream Server-Sent Events
POST /query/stream accepts the same request body as /query and has the same setup status/error behavior before the first progress frame. Tool progress can arrive while the agent runs; generated answer text is revealed only after faithfulness and Government Policy Guard checks complete.
Answer text uses buffer-then-reveal:
- No unapproved draft text is streamed.
answer.deltaframes are emitted only after the guard passes.- If the guard blocks, no
answer.deltaframe is emitted. tool.startedandtool.finishedcan precedemessage.start. Forcreate_artifact, progress exposes onlycall,tool, and, on completion,okandms. Arguments, content, claims, and result/refusal summaries are withheld at the observer boundary, including for saved artifacts. Artifact results are internal generation feedback and do not become grounding orpassageframes. Other tools retain their structured arguments and result summaries.- There are no heartbeat frames; allow for long gaps between progress events.
- Setup failures return normal HTTP JSON errors before the
text/event-streambody starts.
Request fields and JSON
The request schema is identical to POST /query.
{
"query": "Apa ketentuan lanjutan dari aturan itu?",
"user_id": "00000000-0000-0000-0000-000000000001",
"conversation_id": "00000000-0000-0000-0000-000000000123",
"collection_ids": ["JDIH"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}
Response headers
Successful streams return 200 with Content-Type: text/event-stream. The server also sets Cache-Control: no-cache, no-transform, X-Accel-Buffering: no, and Connection: keep-alive to prevent intermediary buffering and transformations such as compression.
Huawei APIG or any other gateway in front of the API must pass text/event-stream responses through immediately, without compression or buffering. The production Caddy edge excludes /query and /query/* from response compression. Gateway timeouts must allow the full multi-step answer pipeline, terminal refusal handling, and gaps between progress frames.
SSE frame format
Each event is framed as:
event: <event-name>
data: <single-line JSON object>
The data: payload is JSON. Clients should parse events in wire order and should not reorder citations or deltas.
Event order and terminal semantics
A successful stream follows exactly one of these paths:
message.start
trace optional
citation zero or more times
passage zero or more times
answer.delta zero or more times
faithfulness
done
or, when the Government Policy Guard blocks the generated answer:
message.start
trace optional
citation zero or more times
guard.blocked
done
Either path can begin with live tool.started / tool.finished events. A failure after streaming starts sends terminal error instead of done; treat it as terminal and stop reading.
| Event | Data shape | When it appears | Client behavior |
|---|---|---|---|
tool.started / tool.finished | Progress object with call, tool; completion adds ok, ms. Artifact progress omits arguments and result text. | While tools run, before message.start. | Update progress; do not treat ok as a publication verdict. |
message.start | {"conversation_id": string, "message_id": string} | After live progress, if any. | Store IDs for the current assistant message and future resume, subject to the timeout persistence contract. |
trace | {"tools": array, "completed": true} | After message.start when tools were used. | Show the completed tool trace. |
citation | Citation object | Zero or more times after message.start and before answer or block events. | Attach citations to the pending assistant message in received order. |
answer.delta | {"text": string} | Zero or more times only when the guard passes. | Append text to the visible answer buffer. |
passage | Grounding object with kind and text; chunk passages include chunk_id, source_type, score; tool passages include tool, source_url, the full citation object (including call identity), and citation_key (the passage label). | Before deltas, only when the guard passes. | Attach supporting passages to citations by identity and label; a shared source URL alone cannot distinguish repeated tool invocations. |
faithfulness | {"dropped": number, "claims_supported": number, "claims_total": number, "grounding_rate": number or null, "fallback_used": boolean, "fallback_reason": string or null} | After deltas, only when the guard passes. | Show gate metadata and the claim grounding rate; dropped claim text is withheld. |
guard.blocked | {"reason": string} | Instead of answer.delta when the guard blocks. | Show a refusal/block state using reason; do not render an answer buffer. |
done | {"finish_reason": "stop" or "guard_blocked", "history_truncated": boolean, "history_exchanges_in_view"?: integer} | Normal terminal event, including guard blocks and timeout refusals. | Finalize the message and show a separate history-window notice when flagged; see the JSON response fields above. |
error | {"code": string, "message": string} | Terminal failure after streaming starts; codes include query_failed, http_<status>, and stream_failed. | Surface an error and stop; do not wait for done. |
Guarded answer frames (excerpt)
event: message.start
data: {"conversation_id":"00000000-0000-0000-0000-000000000123","message_id":"00000000-0000-0000-0000-000000000457"}
event: citation
data: {"document_id":"00000000-0000-0000-0000-000000000042","document_title":"Qanun Aceh Budget Transparency","chunk_id":"00000000-0000-0000-0000-000000000991","chunk_ordinal":7,"page_number":12,"section_path":["Bab III","Pasal 8"],"legal_reference":"Qanun Aceh No. 1 Tahun 2024 Pasal 8","char_start":1534,"char_end":2088}
event: answer.delta
data: {"text":"Aturan transparansi anggaran mewajibkan publikasi informasi anggaran"}
event: answer.delta
data: {"text":" dan pelaporan yang dapat diakses masyarakat [chunk:00000000-0000-0000-0000-000000000991]."}
event: done
data: {"finish_reason":"stop","history_truncated":false,"history_exchanges_in_view":0}
Guard-blocked SSE transcript
event: message.start
data: {"conversation_id":"00000000-0000-0000-0000-000000000123","message_id":"00000000-0000-0000-0000-000000000458"}
event: guard.blocked
data: {"reason":"Permintaan tidak dapat dijawab karena melanggar kebijakan."}
event: done
data: {"finish_reason":"guard_blocked","history_truncated":false,"history_exchanges_in_view":0}
curl
Use curl --no-buffer to avoid client-side output buffering.
curl --no-buffer --fail-with-body \
--request POST \
"$API_BASE/query/stream" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--header "Accept: text/event-stream" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'
POST /search raw retrieval
POST /search returns ranked source chunks. It does not synthesize an answer, does not apply the generated-answer guard, and should not be shown to end users as if it were a chat answer. Use it for retrieval inspection, source pickers, citation previews, and debugging search relevance.
No matches is a successful 200 with results: []. This includes a well-formed document_ids value that is unknown, forbidden, inaccessible, or has no chunks matching the query; do not infer document existence or permissions from an empty result.
Request fields
| Field | Type | Required | Constraints | Meaning |
|---|---|---|---|---|
query | string | Yes | Minimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400. | Search text. |
mode | string | No | One of hybrid, semantic, lexical. Default hybrid. | Retrieval strategy. |
top_k | integer | No | Default 5; must be 1 through 50. | Maximum number of chunks returned after ranking. |
collection_ids | string array or null | No | Omitted, null, [], or an array that normalizes to no nonblank IDs means all accessible collections; nonblank IDs are trimmed and deduplicated before scoping. | Optional retrieval scope. |
document_ids | UUID string array or null | No | Omitted, null, or [] means all accessible documents. Each supplied value must be a valid UUID; values are canonicalized and deduplicated before scoping. | Optional selected-document retrieval focus; intersects with collection_ids and access filtering. |
Search modes
| Mode | Behavior | Use when |
|---|---|---|
hybrid | Combines semantic vector retrieval and lexical PostgreSQL full-text retrieval, then ranks the fused candidates. | Default for user-facing retrieval quality. |
semantic | Uses only semantic vector retrieval. | You need meaning-based matches without lexical fusion. |
lexical | Uses only full-text lexical retrieval. | You need exact term/statute/string matching behavior. |
Request JSON
{
"query": "transparansi anggaran",
"mode": "hybrid",
"top_k": 5,
"collection_ids": ["JDIH", "PPID"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}
Response fields
| Field | Type | Meaning |
|---|---|---|
mode | string | The mode used for the request. |
results | search result array | Ranked chunks. Empty when there are no visible matches. |
Search result object:
| Field | Type | Meaning |
|---|---|---|
chunk_id | string (UUID) | Source chunk ID. Mirrors citation.chunk_id. |
document_id | string (UUID) | Source document ID. Mirrors citation.document_id. |
ordinal | integer | Chunk ordinal within the document. |
text | string | Retrieved chunk text. |
score | number | Retrieval score for ranking. Higher-ranked results appear earlier. Do not compare scores across different modes as a stable API contract. |
citation | citation object | Full citation metadata. See Shared citation object. |
Response JSON
{
"mode": "hybrid",
"results": [
{
"chunk_id": "00000000-0000-0000-0000-000000000991",
"document_id": "00000000-0000-0000-0000-000000000042",
"ordinal": 7,
"text": "Pemerintah Aceh mempublikasikan informasi anggaran kepada masyarakat...",
"score": 0.8732,
"citation": {
"document_id": "00000000-0000-0000-0000-000000000042",
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": "00000000-0000-0000-0000-000000000991",
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088
}
}
]
}
curl
curl --fail-with-body \
--request POST \
"$API_BASE/search" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'
Status and error behavior
Standard error responses are JSON objects:
{"detail":"Invalid or missing API key"}
FastAPI request-validation errors use the standard 422 shape with detail as an array of validation items.
{
"detail": [
{
"type": "greater_than",
"loc": ["body", "top_k"],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {"gt": 0}
}
]
}
| Status | Applies to | Meaning | Body shape |
|---|---|---|---|
200 | /query | Answer returned. A guard refusal is still 200 with the refusal in answer. | QueryResponse |
200 | /query/stream | SSE stream started after setup completed. The stream terminates with done or error. | text/event-stream |
200 | /search | Search completed, including no matches. | SearchResponse with results: [] when empty |
400 | All three routes | Non-empty query string is whitespace-only after parsing. | {"detail":"Query must not be empty"} |
401 | All three routes | API key missing/invalid, required principal missing, or malformed principal when trusted auth is required. | {"detail":"Invalid or missing API key"}, {"detail":"Authenticated principal required"}, or {"detail":"Invalid authenticated principal"} |
404 | /query, /query/stream | Conversation ID is unknown or belongs to another subject; or no visible indexed content is available to answer, including when a well-formed document focus yields no matching chunks. | {"detail":"..."} |
413 | /query, /query/stream | The answer context would exceed the model context window. | {"detail":"..."} |
422 | All three routes | JSON schema validation failed, such as query: "", invalid enum, malformed document_ids entry, conversation_id: "", top_k outside 1..50, missing query, or extra fields on /search. | {"detail":[...]} |
502 | /query, /query/stream | Model or orchestration dependency failed; for timeout and budget refusals, see ADR-0041. | {"detail":"..."} |
502 | /search | Model dependency failed, such as an embedding or reranker ModelClientError. Unhandled repository faults are not mapped by this handler. | {"detail":"..."} for model dependency failures |
For /query/stream, failures before the first progress frame (400, 401, 404, 413, 422, 502) return HTTP JSON errors. Once a 200 text/event-stream body starts, normal completion is represented by done; a pipeline or framing failure is represented by terminal error instead of done.
Production TypeScript client
These helpers are intended for a trusted server, BFF, or same-origin route that can read the API key from server-side configuration. Do not ship the shared API key to browser JavaScript.
type SearchMode = "hybrid" | "semantic" | "lexical";
type Citation = {
document_id: string;
document_title: string;
chunk_id: string;
chunk_ordinal: number;
page_number: number | null;
section_path: string[];
legal_reference: string | null;
char_start: number | null;
char_end: number | null;
confidential?: true;
owning_org_unit?: string;
min_position_level?: number;
};
type QueryRequest = {
query: string;
user_id?: string;
conversation_id?: string | null;
collection_ids?: string[] | null;
document_ids?: string[] | null;
};
type QueryResponse = {
answer: string;
conversation_id: string;
message_id: string;
trace_id: string | null;
history_truncated: boolean;
history_exchanges_in_view?: number;
citations: Citation[];
fallback_used: boolean;
fallback_reason: string | null;
dropped_claims_count: number;
claims_supported: number;
claims_total: number;
grounding_rate: number | null;
};
type SearchRequest = {
query: string;
mode?: SearchMode;
top_k?: number;
collection_ids?: string[] | null;
document_ids?: string[] | null;
};
type SearchResult = {
chunk_id: string;
document_id: string;
ordinal: number;
text: string;
score: number;
citation: Citation;
};
type SearchResponse = {
mode: SearchMode;
results: SearchResult[];
};
type ApiValidationItem = {
type?: string;
loc?: Array<string | number>;
msg?: string;
input?: unknown;
ctx?: Record<string, unknown>;
};
type ApiErrorPayload = { detail: string | ApiValidationItem[] };
class AcehApiError extends Error {
readonly status: number;
readonly detail: string | ApiValidationItem[] | undefined;
constructor(status: number, detail: string | ApiValidationItem[] | undefined) {
super(renderApiDetail(status, detail));
this.name = "AcehApiError";
this.status = status;
this.detail = detail;
}
}
type ClientOptions = {
baseUrl: string;
apiKey: string;
apiKeyHeader?: string;
principalHeaders?: {
subject: string;
role?: string;
unit?: string;
level?: number;
};
fetchImpl?: typeof fetch;
};
function apiHeaders(options: ClientOptions, accept?: string): Headers {
const headers = new Headers();
headers.set("Content-Type", "application/json");
if (accept) headers.set("Accept", accept);
headers.set(options.apiKeyHeader ?? "X-API-Key", options.apiKey);
const principal = options.principalHeaders;
if (principal) {
headers.set("X-User-Subject", principal.subject);
if (principal.role) headers.set("X-User-Role", principal.role);
if (principal.unit) headers.set("X-User-Unit", principal.unit);
if (principal.level !== undefined) headers.set("X-User-Level", String(principal.level));
}
return headers;
}
function endpoint(baseUrl: string, path: string): string {
return new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
}
async function readError(response: Response): Promise<AcehApiError> {
let detail: ApiErrorPayload["detail"] | undefined;
try {
const payload = (await response.json()) as Partial<ApiErrorPayload>;
detail = payload.detail;
} catch {
detail = response.statusText || undefined;
}
return new AcehApiError(response.status, detail);
}
function renderApiDetail(status: number, detail: ApiErrorPayload["detail"] | undefined): string {
if (typeof detail === "string" && detail.trim()) return `API ${status}: ${detail}`;
if (Array.isArray(detail)) return `API ${status}: ${JSON.stringify(detail)}`;
return `API ${status}`;
}
async function postJson<TResponse>(
options: ClientOptions,
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<TResponse> {
const fetcher = options.fetchImpl ?? fetch;
const response = await fetcher(endpoint(options.baseUrl, path), {
method: "POST",
headers: apiHeaders(options),
body: JSON.stringify(body),
signal,
});
if (!response.ok) throw await readError(response);
return (await response.json()) as TResponse;
}
export async function queryJson(
options: ClientOptions,
request: QueryRequest,
signal?: AbortSignal,
): Promise<QueryResponse> {
return postJson<QueryResponse>(options, "/query", request, signal);
}
export async function searchRaw(
options: ClientOptions,
request: SearchRequest,
signal?: AbortSignal,
): Promise<SearchResponse> {
return postJson<SearchResponse>(options, "/search", request, signal);
}
TypeScript SSE parsing with fetch() and ReadableStream
type QueryStreamEvent =
| { event: "message.start"; data: { conversation_id: string; message_id: string } }
| { event: "citation"; data: Citation }
| { event: "answer.delta"; data: { text: string } }
| { event: "guard.blocked"; data: { reason: string } }
| {
event: "done";
data: { finish_reason: "stop" | "guard_blocked" } &
Pick<QueryResponse, "history_truncated" | "history_exchanges_in_view">;
}
| { event: "error"; data: { code: string; message: string } };
type QueryStreamCallbacks = {
onStart?: (data: { conversation_id: string; message_id: string }) => void;
onCitation?: (citation: Citation) => void;
onDelta?: (text: string) => void;
onBlocked?: (reason: string) => void;
onDone?: (data: Extract<QueryStreamEvent, { event: "done" }>["data"]) => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
};
export async function streamQuery(
options: ClientOptions,
request: QueryRequest,
callbacks: QueryStreamCallbacks,
): Promise<{ conversationId: string; messageId: string; answer: string; citations: Citation[]; blockedReason?: string }> {
const fetcher = options.fetchImpl ?? fetch;
const response = await fetcher(endpoint(options.baseUrl, "/query/stream"), {
method: "POST",
headers: apiHeaders(options, "text/event-stream"),
body: JSON.stringify(request),
signal: callbacks.signal,
});
if (!response.ok) throw await readError(response);
if (!response.body) throw new Error("Streaming response did not include a body");
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("text/event-stream")) {
throw new Error(`Expected text/event-stream but received ${contentType || "no content-type"}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let conversationId: string | undefined;
let messageId: string | undefined;
let answer = "";
let blockedReason: string | undefined;
const citations: Citation[] = [];
const handleEvent = (frame: QueryStreamEvent): boolean => {
switch (frame.event) {
case "message.start":
conversationId = frame.data.conversation_id;
messageId = frame.data.message_id;
callbacks.onStart?.(frame.data);
return false;
case "citation":
citations.push(frame.data);
callbacks.onCitation?.(frame.data);
return false;
case "answer.delta":
answer += frame.data.text;
callbacks.onDelta?.(frame.data.text);
return false;
case "guard.blocked":
blockedReason = frame.data.reason;
callbacks.onBlocked?.(frame.data.reason);
return false;
case "done":
callbacks.onDone?.(frame.data);
return true;
case "error": {
const error = new Error(`${frame.data.code}: ${frame.data.message}`);
callbacks.onError?.(error);
throw error;
}
}
};
try {
for (;;) {
const { value, done } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
buffer = buffer.replace(/\r\n/g, "\n");
let boundary: number;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const rawFrame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
if (!rawFrame.trim() || rawFrame.startsWith(":")) continue;
const parsed = parseSseFrame(rawFrame);
if (!parsed) continue;
const terminal = handleEvent(parsed);
if (terminal) {
await reader.cancel().catch(() => undefined);
if (conversationId === undefined || messageId === undefined) {
throw new Error("Stream completed without message.start");
}
return { conversationId, messageId, answer, citations, blockedReason };
}
}
if (done) break;
}
} finally {
reader.releaseLock();
}
throw new Error("SSE stream ended before done or error");
}
function parseSseFrame(rawFrame: string): QueryStreamEvent | null {
let eventName = "message";
const dataLines: string[] = [];
for (const line of rawFrame.split("\n")) {
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (dataLines.length === 0) return null;
const data = JSON.parse(dataLines.join("\n")) as unknown;
switch (eventName) {
// This minimal answer renderer ignores progress and supporting metadata.
case "tool.started":
case "tool.finished":
case "trace":
case "passage":
case "faithfulness":
return null;
case "message.start":
case "citation":
case "answer.delta":
case "guard.blocked":
case "done":
case "error":
return { event: eventName, data } as QueryStreamEvent;
default:
throw new Error(`Unexpected SSE event: ${eventName}`);
}
}
A chat UI can render cited streaming answers by collecting citation events, appending answer.delta.text, replacing the answer area with guard.blocked.reason on a block, and treating either done or error as terminal.