Lewati ke konten utama

API Query, Query Streaming, dan Search

Gunakan endpoint berikut untuk membangun klien chat bersitasi dan pengambilan mentah:

EndpointTujuanJenis respons
POST /queryMenjalankan pipeline jawaban lengkap dan mengembalikan satu jawaban bersitasi sebagai JSON.application/json
POST /query/streamMenjalankan pipeline jawaban yang sama, lalu menampilkan hasil yang sudah melewati guard sebagai Server-Sent Events.text/event-stream
POST /searchHanya mengembalikan chunk sumber yang telah diurutkan, tanpa sintesis jawaban.application/json

Semua rute dipasang di root API. Edge produksi publik mem-proxy seluruh permukaan HTTP FastAPI, termasuk /healthz dan /readyz. Rute dokumentasi /docs, /redoc, dan /openapi.json tersedia saat api_docs_enabled=true, yang saat ini aktif di produksi. Halaman ini mendokumentasikan tiga endpoint query dan retrieval; lihat halaman referensi API lainnya untuk operasi percakapan, memori, ingestion, dan dokumen. Tidak ada rute query WebSocket.

Autentikasi dan integrasi browser​

Setiap rute di halaman ini memerlukan header API key yang dikonfigurasi. Nama header default adalah X-API-Key.

X-API-Key: <server-side API key>

Deployment yang membawa principal juga dapat menerima header identitas tepercaya dari gateway upstream atau backend-for-frontend:

HeaderTipeMakna
X-User-SubjectstringSubject pengguna terautentikasi yang stabil.
X-User-RolestringRole terautentikasi. Default ke user hanya ketika resolver tepercaya menerima perilaku fallback.
X-User-UnitstringUnit organisasi opsional untuk pemfilteran akses.
X-User-Levelinteger stringLevel posisi opsional untuk pemfilteran akses.

Aplikasi browser tidak boleh menyematkan shared API key dan tidak boleh membiarkan pengguna memalsukan header identitas X-User-*. Tempatkan panggilan ini di balik backend-for-frontend same-origin atau API gateway yang menyuntikkan key dan header identitas di sisi server. API tidak menambahkan perilaku CORS tingkat aplikasi untuk panggilan lintas origin dari browser.

Untuk edge produksi, klien sisi server mengirim X-API-Key dan X-User-Subject ketika mengidentifikasi pemanggil. Caddy mempertahankan subject tersebut tetapi menghapus X-User-Role, X-User-Unit, dan X-User-Level yang dikirim pemanggil. Pada request tanpa subject, Caddy menghapus semua header identitas dan tidak menyuntikkan identitas; FastAPI menerapkan kebijakan autentikasi yang dikonfigurasi. Contoh dengan key saja di bawah tidak mengidentifikasi pemanggil individual. Tetapkan URL dan baca kredensial tanpa menyimpannya dalam shell history sebelum menjalankan contoh curl di halaman ini:

export API_BASE='https://rag-aceh-ai-eng-neo.reviewstagepro.com'
read -rsp 'Production API key: ' API_KEY && export API_KEY && printf '\n'

EventSource native tidak dapat mengirim X-API-Key atau header identitas tepercaya, sehingga klien streaming harus menggunakan fetch() ditambah parsing ReadableStream, seperti ditunjukkan di bawah. Hapus kredensial setelah pengujian selesai dengan unset API_KEY API_BASE.

Konsep bersama​

Pembatasan collection​

/query, /query/stream, dan /search menerima collection_ids untuk mempersempit pengambilan ke collection mitra.

NilaiPerilaku
Field tidak disertakanMencari semua collection yang dapat diakses pemanggil.
nullSama seperti tidak disertakan.
[]Sama seperti tidak disertakan.
Array stringMemangkas setiap entri, membuang entri kosong, menghapus duplikasi ID yang tersisa sesuai urutan kemunculan pertama, lalu membatasi pengambilan ke irisan ID tersebut dan predikat akses pemanggil.
Array yang hanya berisi string kosongSama seperti tidak disertakan: setelah normalisasi tidak ada scope collection, sehingga semua collection yang dapat diakses dicari.
ID tidak dikenal atau terlarang yang tidak kosongMenghasilkan tanpa baris yang cocok untuk ID yang telah dinormalisasi. Ini bukan 403 dan tidak mengungkapkan apakah collection tersebut ada.

ID collection adalah string opak yang stabil. Contohnya mencakup nama collection mitra seperti JDIH, PPID, SatuData, dan OpenData, tetapi klien harus memperlakukannya sebagai ID yang dikonfigurasi, bukan enum yang lengkap.

Pembatasan dokumen​

/query, /query/stream, dan /search menerima document_ids untuk mempersempit pengambilan ke dokumen AI engine yang dipilih. Setiap nilai adalah UUID kanonis pada documents.id, bukan documentId registry ingestion mitra.

Mitra memperoleh UUID ini dari aiDocumentId dalam event status Kafka setelah persistensi, stored_document_id dalam status upload/ingestion, document_id dalam hasil /search, atau document_id dalam sitasi jawaban. Simpan nilai tersebut ketika pengguna memilih regulasi bernama atau dokumen lain untuk pertanyaan terfokus.

