API Query, Query Streaming, dan Search
Gunakan endpoint berikut untuk membangun klien chat bersitasi dan pengambilan mentah:
| Endpoint | Tujuan | Jenis respons |
|---|---|---|
POST /query | Menjalankan pipeline jawaban lengkap dan mengembalikan satu jawaban bersitasi sebagai JSON. | application/json |
POST /query/stream | Menjalankan pipeline jawaban yang sama, lalu menampilkan hasil yang sudah melewati guard sebagai Server-Sent Events. | text/event-stream |
POST /search | Hanya 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:
| Header | Tipe | Makna |
|---|---|---|
X-User-Subject | string | Subject pengguna terautentikasi yang stabil. |
X-User-Role | string | Role terautentikasi. Default ke user hanya ketika resolver tepercaya menerima perilaku fallback. |
X-User-Unit | string | Unit organisasi opsional untuk pemfilteran akses. |
X-User-Level | integer string | Level 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.
| Nilai | Perilaku |
|---|---|
| Field tidak disertakan | Mencari semua collection yang dapat diakses pemanggil. |
null | Sama seperti tidak disertakan. |
[] | Sama seperti tidak disertakan. |
| Array string | Memangkas 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 kosong | Sama seperti tidak disertakan: setelah normalisasi tidak ada scope collection, sehingga semua collection yang dapat diakses dicari. |
| ID tidak dikenal atau terlarang yang tidak kosong | Menghasilkan 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.
| Nilai | Perilaku |
|---|---|
| Field tidak disertakan | Mencari semua dokumen yang dapat diakses pemanggil. |
null | Sama seperti tidak disertakan. |
[] | Sama seperti tidak disertakan. |
| Array UUID | Mengkanonisasi 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 valid | Validasi request gagal dengan 422; ID tidak valid adalah error input klien, bukan fokus tanpa hasil. |
| UUID yang formatnya benar tetapi tidak dikenal atau terlarang | Menghasilkan 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 tersimpan | mcp_source_ids | Perilaku |
|---|---|---|
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 percakapan | 400 — "This workspace has no sources…" / "This notebook has no sources…". |
| Sumber yang tidak lagi dapat diresolusi (dihapus atau tidak terbaca) | Ada sumber | 200, dijawab hanya dari MCP, dengan catatan tetap bahwa dokumen tersimpan sudah tidak tersedia. |
| Masih memiliki dokumen | Apa pun | Perilaku 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
| Field | Tipe | Wajib | Batasan | Makna |
|---|---|---|---|---|
query | string | Ya | Panjang minimum 1 saat validasi JSON: "" mengembalikan 422; string yang hanya berisi whitespace tetapi tidak kosong mengembalikan 400. | Pertanyaan pengguna. |
user_id | string (UUID) | Tidak | Harus 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_id | string (UUID) atau null | Tidak | Harus tidak kosong ketika diberikan. | Percakapan yang sudah ada untuk dilanjutkan. Hilangkan atau tetapkan null untuk memulai percakapan baru. |
collection_ids | array string atau null | Tidak | Tidak 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_ids | array string UUID atau null | Tidak | Tidak 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
| Field | Tipe | Makna |
|---|---|---|
answer | string | Jawaban akhir yang telah melewati guard atau teks penolakan guard. |
conversation_id | string | ID percakapan untuk giliran lanjutan; pengecualian persistensi timeout mengikuti ADR-0041. |
message_id | string | ID pesan asisten yang disimpan; pengecualian persistensi timeout mengikuti ADR-0041. |
trace_id | string atau null | ID trace Langfuse 32 karakter yang tersimpan bersama pesan asisten. null berarti tracing dinonaktifkan atau tidak tersedia; jawaban tetap valid. |
citations | array sitasi | Sitasi sumber untuk jawaban. Lihat Objek sitasi bersama. |
fallback_used | boolean | Apakah jalur faithfulness/tanpa jawaban menggunakan jawaban fallback. |
fallback_reason | string | null | Alasan publikasi. Lihat tabel alasan yang otoritatif untuk kontrak lengkap, termasuk jawaban sebagian yang diaktifkan melalui konfigurasi. |
dropped_claims_count | integer | Jumlah klaim tanpa dukungan yang dihapus sebelum jawaban ber-guard dikembalikan. |
claims_supported | integer | Klaim yang diverifikasi gate terhadap passage sumber, tetap dihitung meskipun jawaban kemudian ditahan atau ditolak. |
claims_total | integer | claims_supported ditambah klaim yang gagal terhadap sumber: semua klaim yang diperiksa. Bukan claims_supported + dropped_claims_count; lihat Tingkat keterverifikasian klaim. |
grounding_rate | number | null | Tingkat 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.deltahanya dikeluarkan setelah guard lolos. - Jika guard memblokir, tidak ada frame
answer.deltayang dikeluarkan. tool.starteddantool.finisheddapat mendahuluimessage.start. Untukcreate_artifact, progres hanya menampilkancall,tool, dan saat selesai,oksertams. 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-streamdimulai.
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.
| Event | Bentuk data | Kapan muncul | Perilaku klien |
|---|---|---|---|
tool.started / tool.finished | Objek 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. |
citation | Objek sitasi | Nol 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. |
passage | Objek 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
| Field | Tipe | Wajib | Batasan | Makna |
|---|---|---|---|---|
query | string | Ya | Panjang minimum 1 saat validasi JSON: "" mengembalikan 422; string yang hanya berisi whitespace tetapi tidak kosong mengembalikan 400. | Teks pencarian. |
mode | string | Tidak | Salah satu dari hybrid, semantic, lexical. Default hybrid. | Strategi pengambilan. |
top_k | integer | Tidak | Default 5; harus antara 1 hingga 50. | Jumlah maksimum chunk yang dikembalikan setelah pemeringkatan. |
collection_ids | array string atau null | Tidak | Tidak 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_ids | array string UUID atau null | Tidak | Tidak 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
| Mode | Perilaku | Gunakan ketika |
|---|---|---|
hybrid | Menggabungkan pengambilan vektor semantik dan pengambilan full-text leksikal PostgreSQL, lalu mengurutkan kandidat gabungan. | Default untuk kualitas pengambilan yang dihadapi pengguna. |
semantic | Hanya menggunakan pengambilan vektor semantik. | Anda memerlukan kecocokan berbasis makna tanpa penggabungan leksikal. |
lexical | Hanya 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
| Field | Tipe | Makna |
|---|---|---|
mode | string | Mode yang digunakan untuk request. |
results | array hasil pencarian | Chunk yang telah diurutkan. Kosong ketika tidak ada kecocokan yang terlihat. |
Objek hasil pencarian:
| Field | Tipe | Makna |
|---|---|---|
chunk_id | string (UUID) | ID chunk sumber. Mencerminkan citation.chunk_id. |
document_id | string (UUID) | ID dokumen sumber. Mencerminkan citation.document_id. |
ordinal | integer | Ordinal chunk di dalam dokumen. |
text | string | Teks chunk yang diambil. |
score | number | Skor pengambilan untuk pemeringkatan. Hasil dengan peringkat lebih tinggi muncul lebih awal. Jangan membandingkan skor antar-mode sebagai kontrak API yang stabil. |
citation | objek sitasi | Metadata 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}
}
]
}
| Status | Berlaku untuk | Makna | Bentuk body |
|---|---|---|---|
200 | /query | Jawaban dikembalikan. Penolakan guard tetap 200 dengan penolakan di answer. | QueryResponse |
200 | /query/stream | Stream SSE dimulai setelah setup selesai. Stream berakhir dengan done atau error. | text/event-stream |
200 | /search | Pencarian selesai, termasuk tanpa hasil. | SearchResponse dengan results: [] saat kosong |
400 | Ketiga rute | String query yang tidak kosong hanya berisi whitespace setelah parsing. | {"detail":"Query must not be empty"} |
401 | Ketiga rute | API 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/stream | ID 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/stream | Context jawaban akan melebihi context window model. | {"detail":"..."} |
422 | Ketiga rute | Validasi 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/stream | Dependency model atau orkestrasi gagal; untuk penolakan timeout dan anggaran, lihat ADR-0041. | {"detail":"..."} |
502 | /search | Dependency 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.