NilaiPerilaku
Field tidak disertakanMencari semua dokumen yang dapat diakses pemanggil.
nullSama seperti tidak disertakan.
[]Sama seperti tidak disertakan.
Array UUIDMengkanonisasi setiap UUID, menghapus duplikasi ID yang tersisa sesuai urutan kemunculan pertama, lalu membatasi pengambilan ke irisan dengan collection_ids jika ada dan predikat akses pemanggil.
UUID tidak validValidasi request gagal dengan 422; ID tidak valid adalah error input klien, bukan fokus tanpa hasil.
UUID yang formatnya benar tetapi tidak dikenal atau terlarangMenghasilkan tanpa baris yang cocok. Ini bukan 403 dan tidak mengungkapkan apakah dokumen ada atau dapat diakses.

Fokus dokumen yang tidak kosong adalah jaminan sumber jawaban: jawaban Agentic RAG hanya menggunakan chunk hasil pengambilan dari dokumen terindeks yang dipilih dan terlihat. Fokus ini tidak memilih tool SQL Query, OpenData, BPS, atau Excel Query, karena tool tersebut dapat membuat grounding sintetik dari data live atau file mentah di luar chunk dokumen yang dipilih.

Sumber tersimpan, himpunan kosong, dan MCP​

Workspace atau notebook menyimpan himpunan sumbernya sendiri, dan catatan itulah yang mengatur jawaban. Dua kasus kosong perlu dibedakan:

Himpunan sumber tersimpanmcp_source_idsPerilaku
Kosong secara eksplisit (whole_corpus: false, tanpa collection maupun dokumen)Ada sumber (di request, atau pilihan tersimpan percakapan)200, dijawab hanya dari MCP: tidak ada tool dokumen, spreadsheet, atau portal yang ditawarkan, dan tidak ada anchor retrieval.
Kosong secara eksplisit[], atau tidak disertakan/null tanpa pilihan tersimpan percakapan400 — "This workspace has no sources…" / "This notebook has no sources…".
Sumber yang tidak lagi dapat diresolusi (dihapus atau tidak terbaca)Ada sumber200, dijawab hanya dari MCP, dengan catatan tetap bahwa dokumen tersimpan sudah tidak tersedia.
Masih memiliki dokumenApa punPerilaku hari ini: scope dokumen tersimpan yang berlaku, dan sumber MCP publik yang dipilih dapat menambah bukti seperti dijelaskan pada query MCP.

Aturan MCP-only untuk himpunan kosong tidak memerlukan field request baru. Pertanyaan lanjutan boleh tidak menyertakan mcp_source_ids; pilihan MCP tersimpan percakapan yang berlaku. Jika resolusi MCP tidak menyisakan tool yang dapat dipakai, request mengembalikan 400, bukan menjawab dari korpus yang dikecualikan oleh himpunan kosong. Sumber MCP governed tetap ditahan di bawah source boundary.

Pada /query, /query/stream, /query/v2, dan /query/v2/stream, Owner dan setiap Partner workspace boleh menyimpan pilihan MCP chat, baik menetapkan maupun mengosongkannya, sama seperti mereka boleh mengganti himpunan dokumen tersimpan workspace. Pilihan tersimpan mengubah sumber bagi seluruh anggota chat. Viewer ditolak dengan 403: Viewer tidak dapat bertanya di chat itu sama sekali. Pilihan pada chat pribadi disimpan oleh pemiliknya.

Inventaris unggahan workspace​

Perbedaan jumlah unggahan workspace dan sumber yang dipilih, batas daftar judul, serta aturan akses dijelaskan dalam referensi inventaris kanonis berbahasa Inggris.

Objek sitasi bersama​

Kontrak field, contoh, dan aturan tampilan sitasi dikelola dalam referensi sitasi kanonis berbahasa Inggris. Gunakan kind untuk membedakan sitasi chunk, hasil tool (termasuk pembacaan workbook katalog), riwayat percakapan, dan memori jangka panjang sebelum merender sumber. kind: "memory" (ADR-0047) menandai kalimat yang menyatakan ulang memori pengguna; entri ini hanya ada di claim_map, tidak pernah di citations, dan bukan dokumen.

POST /query jawaban JSON​

POST /query menjalankan alur jawaban bersitasi lengkap dan mengembalikan satu objek JSON. Rute ini membuat percakapan baru ketika conversation_id tidak disertakan. Ketika conversation_id ada, giliran pengguna baru ditambahkan ke percakapan tersebut dan konteks percakapan terbaru digunakan untuk menjawab pertanyaan lanjutan. Percakapan yang tidak dikenal dan percakapan yang dimiliki subject lain mengembalikan 404.

Respons yang berhasil menyertakan Cache-Control: no-transform. Gateway publik dan perantara lain yang mematuhi standar harus meneruskan representasi JSON tanpa menambahkan gzip, zstd, atau transformasi lain.

Penolakan Government Policy Guard tetap merupakan respons JSON 200 yang berhasil: answer berisi teks penolakan, dan citations dapat kosong atau berisi sumber yang digunakan sebelum keputusan penolakan. Gunakan /query/stream ketika UI memerlukan event guard.blocked yang berbeda.

Bukti terlalu besar sebelum pembuatan jawaban​

Bukti awal wajib yang tidak muat menghasilkan HTTP 200 dengan fallback_reason="context_budget_exhausted", pemberitahuan kapasitas konteks, citations: [], dan faithfulness_checked: false. Frame SSE faithfulness dan metadata.synthesis.faithfulness tersimpan memakai checked: false. Tampilkan status belum diperiksa, bukan klaim ditolak atau jawaban model berkeyakinan rendah. Nilai checked yang tidak ada/null pada jalur lain tidak memberi kepastian baru bahwa pemeriksaan berlangsung. provenance_complete tetap false dan fallback_used tetap true. Kehabisan waktu/panggilan model tetap memakai alasan dan pemberitahuan timeout yang ada.

Metadata penolakan menyimpan synthesis.prompt_budget: initial_passages, initial_source_chars, grounding_char_limit, anggaran token dinamis, serta jumlah bagian yang dipotong/dibuang. Jumlah karakter sumber awal tidak mencakup label; grounding_chars_used mencakup framing yang benar-benar dicatat. Lihat kontrak tampilan partner.

Batas keluaran agen​

Setiap putaran model agen pada /query dan /query/stream memiliki batas token keluaran. Putaran yang mencapai batas itu terpotong, sehingga teksnya tidak pernah diterbitkan: respons berupa HTTP 200 dengan fallback_reason="agent_output_limit", pemberitahuan tetap yang meminta pembaca menanyakan satu bagian pertanyaan saja, citations: [] dan fallback_used: true. Putaran yang masukannya menyisakan terlalu sedikit ruang di jendela konteks model justru dijawab dengan penolakan context_budget_exhausted yang dijelaskan di atas. Ini bukan penilaian atas dokumen, dan mengulang pertanyaan yang sama memberi hasil yang sama. Alasan ini berbeda dari query_budget_exhausted agar operator dapat membedakan dekode yang tidak berhenti dari anggaran yang habis.

Pertanyaan lanjutan dokumen​

Untuk chat biasa milik pemanggil yang tidak dibagikan pada /query dan /query/stream, resolver self-hosted menerima pertukaran pertama dan terbaru yang dibatasi. Hasil harus berupa boolean true dan ID pertukaran yang dikenal. Sitasi yang dipublikasikan mengikat ID dokumen saat ini, termasuk ketika dua dokumen memiliki judul sama. Dokumen warisan dibawa lewat ID, bukan judul: setiap pencarian memakai kata-kata model dan pengguna sendiri, dan setiap pencarian hybrid menempatkan dokumen warisan di depan kecocokan label dokumennya sendiri sehingga lookup berbatas yang di-scope ke label dapat memasukkan bagiannya. Menambahkan judul ke teks kueri menambah sekitar 60 leksem pada pertanyaan lanjutan dengan enam judul, yang menggagalkan tier leksikal semua-kata dan membuat tier kata-mana-saja menelusuri sebagian besar korpus. ID warisan hanya petunjuk pencarian, bukan filter atau izin: filter akses dan scope sumber pemanggil diperiksa ulang pada setiap bagian yang dimasukkannya. Kumpulan judul warisan yang luas tidak otomatis menjadi anchor dokumen wajib. Satu dokumen yang dirujuk secara pasti oleh model tetap mendukung anchor Pasal/penutup; judulnya menamai dokumen anchor, tetapi pencarian anchor hanya memakai pertanyaan. Selebihnya, anchor mengikuti pertanyaan pengguna saat ini atau scope satu dokumen yang dipilih eksplisit. Kutipan terdahulu dipulihkan terpisah, dan pencarian tambahan tetap menominasikan dokumen terpilih. Dengan demikian, jawaban terdahulu yang mengutip banyak peraturan tidak memaksa pemindaian bagian penutup/tanda tangan setiap dokumen sebelum jawaban dibuat. Pencarian identitas peraturan yang disebut dibatasi: pencarian yang mencapai batas barisnya atau melewati tenggat 10 detik kembali ke pengambilan biasa, tanpa memilih dokumen.

ID chunk dan panjang kutipan tersimpan dipakai untuk membaca ulang isi indexed dengan akses dan scope sumber saat ini. Header yang sudah dibaca dari dokumen yang sitasi dokumennya dipublikasikan juga dipertahankan. Pemakaian ulang hanya membaca prefiks terlihat yang tersimpan, tanpa menambah bagian sumber atau memilih versi terpanjang. Bukti yang kurang memerlukan pengambilan baru. Snapshot tabel lengkap dalam metadata tidak dapat melewati pemeriksaan kelayakan chunk.

Paket metadata.synthesis.source_passages menyimpan document_id, chunk_id, dan visible_chars berupa integer positif; batas yang tidak ada atau tidak valid memerlukan pengambilan baru. Sitasi publik hanya memberi petunjuk identitas, bukan panjang kutipan. Anchor yang diambil secara independen tetap memakai isi terkini dalam anggaran yang ada. Ekspor dan chat bersama tidak menerima paket yang dipulihkan; lihat alur resume untuk scope memori dan pengingatan percakapan murni.

Kutipan yang sudah mencukupi tidak memerlukan pencarian ulang. Pencarian tambahan tetap memakai gabungan pilihan collection/dokumen dan pengecualian asli pemanggil, bukan pembatasan permanen ke dokumen terdahulu. Label baru tetap mengikuti pemeriksaan sitasi, faithfulness, dan Policy Guard; prosa atau nilai jawaban asisten terdahulu bukan bukti korpus.

Dokumen yang disebut secara eksplisit dan pertanyaan mandiri tidak mendapat carry baru. Judge memiliki batas lima detik dalam anggaran query bersama, secara default 90 detik; hasil tidak tersedia, tidak valid, nonboolean, atau timeout mempertahankan query asli. Kata kunci tool data live tidak ditulis ulang. Audit tool menyimpan proposed_arguments dan executed_arguments (kini identik: tidak ada kueri pencarian yang ditulis ulang), serta followup_document_ids bila pertanyaan lanjutan membawa dokumen, dalam metadata.synthesis.tool_call_audit, tanpa field respons tingkat atas baru. Paling banyak enam dokumen dibawa, dimulai dari pertukaran terbaru: pertanyaan lanjutan pada putaran kedua tidak pernah membawa lebih, dan setiap dokumen tambahan menurunkan peringkat kecocokan label kueri sendiri. followup_documents pada answer trace mencatat setiap dokumen, apakah masuk pool rerank, dan apakah dikutip (ADR-0046). Ini bukan memori semantik umum atau autocompaction.

Status pencocokan pada hasil pencarian kata kunci​

Agen diarahkan untuk menjalankan beberapa pencarian terfokus dengan satu hingga empat istilah khas, alih-alih satu konjungsi panjang, menggunakan perencana kueri hukum berbatas yang sudah ada.

search_documents melaporkan keputusan tier leksikal sebelum bagian hasil. All-words fill berarti tier tersebut memenuhi batas kandidat dengan bagian yang memuat seluruh istilah kueri; jumlah yang dilaporkan merupakan batas bawah. PARTIAL berarti hanya N bagian yang dilaporkan memuat seluruh istilah, sehingga tier belum memenuhi batasnya. NONE berarti tidak ada bagian yang memuat seluruh istilah. Dalam kedua kasus kekurangan itu, fallback any_word_or memasok hasil KEYWORD, termasuk bagian yang hanya cocok dengan sebagian istilah. Fallback itu memilih bagian hanya dengan istilah kueri yang kurang umum: istilah yang menurut statistik basis data muncul di sedikitnya 5% bagian secara default (misalnya tahun atau aceh) tetap ikut menentukan peringkat bagian yang memuatnya, tetapi tidak menambahkan bagian dengan sendirinya; jika istilah yang kurang umum tidak cocok dengan apa pun, semua istilah dipakai (ADR-0021). Label ini tidak menyatakan cakupan istilah atau relevansi setiap hasil gabungan. Agen menulis ulang kueri alih-alih menganggap kecocokan sebagian sebagai konfirmasi kueri lengkap. Respons juga menyebutkan jumlah bagian yang dikembalikan dan menandai bagian yang dipotong pada batas snippet alat. Jika perencana mengabaikan kueri model karena hanya berupa petunjuk identitas peraturan, atau backend tidak menyediakan keputusan tier, respons tidak menampilkan label kecocokan alih-alih menebaknya.

Pengambilan itu sendiri tidak berubah: fallback tetap berjalan pada pemicu yang sama, terhadap indeks yang sama, dan keputusan tier per arm tetap tercatat pada answer trace (lexical_arms[]). Yang baru hanyalah apa yang diberitahukan kepada agen.

/query/v2: mencari di banyak dokumen, dan di dalam satu dokumen​

Khusus di /query/v2, agen juga dapat memanggil search_in_document(document_id, query). Alat ini mencari di dalam satu dokumen yang sudah dikembalikan oleh pencarian sebelumnya untuk pertanyaan yang sama, dengan tetap memakai akses dan cakupan pemanggil. Baris pertamanya menyebutkan berapa bagian dokumen itu yang memuat semua istilah, sehingga agen dapat memastikan "apakah peraturan ini mengatur X?" dalam satu panggilan, alih-alih membacanya pasal demi pasal. document_id yang belum pernah dikembalikan oleh pencarian akan ditolak.

Di /query/v2, sebuah pencarian dihitung sebagai kemajuan hanya jika mengembalikan dokumen yang belum pernah muncul untuk pertanyaan yang sama. Dua putaran alat berturut-turut yang tidak membawa hal baru mengakhiri tahap pencarian, lalu agen menjawab dengan apa yang ditemukannya dan menyebutkan bagian yang tidak ditemukan. Pertanyaan yang cakupannya dibatasi pada dokumen pilihan tetap memakai aturan sebelumnya, yaitu setiap bagian baru dihitung sebagai kemajuan, karena membaca dokumen itu pasal demi pasal memang tugasnya. Aturan ini hanya berlaku untuk pencarian dokumen: jika OpenData atau BPS tidak menemukan data, agen tetap beralih ke dokumen. Untuk pertanyaan tentang suatu topik di suatu daerah, agen diminta mengirim dua atau tiga pencarian yang berbeda pada putaran pertamanya (ADR-0051, diubah 24 Okt 2026). Pencarian yang tidak cocok dengan satu pun istilahnya ditulis ulang sebelum agen menjawab, meskipun pencarian lain pada putaran yang sama cocok (diubah 25 Okt 2026). /query tidak berubah.

/query/v2: ketika peraturan yang terindeks mencabut atau mengubah sebuah sumber​

Deploy dapat menyalakan fitur ini dengan ACEH_RAG_LEGAL_RELATIONS__SURFACE_ENABLED=true. Bawaannya mati, dan fitur ini hanya berlaku di /query/v2 (ADR-0053, Diusulkan). Jika sebuah peraturan yang terindeks menyatakan mencabut, menggantikan, atau mengubah dokumen yang dikembalikan pencarian, identitas sumber bagian itu memuat legal_status (revoked, superseded, atau amended). Identitas itu juga memuat legal_status_basis yang menyebut siapa yang menyatakannya: "dicabut oleh <peraturan> <Pasal> (sejak <tanggal>)", "digantikan oleh …", atau "diubah oleh … (ketentuan lain tetap berlaku)". Pasal pencabutnya, paling banyak dua per pencarian, ditambahkan ke hasil sebagai bagian biasa yang dapat dikutip. Agen menyandarkan status itu pada peraturan tersebut dan mengutip pasalnya. Untuk ketentuan yang berlaku sekarang, agen mengambil aturan dari peraturan yang lebih baru. Untuk periode lampau, agen menjawab dari peraturan lama dan menyebutkan sejak kapan peraturan itu dicabut. Tidak ada yang disaring atau diturunkan peringkatnya. Dokumen yang tidak disebut oleh peraturan terindeks mana pun tetap legal_status="unknown". Itu bukan pernyataan bahwa dokumen tersebut masih berlaku. Bentuk respons dan sitasi tidak berubah.

Menjawab pertanyaan angka dan jumlah​

Bentuk request dan respons tidak berubah, tetapi jalur jawaban menangani pertanyaan berbentuk angka, hitungan, dan kuantitas secara lebih langsung daripada sebelumnya:

  • Pemeriksaan faithfulness yang berjalan sebelum jawaban dikembalikan kini peka terhadap angka (#209, #216). Jawaban numerik, angka pendek, tanggal, dan angka yang tampil berbeda antarbahasa tidak lagi dibuang oleh pemeriksaan naif berbasis tumpang tindih kata, sehingga pertanyaan angka berbasis sumber dijawab dan diberi sitasi, bukan ditolak berlebihan.
  • Planner memilih tool SQL Query read-only hanya ketika sumber data terstruktur benar-benar dikonfigurasi untuk deployment (#223). Ketika tidak ada sumber terstruktur yang dikonfigurasi, pertanyaan berbentuk kuantitas dijawab dari corpus dokumen melalui hybrid retrieval, bukan diarahkan ke tool yang tidak tersedia, sehingga menghasilkan jawaban bersitasi alih-alih penolakan.
  • Angka OpenData dan SatuData dibaca secara spesifik untuk query dari portal CKAN yang ada di allow-list dan diberi grounding dengan identitas dataset serta waktu pembacaan.
  • BPS Statistics membaca indikator otoritatif dari BPS WebAPI yang ada di allow-list ketika fitur BPS diaktifkan dan memiliki key.

Ini adalah perilaku kualitas jawaban di dalam pipeline. Perilaku ini tidak menambah atau mengganti nama field request. Nilai OpenData/SatuData dan BPS hanya menjadi grounding tool live ketika feature flag, allow-list, dan (untuk BPS) application key dikonfigurasi; jika tidak, tool mengembalikan catatan jujur bahwa layanan tidak tersedia atau belum diaktifkan.

Field request​

FieldTipeWajibBatasanMakna
querystringYaPanjang minimum 1 saat validasi JSON: "" mengembalikan 422; string yang hanya berisi whitespace tetapi tidak kosong mengembalikan 400.Pertanyaan pengguna.
user_idstring (UUID)TidakHarus UUID yang valid jika diberikan; default ke UUID sentinel semua nol.Hanya subject fallback development ketika autentikasi tepercaya tidak diwajibkan; klien terintegrasi sebaiknya mengandalkan header principal tepercaya.
conversation_idstring (UUID) atau nullTidakHarus tidak kosong ketika diberikan.Percakapan yang sudah ada untuk dilanjutkan. Hilangkan atau tetapkan null untuk memulai percakapan baru.
collection_idsarray string atau nullTidakTidak disertakan, null, [], atau array yang dinormalisasi menjadi tanpa ID non-kosong berarti semua collection yang dapat diakses; ID non-kosong dipangkas dan dideduplikasi sebelum pembatasan.Scope pengambilan opsional.
document_idsarray string UUID atau nullTidakTidak disertakan, null, atau [] berarti semua dokumen yang dapat diakses. Setiap nilai yang diberikan harus UUID valid; nilai dikanonisasi dan dideduplikasi sebelum pembatasan.Fokus pengambilan ke dokumen pilihan secara opsional; beririsan dengan collection_ids dan pemfilteran akses.

JSON request​

{
"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"]
}

Field response​

FieldTipeMakna
answerstringJawaban akhir yang telah melewati guard atau teks penolakan guard.
conversation_idstringID percakapan untuk giliran lanjutan; pengecualian persistensi timeout mengikuti ADR-0041.
message_idstringID pesan asisten yang disimpan; pengecualian persistensi timeout mengikuti ADR-0041.
trace_idstring atau nullID trace Langfuse 32 karakter yang tersimpan bersama pesan asisten. null berarti tracing dinonaktifkan atau tidak tersedia; jawaban tetap valid.
citationsarray sitasiSitasi sumber untuk jawaban. Lihat Objek sitasi bersama.
fallback_usedbooleanApakah jalur faithfulness/tanpa jawaban menggunakan jawaban fallback.
fallback_reasonstring | nullAlasan publikasi. Lihat tabel alasan yang otoritatif untuk kontrak lengkap, termasuk jawaban sebagian yang diaktifkan melalui konfigurasi.
dropped_claims_countintegerJumlah klaim tanpa dukungan yang dihapus sebelum jawaban ber-guard dikembalikan.
claims_supportedintegerKlaim yang diverifikasi gate terhadap passage sumber, tetap dihitung meskipun jawaban kemudian ditahan atau ditolak.
claims_totalintegerclaims_supported ditambah klaim yang gagal terhadap sumber: semua klaim yang diperiksa. Bukan claims_supported + dropped_claims_count; lihat Tingkat keterverifikasian klaim.
grounding_ratenumber | nullTingkat keterverifikasian klaim: claims_supported / claims_total, 0 sampai 1 dengan empat desimal; null bila tidak ada klaim yang diperiksa. Lihat Tingkat keterverifikasian klaim.

Tingkat keterverifikasian klaim​

grounding_rate adalah bagian klaim jawaban yang diperiksa dan didukung sumber yang dikutip: klaim yang diverifikasi gate, dari klaim tersebut ditambah klaim yang gagal terhadap sumber. Tampilkan sebagai persentase beserta jumlahnya, misalnya "78% · 7/9 klaim" (label bahasa Inggris: Claim grounding rate). Bila nilainya null, tidak ada yang diperiksa (no_claims, no_answer, empty_completion, penolakan sebelum gate): tampilkan "—", jangan 0%. Klaim yang mengulang giliran pengguna sebelumnya atau memori tersimpan tidak diverifikasi terhadap sumber, sehingga tidak dihitung di kedua sisi, baik dipertahankan maupun dihapus. Kutipan verbatim yang tidak dipakai dan dihapus gate (surplus_excerpt) tidak gagal apa pun, sehingga juga tidak dihitung; dropped_claims_count tetap mencakup keduanya, karena itu claims_total bisa lebih kecil dari claims_supported + dropped_claims_count. Nilai ini menggambarkan apa yang diperiksa gate, bukan apa yang ditampilkan: bila jawaban ditahan (coverage_incomplete) atau diganti penolakan Policy Guard, klaim yang telah diverifikasi gate tetap dihitung, sehingga nilainya "1 dari 2 terverifikasi", bukan 0. Klien boleh menyembunyikan badge pada penolakan; tinjauan kualitas membacanya sebagai bukti akar masalah.

Ini sinyal runtime per jawaban, bukan angka faithfulness. Angka faithfulness untuk klien berasal dari evaluasi independen tingkat klaim (ADR-0042). Tiga field yang sama ada pada frame SSE faithfulness, pada /query/v2, dan tersimpan di metadata.synthesis.faithfulness pesan asisten.

:::note Perubahan kontrak (3 Nov 2026)

claims_supported, claims_total, dan grounding_rate bersifat tambahan: tidak ada field lama yang berubah.

:::

Jawaban tanpa label sitasi tidak otomatis ditampilkan. no_claims berarti model memastikan jawaban tidak menyatakan fakta yang memerlukan verifikasi sumber; teks ditampilkan dengan penanda. Fakta tanpa label diperiksa terhadap bagian sumber yang ditunjukkan dan dapat disitasi, lalu diterbitkan dengan sitasi kolektif hanya jika didukung. unverified_no_claims menahan teks asli jika klasifikasi atau dukungan gagal, termasuk saat hakim model tidak tersedia.

unparseable_completion menahan keluaran model yang tidak valid; bukti kegagalan mentah tetap privat, termasuk pada riwayat percakapan. Penolakan format mencegah pembuatan dan lampiran berkas baru pada giliran itu. unresolved_citation menahan jawaban dengan provenance yang tidak dapat diselesaikan. Studio menjelaskan kedua alasan ini dan unverified_no_claims dalam Bahasa Indonesia dan Inggris. Referensi gate memiliki kontrak batas struktural; docstring modulnya menetapkan aturan pemisah prosa dan kode.

JSON response​

{
"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",
"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 menerima body request yang sama seperti /query dan memiliki perilaku status/error setup yang sama sebelum frame progres pertama. Progres tool dapat diterima saat agent berjalan; teks jawaban hanya ditampilkan setelah pemeriksaan faithfulness dan Government Policy Guard selesai.

Teks jawaban menggunakan buffer-lalu-tampilkan:

  • Tidak ada teks draf yang belum disetujui yang di-stream.
  • Frame answer.delta hanya dikeluarkan setelah guard lolos.
  • Jika guard memblokir, tidak ada frame answer.delta yang dikeluarkan.
  • tool.started dan tool.finished dapat mendahului message.start. Untuk create_artifact, progres hanya menampilkan call, tool, dan saat selesai, ok serta ms. Argumen, content, claims, dan ringkasan hasil/penolakan ditahan pada batas observer, termasuk untuk artifact yang tersimpan. Tool lain tetap membawa argumen terstruktur dan ringkasan hasil.
  • Tidak ada frame heartbeat; izinkan jeda panjang antar-event progres.
  • Kegagalan setup mengembalikan error JSON HTTP biasa sebelum body text/event-stream dimulai.

Field dan JSON request​

Skema request identik dengan 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"]
}

Header response​

Stream yang berhasil mengembalikan 200 dengan Content-Type: text/event-stream. Server juga menetapkan Cache-Control: no-cache, no-transform, X-Accel-Buffering: no, dan Connection: keep-alive untuk mencegah buffering serta transformasi oleh perantara, termasuk kompresi.

Huawei APIG atau gateway lain di depan API harus meneruskan respons text/event-stream segera, tanpa kompresi atau buffering. Caddy produksi mengecualikan /query dan /query/* dari kompresi respons. Timeout gateway harus mengizinkan seluruh pipeline jawaban multi-langkah, penanganan penolakan saat batas waktu habis, dan jeda antar-frame progres.

Format frame SSE​

Setiap event dibingkai sebagai berikut:

event: <event-name>
data: <single-line JSON object>

Payload data: adalah JSON. Klien harus mem-parse event sesuai urutan di wire dan tidak boleh mengurutkan ulang sitasi atau delta.

Urutan event dan semantik terminal​

Stream yang berhasil mengikuti tepat satu dari jalur berikut:

message.start
trace optional
citation zero or more times
passage zero or more times
answer.delta zero or more times
faithfulness
done

atau, ketika Government Policy Guard memblokir jawaban yang dihasilkan:

message.start
trace optional
citation zero or more times
guard.blocked
done

Kedua jalur dapat diawali event progres langsung tool.started / tool.finished. Kegagalan setelah streaming dimulai mengirim error terminal, bukan done; perlakukan sebagai terminal dan berhenti membaca.

EventBentuk dataKapan munculPerilaku klien
tool.started / tool.finishedObjek progres dengan call, tool; penyelesaian menambahkan ok, ms. Progres artifact tidak memuat argumen dan teks hasil.Saat tool berjalan, sebelum message.start.Perbarui progres; ok bukan keputusan publikasi.
message.start{"conversation_id": string, "message_id": string}Setelah progres langsung, jika ada.Simpan ID untuk pesan asisten saat ini dan resume berikutnya, dengan mengikuti kontrak persistensi timeout.
trace{"tools": array, "completed": true}Setelah message.start jika tool digunakan.Tampilkan trace tool yang selesai.
citationObjek sitasiNol kali atau lebih setelah message.start dan sebelum event jawaban atau block.Lampirkan sitasi ke pesan asisten yang menunggu sesuai urutan penerimaan.
answer.delta{"text": string}Nol kali atau lebih hanya ketika guard lolos.Tambahkan text ke buffer jawaban yang terlihat.
passageObjek grounding dengan kind dan text; passage chunk memuat chunk_id, source_type, score; passage tool memuat tool, source_url.Sebelum delta, hanya ketika guard lolos.Lampirkan passage pendukung ke sitasi.
faithfulness{"dropped": number, "claims_supported": number, "claims_total": number, "grounding_rate": number atau null, "fallback_used": boolean, "fallback_reason": string atau null}Setelah delta, hanya ketika guard lolos.Tampilkan metadata gate dan tingkat keterverifikasian klaim; teks claim yang dihapus ditahan.
guard.blocked{"reason": string}Menggantikan answer.delta ketika guard memblokir.Tampilkan status penolakan/block menggunakan reason; jangan render buffer jawaban.
done{"finish_reason": "stop"} atau {"finish_reason": "guard_blocked"}Event terminal normal.Finalisasi pesan.
error{"code": string, "message": string}Kegagalan terminal setelah streaming dimulai; kode mencakup query_failed, http_<status>, dan stream_failed.Tampilkan error dan berhenti; jangan menunggu done.

Frame jawaban yang lolos guard (cuplikan)​

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"}

Transkrip SSE yang diblokir guard​

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"}

curl​

Gunakan curl --no-buffer untuk mencegah buffering output di sisi klien.

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 pengambilan mentah​

POST /search mengembalikan chunk sumber yang telah diurutkan. Rute ini tidak menyintesis jawaban, tidak menerapkan guard jawaban yang dihasilkan, dan tidak boleh ditampilkan kepada pengguna akhir seolah-olah merupakan jawaban chat. Gunakan untuk memeriksa pengambilan, pemilih sumber, pratinjau sitasi, dan debugging relevansi pencarian.

Tidak ada hasil merupakan 200 yang berhasil dengan results: []. Ini termasuk nilai document_ids yang formatnya benar tetapi tidak dikenal, terlarang, tidak dapat diakses, atau tidak memiliki chunk yang cocok dengan query; jangan menyimpulkan keberadaan dokumen atau izin dari hasil kosong.

Field request​

FieldTipeWajibBatasanMakna
querystringYaPanjang minimum 1 saat validasi JSON: "" mengembalikan 422; string yang hanya berisi whitespace tetapi tidak kosong mengembalikan 400.Teks pencarian.
modestringTidakSalah satu dari hybrid, semantic, lexical. Default hybrid.Strategi pengambilan.
top_kintegerTidakDefault 5; harus antara 1 hingga 50.Jumlah maksimum chunk yang dikembalikan setelah pemeringkatan.
collection_idsarray string atau nullTidakTidak disertakan, null, [], atau array yang dinormalisasi menjadi tanpa ID non-kosong berarti semua collection yang dapat diakses; ID non-kosong dipangkas dan dideduplikasi sebelum pembatasan.Scope pengambilan opsional.
document_idsarray string UUID atau nullTidakTidak disertakan, null, atau [] berarti semua dokumen yang dapat diakses. Setiap nilai yang diberikan harus UUID valid; nilai dikanoisasi dan dideduplikasi sebelum pembatasan.Fokus pengambilan ke dokumen pilihan secara opsional; beririsan dengan collection_ids dan pemfilteran akses.

Mode pencarian​

ModePerilakuGunakan ketika
hybridMenggabungkan pengambilan vektor semantik dan pengambilan full-text leksikal PostgreSQL, lalu mengurutkan kandidat gabungan.Default untuk kualitas pengambilan yang dihadapi pengguna.
semanticHanya menggunakan pengambilan vektor semantik.Anda memerlukan kecocokan berbasis makna tanpa penggabungan leksikal.
lexicalHanya menggunakan pengambilan leksikal full-text.Anda memerlukan perilaku pencocokan istilah/kata/ketentuan yang tepat.

JSON request​

{
"query": "transparansi anggaran",
"mode": "hybrid",
"top_k": 5,
"collection_ids": ["JDIH", "PPID"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}

Field response​

FieldTipeMakna
modestringMode yang digunakan untuk request.
resultsarray hasil pencarianChunk yang telah diurutkan. Kosong ketika tidak ada kecocokan yang terlihat.

Objek hasil pencarian:

FieldTipeMakna
chunk_idstring (UUID)ID chunk sumber. Mencerminkan citation.chunk_id.
document_idstring (UUID)ID dokumen sumber. Mencerminkan citation.document_id.
ordinalintegerOrdinal chunk di dalam dokumen.
textstringTeks chunk yang diambil.
scorenumberSkor pengambilan untuk pemeringkatan. Hasil dengan peringkat lebih tinggi muncul lebih awal. Jangan membandingkan skor antar-mode sebagai kontrak API yang stabil.
citationobjek sitasiMetadata sitasi lengkap. Lihat Objek sitasi bersama.

JSON response​

{
"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?"}'

Perilaku status dan error​

Respons error standar adalah objek JSON:

{"detail":"Invalid or missing API key"}

Error validasi request FastAPI menggunakan bentuk standar 422 dengan detail berupa array item validasi.

{
"detail": [
{
"type": "greater_than",
"loc": ["body", "top_k"],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {"gt": 0}
}
]
}
StatusBerlaku untukMaknaBentuk body
200/queryJawaban dikembalikan. Penolakan guard tetap 200 dengan penolakan di answer.QueryResponse
200/query/streamStream SSE dimulai setelah setup selesai. Stream berakhir dengan done atau error.text/event-stream
200/searchPencarian selesai, termasuk tanpa hasil.SearchResponse dengan results: [] saat kosong
400Ketiga ruteString query yang tidak kosong hanya berisi whitespace setelah parsing.{"detail":"Query must not be empty"}
401Ketiga ruteAPI key tidak ada/tidak valid, principal wajib tidak ada, atau principal tidak valid ketika autentikasi tepercaya diwajibkan.{"detail":"Invalid or missing API key"}, {"detail":"Authenticated principal required"}, atau {"detail":"Invalid authenticated principal"}
404/query, /query/streamID percakapan tidak dikenal atau dimiliki subject lain; atau tidak ada content terindeks yang terlihat untuk menjawab, termasuk ketika fokus dokumen yang formatnya benar tidak menghasilkan chunk yang cocok.{"detail":"..."}
413/query, /query/streamContext jawaban akan melebihi context window model.{"detail":"..."}
422Ketiga ruteValidasi skema JSON gagal, misalnya query: "", enum tidak valid, entri document_ids tidak valid, conversation_id: "", top_k di luar 1..50, query tidak ada, atau field tambahan pada /search.{"detail":[...]}
502/query, /query/streamDependency model atau orkestrasi gagal; untuk penolakan timeout dan anggaran, lihat ADR-0041.{"detail":"..."}
502/searchDependency model gagal, misalnya ModelClientError embedding atau reranker. Gangguan repository yang tidak ditangani tidak dipetakan oleh handler ini.{"detail":"..."} untuk kegagalan dependency model

Untuk /query/stream, kegagalan sebelum frame progres pertama (400, 401, 404, 413, 422, 502) dikembalikan sebagai error HTTP JSON. Setelah body 200 text/event-stream dimulai, penyelesaian normal direpresentasikan oleh done; kegagalan pipeline atau framing direpresentasikan oleh error terminal, bukan done.

Klien TypeScript production​

Helper ini ditujukan untuk server tepercaya, BFF, atau rute same-origin yang dapat membaca API key dari konfigurasi sisi server. Jangan mengirim shared API key ke JavaScript browser.

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;
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);
}

Parsing SSE TypeScript dengan fetch() dan 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" } }
| { 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?: (finishReason: "stop" | "guard_blocked") => 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.finish_reason);
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}`);
}
}

UI chat dapat merender jawaban streaming bersitasi dengan mengumpulkan event citation, menambahkan answer.delta.text, mengganti area jawaban dengan guard.blocked.reason saat diblokir, dan memperlakukan done maupun error sebagai terminal.