Ringkasan & prinsip
ChatZAP memisahkan dua pertanyaan yang sering dicampur sistem tutor AI: apakah langkah ini benar secara matematis dan bagaimana mengatakannya dengan bahasa yang manusiawi. Pertanyaan pertama dijawab mesin simbolik deterministik. Pertanyaan kedua dijawab model bahasa.
| Lapis | Penentu | Boleh melakukan | Tidak boleh melakukan |
|---|---|---|---|
| Kebenaran | SymPy + mesin CFL (backend/cfl/) | Mengurai langkah, membandingkan ekuivalensi, melokalisasi fault pertama, membuat counter-example, memberi status terverifikasi. | Menyerahkan penilaian validitas ke LLM atau ke tebakan probabilistik. |
| Bahasa | Model bahasa (Gemini, NVIDIA NIM, BazaarLink, OpenAI, Anthropic, Mistral, atau model lokal) | Merumuskan pertanyaan Sokratik, empati, klarifikasi, dan penjelasan konsep. | Menyatakan jawaban akhir, mengubah vonis CFL, atau mengarang rumus yang tidak ada. |
Zero-Answer Policy adalah invariant produk: sistem tidak pernah mengeluarkan solusi akhir soal latihan. Yang dikirim adalah pertanyaan, petunjuk arah, dan diagnosis pada langkah yang sedang dikerjakan. Penegakannya berlapis: instruksi prompt, sanitasi keluaran di backend, dan sanitasi terakhir di finalizeModelReply() sebelum teks menyentuh peramban.
- Determinisme lebih dulu. Bila mesin tidak dapat memutuskan, statusnya
UNDECIDABLE_STEP, bukan vonis salah. - Fail-closed untuk rahasia. Endpoint harness menolak permintaan tanpa secret yang cocok saat secret terpasang; di production tanpa secret, endpoint menolak dengan 503.
- Isolasi pengguna. Memori episodik disimpan per
user_keyhash, dan referensi hasil promosi percakapan hanya muncul untuk pemiliknya. - Batas diakui. Setiap bagian di bawah menyertakan limitasi yang benar-benar ada di kode, bukan hanya kemampuan.
Arsitektur
Ada dua proses utama. Next.js 14 menjalankan UI dan route handler di port 3000. FastAPI menjalankan CFL, harness, korpus, dan relay papan kolaborasi di port 8000. Keduanya berbicara lewat HTTP; rute chat di Next bertindak sebagai orchestrator yang memanggil backend dengan secret server-ke-server.
Peramban (app/chat/page.tsx)
│ POST /api/chat { message, stream: true, targetVariable: "x", ... }
▼
Next.js route handler (app/api/chat/route.ts)
├─ 1. parse JSON ......................... 400 bila body kosong / bukan JSON
├─ 2. cek strike & suspend ............... registry server, bukan nilai dari klien
├─ 3. POST /api/v1/harness/context ....... timeout 1.200 ms
│ └─ FastAPI → memori episodik + korpus + rumus geometri
├─ 4. pilih provider LLM ................. Gemini / NVIDIA NIM / BazaarLink /
│ (hanya untuk bahasa) OpenAI / Anthropic / Mistral / lokal
├─ 5. finalizeModelReply() ............... cleanModelResponse + sanitizeZeroAnswer
└─ 6. kirim balasan ...................... JSON utuh ATAU SSE:
event: meta → delta* → done
▼
Peramban
│ (fire-and-forget) POST /api/v1/harness/learn ... timeout 1.500 ms
▼
FastAPI backend/main.py
└─ ZAPAgentHarness.learn_from_conversation()
redaksi PII → ekstraksi langkah → gerbang CFL SymPy
→ upsert memori episodik → promosi korpus (hanya ALL_STEPS_VALID)| Komponen | Lokasi berkas | Tanggung jawab |
|---|---|---|
| UI chat & Smartboard | frontend/app/chat/page.tsx | Mengirim pesan, menampilkan balasan bertahap, papan geometri, dan status diagnosis. |
| Route chat | frontend/app/api/chat/route.ts | Orkestrasi: validasi, deteksi jailbreak, ambil konteks harness, pilih provider, sanitasi, SSE/JSON. |
| Guardrail keluaran | frontend/lib/zero-answer.ts | Pintu keluar terakhir: bersihkan artefak penalaran lalu sensor deklarasi jawaban. |
| Gateway FastAPI | backend/main.py | Endpoint diagnose/verify/harness, CORS, secret gate, dan WebSocket papan kolaborasi. |
| Parser aman | backend/cfl/parser.py | Whitelist-first, batas eksponen, parsing SymPy/LaTeX. |
| Mesin CFL | backend/cfl/cfl_engine.py | Membandingkan langkah berurutan, melokalisasi fault, menandai langkah tak dapat diputuskan. |
| Klasifikasi & counter-example | backend/cfl/classifier.py, counter_example.py | Menamai miskonsepsi dan mencari substitusi integer kecil yang menunjukkan kontradiksi. |
| Harness memori | backend/data_engine/agent_harness.py | Memori episodik, isolasi pengguna, retrieval, profil belajar, umpan balik, promosi korpus. |
| Korpus & harvester | backend/data_engine/store.py, build_corpus.py, harvester.py | Penyimpanan SQLite + mirror JSONL, pembangunan korpus, panen sumber internet terverifikasi. |
| Vault & sesi akun | frontend/lib/vault-crypto.ts, vault-session.ts, account-session.ts | Enkripsi sisi klien, kunci sesi per tab, token sesi HMAC, sinkronisasi envelope. |
| Sinkronisasi cloud | frontend/app/api/user/sync/route.ts | Menerima envelope terenkripsi dan metadata identitas; menolak plaintext yang tidak berhak. |
Zero-Answer Policy
Ada dua implementasi guardrail yang saling melengkapi: satu di backend Python untuk prompt yang dihasilkan server, satu di frontend TypeScript untuk semua balasan model sebelum dikirim ke klien. Keduanya bekerja dengan pola deklaratif, bukan dengan memahami makna kalimat.
3.1 Guardrail backend: RobustZeroAnswerGuardrail
backend/cfl/guardrail.py mendefinisikan DECLARATIVE_ANSWER_PATTERNS: 11 regex yang menangkap deklarasi jawaban akhir, simpulan variabel, nilai geometri, hasil aritmetika, satuan fisika, pH/pOH, output program, dan kompleksitas Big-O. Setiap kecocokan diganti dengan Bagaimanakah menurut analisismu?. Parameter opsional final_solution hanya menyensor bila nilai itu muncul setelah penanda kunci atau nilai akhir, sehingga kalimat instruksional seperti jika x = 2 tetap utuh.
# backend/cfl/guardrail.py
class RobustZeroAnswerGuardrail:
DECLARATIVE_ANSWER_PATTERNS = [ ... ] # 11 regex: deklaratif + domain non-matematika
@classmethod
def sanitize(cls, text: str, final_solution: str | None = None) -> str:
sanitized = text
for pattern in cls.DECLARATIVE_ANSWER_PATTERNS:
sanitized = re.sub(pattern, "Bagaimanakah menurut analisismu?",
sanitized, flags=re.IGNORECASE)
if final_solution and str(final_solution).strip():
sol_escaped = re.escape(str(final_solution).strip())
target_pattern = (
rf"(?:kunci|nilai\s+akhir)\s*(?:adalah|=|:)\s*"
rf"(?:[a-zA-Z]\s*=\s*)?{sol_escaped}\b"
)
sanitized = re.sub(target_pattern, "kunci adalah [DIRAHASIAKAN]",
sanitized, flags=re.IGNORECASE)
return sanitized/api/v1/diagnose memakai guardrail ini untuk membersihkan socratic_prompt, lalu menghitung guardrail_status.final_answer_suppressed dengan memeriksa ulang teks yang benar-benar dikirim terhadap pola yang sama. Status itu bukan klaim kosong: ia dihitung dari hasil sanitasi nyata.
3.2 Guardrail frontend: delimiter-aware
Temuan audit 13 November 2026 menunjukkan versi lama hanya mencocokkan teks mentah, sehingga jawaban dalam matematika inline/display lolos. Perbaikannya ada di frontend/lib/zero-answer.ts: seluruh span matematika dinormalisasi lebih dulu, lalu pola lama dijalankan.
$$...$$,\[...\],\(...\), dan$...$diperiksa isinya; span yang hanya berisivariabel = konstantadiganti placeholder Sokratik.- Baris penuh
x = 4tanpa delimiter juga disensor oleh pola nomor 11. - Bila
targetVariablesesi diketahui (misalnyax), bentukx = 4di tengah kalimat ikut disensor tanpa merusak langkah seperti2x = 8ataux = 8/2. - Balasan dari provider model melewati
finalizeModelReply(): bersihkan blok<think>, sanitasi, lalu pakai fallback bila teks yang tersisa terlalu pendek.
// frontend/lib/zero-answer.ts
export function sanitizeZeroAnswer(text: string, options?: ZeroAnswerOptions): string {
if (!text) return "";
// 1. Seluruh span matematika dinormalisasi lebih dulu:
// $$…$$, \[…\], \(…\), lalu $…$
let result = sanitizeMathSpans(text);
// 2. Bila variabel target sesi diketahui, sensor "x = 4" di tengah kalimat.
const targetVariable = options?.targetVariable?.trim();
if (targetVariable) {
result = redactTargetAssignments(result, targetVariable);
}
// 3. Baru jalankan 11 pola deklarasi jawaban.
for (const item of ZERO_ANSWER_PATTERNS) {
result = result.replace(item.regex, item.replacement);
}
return result;
}
export function finalizeModelReply(text, fallback, options) {
const cleaned = cleanModelResponse(text || "");
const guarded = sanitizeZeroAnswer(cleaned, options).trim();
// Balasan yang seluruhnya tersensor tidak boleh dikirim kosong.
if (guarded.replace(/[^A-Za-z0-9\u00C0-\u024F]/g, "").length < 8) {
return fallback;
}
return guarded;
}3.3 Contoh sebelum dan sesudah
| Sebelum | Sesudah (pola yang berlaku) | Catatan |
|---|---|---|
$$x = 4$$ | [Berapakah nilainya menurut hitunganmu?] | Span matematika yang hanya berisi jawaban jadi. |
x = 4 | [Berapakah nilainya menurut hitunganmu?] | Satu baris penuh tanpa delimiter. |
coba hitung x = 4 dulu | coba hitung [Berapakah nilainya menurut hitunganmu?] dulu | Hanya bila targetVariable = x. |
Jadi x = 4 | Coba tentukan nilai variabel tersebut berdasarkan penalaranmu | Pola kesimpulan (jadi/maka/sehingga). |
jawabannya adalah 4 | Bagaimanakah menurut analisismu? | Deklarasi jawaban eksplisit. |
2x = 8 | tetap 2x = 8 | Langkah penalaran, bukan kunci jawaban. |
targetVariable yang diketahui dan hanya menerima satu huruf. (2) final_solution backend hanya aktif bila nilainya didahului penanda kunci/nilai akhir. (3) Semua ini pencocokan regex: parafrase baru, bahasa lain di luar pola, atau jawaban yang dipecah dengan cara tak terduga bisa lolos. (4) Pada mode streaming, potongan ditahan sampai batas kalimat dan sekitar 48 karakter terakhir, lalu teks final pada event done menjadi sumber kebenaran; tetap saja tidak ada jaminan matematis bahwa setiap formulasi akan tertangkap.Mesin CFL (Cognitive Fault Localization)
CFL menerima daftar baris aljabar, mengurainya satu per satu, lalu membandingkan pasangan baris berurutan. Tujuannya bukan menilai seluruh jawaban, tetapi menemukan titik patah logika pertama dan menjelaskannya secara deterministik.
4.1 Whitelist-first parsing
parse_expr SymPy memanggil eval() di internalnya. Karena itu urutannya wajib: validasi whitelist, batas eksponen, baru parsing. Membalik urutan ini berarti mengeksekusi parser pada input yang belum dipercaya.
- Jalur teks biasa:
_is_plain_algebra()menolak panjang > 200 karakter, garis bawah, kutip, backtick, titik-koma, dan backslash. Hanya karakter aljabar standar yang lolos; setiap identifier harus ada di whitelist. - Jalur LaTeX:
_is_safe_latex()mewajibkan backslash, menolak underscore/kutip/titik-koma/dolar, dan hanya mengizinkan perintahfrac,sqrt,cdot,times,sin,cos,tan,log,exp,pm. - Batas eksponen: eksponen numerik > 100 ditolak (termasuk bentuk kurung dan kurung kurawal), indeks akar
\sqrt[n]dengan n > 100 ditolak, dan pangkat bertingkat seperti(9^9)^9ditolak. - Baru kemudian
parse_expr()atauparse_latex()dijalankan.AssertionErrorselalu di-raise ulang sebelumexcept Exceptiongenerik (aturan Anti-Masking).
Variabel yang diizinkan: x, y, z, a, b, c, d, m, n, t. Fungsi: sin, cos, tan, sqrt, log, exp. Konstanta: pi, E.
# backend/cfl/parser.py
ALLOWED_FUNCS = {"sin", "cos", "tan", "sqrt", "log", "exp"}
ALLOWED_VARS = {"x", "y", "z", "a", "b", "c", "d", "m", "n", "t"}
ALLOWED_CONSTS = {"pi", "E"}
ALLOWED_LATEX_CMDS = {"frac", "sqrt", "cdot", "times", "sin", "cos",
"tan", "log", "exp", "pm"}
def parse_single_side(side_str: str) -> sp.Expr:
cleaned = side_str.strip()
if not cleaned:
raise ValueError("Ekspresi matematika tidak boleh kosong.")
# 1. Whitelist DULU, baru parse.
if _is_plain_algebra(cleaned):
if _has_dangerous_exponents(cleaned):
raise ValueError("Eksponen melebihi batas aman (maks 100) ...")
try:
return parse_expr(cleaned, transformations=TRANSFORMATIONS)
except AssertionError:
raise # Anti-Masking: jangan ditelan except generik
except Exception as err:
raise ValueError(f"Gagal mengurai aljabar '{side_str}': {err}")
if "\\" in cleaned and _is_safe_latex(cleaned):
if _has_dangerous_exponents(cleaned):
raise ValueError("Eksponen/indeks akar melebihi batas aman ...")
try:
return parse_latex(cleaned)
except AssertionError:
raise
except Exception as err:
raise ValueError(f"Gagal mengurai LaTeX '{side_str}': {err}")
# 3. Di luar whitelist = ditolak, bukan ditebak.
raise ValueError(f"Ekspresi tidak valid atau di luar whitelist aljabar: '{side_str}'")4.2 Perbandingan langkah & batas derajat
Untuk dua persamaan, CFL memeriksa apakah ruas kiri identik; bila ya, selisih ruas kanan harus nol. Bila tidak, himpunan solusi real kedua persamaan dibandingkan lewat solveset. Derajat polinomial di atas MAX_SOLVESET_DEGREE = 12 tidak dipaksa: hasilnya None dan API melaporkan UNDECIDABLE_STEP. Angka 12 dipilih karenax^100 + x^99 = 1 terhadap x^100 = 1 - x^99 tidak selesai dalam 15 detik dan dapat memblokir worker.
# backend/cfl/cfl_engine.py
MAX_SOLVESET_DEGREE = 12 # x^100 + x^99 = 1 sempat > 15 detik, jadi dibatasi
def steps_equivalent(prev, nxt, var=sp.Symbol("x")) -> bool | None:
if isinstance(prev, sp.Eq) and isinstance(nxt, sp.Eq):
if prev.lhs == nxt.lhs:
if sp.simplify(nxt.rhs - prev.rhs) == 0:
return True
degrees = (_polynomial_degree(prev.lhs - prev.rhs, var),
_polynomial_degree(nxt.lhs - nxt.rhs, var))
if any(d is not None and d > MAX_SOLVESET_DEGREE for d in degrees):
return None # UNDECIDABLE_STEP, bukan vonis
s1 = sp.solveset(prev.lhs - prev.rhs, var, domain=sp.S.Reals)
s2 = sp.solveset(nxt.lhs - nxt.rhs, var, domain=sp.S.Reals)
if isinstance(s1, sp.ConditionSet) or isinstance(s2, sp.ConditionSet):
return None
if s1 == sp.EmptySet and s2 == sp.EmptySet:
return sp.simplify((prev.lhs - prev.rhs) - (nxt.lhs - nxt.rhs)) == 0
return s1 == s2
if not isinstance(prev, sp.Eq) and not isinstance(nxt, sp.Eq):
return sp.simplify(nxt - prev) == 0
return False4.3 Status dan jenis miskonsepsi
| Status | Arti | Perilaku API |
|---|---|---|
FAULT_DETECTED | Ada pasangan langkah yang tidak ekuivalen. | has_fault true, fault_type diisi, counter_example bila tersedia. |
ALL_STEPS_VALID | Semua pasangan langkah ekuivalen. | has_fault false, tidak ada fault_type. |
UNDECIDABLE_STEP | ConditionSet, derajat > 12, atau di luar cakupan. | has_fault false, fault_type UNDECIDABLE_STEP, first_fault_line diisi. |
PARSING_ERROR | Salah satu baris tidak lolos parser. | Berhenti; endpoint mengembalikan 422 dengan pesan baris. |
| fault_type | Cara classifier memutuskannya |
|---|---|
DISTRIBUTIVE_EXPANSION_ILLUSION | Ekspresi memiliki pangkat; hasil expand tidak sama dengan ekspresi siswa. |
ROOT_LOSS | Himpunan solusi langkah baru adalah subset murni dari langkah sebelumnya. |
EXTRANEOUS_ROOT_INTRODUCTION | Himpunan solusi langkah baru adalah superset murni dari langkah sebelumnya. |
SIGN_INVERSION_OR_TRANSPOSITION_ERROR | Kedua ruas berubah bukan nol setelah penyederhanaan, mengindikasikan pindah ruas/salah tanda. |
GENERAL_ALGEBRAIC_DERIVATION_ERROR | Fallback bila tidak ada pola yang lebih spesifik. |
Counter-example dicari dengan substitusi integer kecil [2, 3, 1, 4]. Bila ditemukan selisih, respons berisi test_variable, test_value, lhs_expected, rhs_calculated, dan contradiction_statement. Bila tidak ada yang berhasil, respons berisi catatan untuk memeriksa aturan ekspansi - bukan counter-example palsu.
4.4 Contoh nyata /api/v1/diagnose
Permintaan berikut dijalankan langsung ke backend yang sedang berjalan saat dokumentasi ini disusun.
POST http://127.0.0.1:8000/api/v1/diagnose
Content-Type: application/json
{
"confirmed_steps": [
"2x + 6 = 14",
"2x = 20",
"x = 10"
],
"target_variable": "x"
}HTTP/1.1 200 OK
{
"status": "success",
"session_id": "sess_5733145a",
"transcription": {
"total_lines": 3,
"lines": [
{ "line_number": 1, "raw_latex": "2x + 6 = 14", "is_valid": true },
{ "line_number": 2, "raw_latex": "2x = 20", "is_valid": false },
{ "line_number": 3, "raw_latex": "x = 10", "is_valid": false }
]
},
"diagnosis": {
"has_fault": true,
"first_fault_line": 2,
"fault_type": "SIGN_INVERSION_OR_TRANSPOSITION_ERROR",
"status": "FAULT_DETECTED",
"human_explanation": "Terdapat kekeliruan pembalikan tanda atau transposisi suku saat memindahkan ruas persamaan.",
"socratic_prompt": "Perhatikan baris ke-2. Operasi aljabar apa yang kamu terapkan pada kedua ruas persamaan? Coba periksa kembali apakah tanda positif atau negatifnya sudah seimbang.",
"counter_example": {
"test_variable": "x",
"test_value": 2,
"lhs_expected": "-4",
"rhs_calculated": "-16",
"contradiction_statement": "Ketika x = 2, rumus awal bernilai -4, tetapi langkahmu menghasilkan -16."
}
},
"guardrail_status": {
"final_answer_suppressed": true
}
}Agent harness: memori & self-learning
Harness menyimpan apa yang terjadi antar-giliran percakapan supaya sistem tidak mulai dari nol. Penyimpanan utamanya SQLite agent_episodic_memory, dengan mirror JSONL di data/zap_agent_memory.jsonl. Setiap baris adalah satu pola percakapan yang dideduplikasi, bukan transkrip mentah seluruh sesi.
| Kelompok kolom | Isi |
|---|---|
| Identitas | memory_id, user_key, conversation_id |
| Klasifikasi | tier, topic, shape_kind |
| Isi percakapan | user_query (maks 500 karakter), extracted_steps (JSON) |
| Hasil CFL | cfl_status, misconception_type, counter_example, sympy_verified |
| Scaffolding | socratic_resolution yang sudah disanitasi guardrail |
| Sinyal kualitas | hit_count, upvotes, downvotes, quality_score, updated_at |
5.1 Isolasi pengguna
Identitas mentah tidak disimpan. user_id di-hash SHA-256, diambil 16 karakter hex, lalu diberi awalan u_. Tanpa identitas, baris masuk keranjang eksplisit anonymous - bukan NULL - agar predikat user_key selalu dapat diprediksi. Untuk pengguna beridentitas, memory_id juga menyertakan user_key, sehingga dua pengguna dengan pertanyaan identik tidak saling menimpa.
# backend/data_engine/agent_harness.py
@classmethod
def _user_key(cls, user_id: Optional[str]) -> str:
if user_id and str(user_id).strip():
return cls._profile_key(str(user_id))
return "anonymous" # keranjang eksplisit, bukan NULL
@staticmethod
def _profile_key(user_id: str) -> str:
digest = hashlib.sha256(
user_id.strip().lower().encode("utf-8")
).hexdigest()[:16]
return f"u_{digest}" # identitas mentah tidak pernah disimpan# backend/data_engine/agent_harness.py
@staticmethod
def _compute_memory_id(topic, normalized_query, steps, user_key="anonymous"):
parts = [topic.lower(), normalized_query.lower().strip(), "||".join(steps)]
if user_key and user_key != "anonymous":
parts.insert(0, user_key) # dedup terpisah per pemilik
payload = "|".join(parts)
return "mem_" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]5.2 Alur belajar enam langkah
- Redaksi PII. Email, nomor telepon Indonesia, NIK 16 digit, dan pola API key (
sk-,nvapi-,AIza,ghp_) diganti placeholder. - Ekstraksi langkah. Langkah eksplisit dipakai lebih dulu; bila kosong,
extract_math_steps_from_text()memecah teks pada baris baru, tanda panah, atau titik-koma, lalu memvalidasi setiap kandidat dengan parser CFL. - Gerbang CFL. Dua langkah atau lebih menjalani
isolate_first_fault_breakpoint(). Fault adalah diagnosis, bukan verifikasi:sympy_verifiedtetap false. - Resolusi Sokratik. Bila balasan asisten tidak ada, template dipilih berdasarkan jenis miskonsepsi atau geometri, lalu disanitasi
RobustZeroAnswerGuardrail.sanitize(). - Upsert memori.
INSERT ... ON CONFLICT(memory_id) DO UPDATEmenaikkanhit_countdan memperbarui resolusi. Mirror JSONL ditulis dengan lock proses. - Promosi korpus. Hanya derivasi dengan status
ALL_STEPS_VALID,sympy_verifiedtrue, dan minimal dua langkah yang masukMathCorpusStorepermanen.
# backend/data_engine/agent_harness.py — langkah 3 (gerbang CFL)
if len(steps) >= 2:
fault_idx, diag = isolate_first_fault_breakpoint(steps, target_var=target_var)
cfl_status = diag.get("status", "UNKNOWN")
if cfl_status == "FAULT_DETECTED":
# Diagnosis BUKAN verifikasi. Turunan salah tidak boleh dipromosikan.
misconception_type = diag.get("misconception_type")
counter_example = diag.get("counter_example")
sympy_verified = False
elif cfl_status == "ALL_STEPS_VALID":
sympy_verified = True
elif len(steps) == 1:
try:
parse_step_to_symbolic(steps[0])
cfl_status = "SINGLE_EXPRESSION_VALID"
sympy_verified = True
except Exception:
cfl_status = "CONCEPTUAL_QUERY"
# langkah 6 — promosi ke MathCorpusStore permanen
promoted_to_corpus = False
if cfl_status == "ALL_STEPS_VALID" and sympy_verified and len(steps) >= 2:
corpus_item = {
"id": f"conv_{memory_id}",
"tier": tier,
"topic": f"Pembelajaran Percakapan ({topic.capitalize()})",
"question": clean_user[:500],
"steps": steps,
"target_variable": target_var,
"sympy_verification": {"cfl_status": cfl_status, "is_valid": True},
"socratic_prompt": socratic_resolution,
"source": "ZAP Continuous User Conversation Memory",
}
self.corpus_store.insert_batch([corpus_item])
promoted_to_corpus = True5.3 Retrieval: kata kunci, bukan embedding
Sebelum menjawab, rute chat memanggil POST /api/v1/harness/context dengan timeout 1.200 ms. Harness mengambil maksimum 2.000 baris milik satu user_key, memberi skor tumpang tindih istilah, lalu memilih baris teratas. Bobotnya: user_query 2,0; extracted_steps 1,25; topic 0,75; socratic_resolution 0,25; bonus frasa utuh 6,0 dan prefiks 40 karakter 2,0. Stopword Indonesia/Inggris umum dibuang. Bila tidak ada istilah yang cocok, yang dikembalikan adalah baris milik pengguna itu saja - bukan baris acak lintas pengguna.
# backend/data_engine/agent_harness.py
def retrieve_relevant_memories(self, query, limit=3, user_id=None):
user_key = self._user_key(user_id)
terms = _query_terms(query)
rows = conn.execute(
"SELECT * FROM agent_episodic_memory WHERE user_key = ? "
"ORDER BY updated_at DESC LIMIT 2000",
(user_key,),
).fetchall()
ranked = sorted(rows, key=lambda r: str(r.get("updated_at") or ""), reverse=True)
scored = [(_score_memory(row, terms, query), row) for row in ranked]
scored.sort(key=lambda pair: (
-pair[0], # skor tumpang tindih istilah
-int(pair[1].get("sympy_verified") or 0), # lalu status terverifikasi
-int(pair[1].get("hit_count") or 0), # lalu frekuensi
))
matched = [row for score, row in scored if score > 0]
return matched[:limit] if matched else [row for _, row in scored[:limit]]Setelah itu, hasil korpus yang ber-ID conv_<memory_id> disaring: hanya pemilik memory_id yang boleh melihatnya. Item korpus sintetis/harvester tetap global karena berisi pengetahuan terverifikasi, bukan pertanyaan pribadi.
5.4 Umpan balik & statistik
POST /api/chat/feedback menerima rating up/down atau 1/-1, lalu meneruskannya ke POST /api/v1/harness/feedback. Backend menyimpan baris di agent_feedback. Kolom upvotes, downvotes, dan quality_score pada memori hanya diperbarui bila user_key pemilik memori cocok; umpan balik untuk memori orang lain tetap dicatat tetapi tidak mengubah baris tersebut.
GET /api/harness/stats adalah proxy Next.js yang menyimpan ZAP_HARNESS_SECRET di server. Endpoint backend aslinya secret-gated. Proxy mengembalikan agregat saja dan meng-cache 60 detik dengan stale-while-revalidate=300.
| Metrik (snapshot 14 Nov 2026) | Nilai |
|---|---|
| Memori episodik unik | 77 |
| Total hit seluruh giliran | 216 |
| Memori berstatus sympy_verified | 25 |
| Sebaran tier | SD 1 · SMP 65 · SMA 11 |
| Miskonsepsi tercatat | SIGN_INVERSION 8 · DISTRIBUTIVE 1 · ROOT_LOSS 1 · GENERAL 1 |
| Umpan balik | 1 total · 1 up · 0 down |
| Catatan JSONL | 77 record · 0 baris rusak · delta DB/JSONL 0 |
agent_memory_fts lama tidak dipelihara; retrieval memakai skoring kata di Python sehingga tidak menangkap sinonim atau makna. (2) Setiap retrieval memuat sampai 2.000 baris per pengguna ke memori proses. (3) Pengguna tanpa identitas berbagi keranjang anonymous, meskipun memory_id tetap memisahkan pola yang berbeda. (4) Mirror JSONL dapat tertinggal dari SQLite bila penulisan gagal; endpoint statistik melaporkan delta-nya secara eksplisit. (5) Promosi korpus hanya dari percakapan yang benar-benar lolos CFL, sehingga sistem tidak belajar dari jawaban yang salah.Basis pengetahuan multi-domain
Selain aljabar, repositori ini membangun korpus terverifikasi untuk empat domain. Verifikasinya berbeda per domain karena kebenaran matematis, kebenaran program, dan kebenaran fisika/kimia tidak dapat diperiksa dengan cara yang sama.
| Domain | Struktur | Item di SQLite | Mesin verifikasi |
|---|---|---|---|
| Matematika (TK–S3) | 7 jenjang · 18 topik · 34 subtopik | 320 | SymPy CAS: gerbang boxed, anotasi GSM8K, ekuivalensi simbolik |
| Pemrograman | 3 topik · 6 subtopik | 10 | AST whitelist: eval/exec tanpa builtins, maks 160 node, eksponen bilangan bulat |
| Fisika | 4 topik · 7 subtopik | 10 | Python/SymPy numerik eksak + pengecekan satuan |
| Kimia | 3 topik · 5 subtopik | 10 | Python/SymPy numerik eksak |
Angka item dari audit 14 November 2026: total 350 record SQLite. Audit yang sama melaporkan 12.553 item unik di mirror JSONL - arsip lama yang belum direkonsiliasi, bukan isi database aktif.
6.1 Gerbang verifikasi per domain
- Matematika. GSM8K diverifikasi ulang dari anotasi kalkulator; MATH dan NuminaMath wajib lolos
_boxed_parseable(): isi\boxed{...}dinormalisasi, di-parse SymPy, tidak boleh punya variabel bebas, dan harus menghasilkan nilai yang dapat dievaluasi. Yang gagal tidak masuk korpus. - Pemrograman. Template kode dijalankan di sandbox AST, bukan sandbox OS. Hanya node yang diizinkan, tanpa
__builtins__, dan eksponen harus bilangan bulat. Ini membatasi eksekusi template yang ditulis sendiri, bukan menjalankan kode pengguna. - Fisika & kimia. Jawaban dihitung ulang dengan Python/SymPy pada saat generasi; hasil numerik harus cocok dan jawaban tidak boleh muncul di field yang terlihat pengguna.
6.2 Harvester internet
| Sumber | Status | Format | Verifikasi |
|---|---|---|---|
gsm8k_sample | Aktif | JSONL GitHub OpenAI | Anotasi kalkulator dievaluasi ulang SymPy |
hf_gsm8k_api | Aktif | HuggingFace Datasets Server rows | Gerbang yang sama dengan GSM8K |
wikimedia_math_theorems | Aktif | Wikimedia REST v1 summary | Identitas matematis diperiksa SymPy; gagal = unverified |
indo_kurikulum_sd | Nonaktif | JSONL | URL mengembalikan HTTP 404; tidak pernah dipanggil |
Prinsip harvester: verified-only. Sumber yang gagal atau tidak dapat diverifikasi tidak menghasilkan fallback berlabel verified. Idempotent: item dideduplikasi berdasarkan teks pertanyaan yang dinormalisasi, sehingga menjalankan harvester berulang tidak menggandakan item. Ringkasan setiap putaran ditulis ke data/harvest_state.json (20 putaran terakhir). Putaran terakhir yang tercatat berhasil memasukkan 28 item terverifikasi: 10 dari GSM8K sample, 10 dari HF API, dan 8 dari Wikimedia.
// data/harvest_state.json — ringkasan 20 putaran terakhir
{
"runs": [
{
"finished_at": "2026-11-13T18:00:43.528860+00:00",
"max_items_per_source": 10,
"inserted_verified": 28,
"per_source": {
"gsm8k_sample": { "fetched": 10, "verified": 10, "rejected_unverified": 0 },
"hf_gsm8k_api": { "fetched": 10, "verified": 10, "rejected_unverified": 0 },
"wikimedia_math_theorems": { "fetched": 8, "verified": 8, "rejected_unverified": 0 }
},
"disabled_sources": {
"indo_kurikulum_sd": "HTTP 404 - URL kurikulum tidak tersedia (perlu sumber resmi baru)"
}
}
],
"last_run_at": "2026-11-13T18:00:43.528860+00:00",
"last_inserted_verified": 28
}python .agents/skills/zap-skills/scripts/train_zap_sources.py --harvest --max-items 25. Sumber nonaktif tetap tercatat beserta alasannya agar laporan kejujuran tidak menghitungnya sebagai sumber aktif.6.3 Cara menambah domain
- Tambahkan definisi domain dan subtopik di
taxonomy.py, termasuk mesin verifikasinya. - Tambahkan generator deterministik di
generators.py; hitung jawaban lebih dulu, lalu rakit item lewat_build_domain_item(). - Tambahkan pemeriksaan zero-answer dengan
_answer_absent()agar jawaban tidak muncul di field yang terlihat. - Daftarkan domain di
DOMAIN_NAMES, rebuild korpus, lalu audit.
# 1. backend/data_engine/taxonomy.py
DOMAIN_TAXONOMY["baru"] = KnowledgeDomainDefinition(
domain="baru",
label="…",
description="…",
verification_engine="…", # mesin deterministik yang memutuskan benar/salah
topics=[DomainTopicDefinition(...)],
)
DOMAIN_NAMES = ("matematika", "pemrograman", "fisika", "kimia", "baru")
# 2. backend/data_engine/generators.py
_DOMAIN_ENGINE["baru"] = "…"
def generate_domain_sample(domain: str, idx: int) -> Dict[str, Any]:
# hitung jawaban lebih dulu, lalu rakit item lewat _build_domain_item()
...
# 3. Tambahkan pemeriksaan zero-answer: _answer_absent(answer, *teks_terlihat)
# 4. Rebuild korpus, lalu jalankan audit:
python .agents/skills/zap-skills/scripts/train_zap_sources.py --train --domain baru --count 25 --auditVault terenkripsi zero-knowledge
Vault melindungi percakapan, proyek, preferensi, dan kunci provider di peramban sebelum dikirim ke server. Server hanya menerima envelope terenkripsi dan metadata identitas non-rahasia; passphrase, kunci turunan, dan plaintext tidak pernah dikirim.
| Parameter | Nilai di kode |
|---|---|
| KDF | PBKDF2-SHA256 |
| Iterasi | 310.000 (rekomendasi OWASP 2023 untuk PBKDF2-SHA256) |
| Salt | 16 byte acak per envelope |
| Cipher | AES-GCM-256 |
| Nonce / IV | 12 byte acak (96-bit) |
| Panjang passphrase minimum | 8 karakter |
| Kode pemulihan | 32 karakter Crockford base32, 8 grup × 4 (entropi ±160 bit) |
| Penyimpanan kunci sesi | memori + sessionStorage per tab, bukan localStorage |
// frontend/lib/vault-crypto.ts
export const VAULT_ENVELOPE_VERSION = 1;
export const PBKDF2_ITERATIONS = 310_000; // rekomendasi OWASP 2023
const SALT_BYTES = 16;
const IV_BYTES = 12; // nonce AES-GCM 96-bit
const KEY_BITS = 256;
export interface VaultEnvelope {
v: number;
alg: "AES-GCM";
kdf: { name: "PBKDF2-SHA256"; iterations: number; saltB64: string };
ivB64: string;
ctB64: string;
}
// passphrase + salt → PBKDF2-SHA256 (310.000 iterasi) → AES-GCM-256
const key = await subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: KEY_BITS },
false, // tidak extractable untuk kunci normal
["encrypt", "decrypt"]
);{
"v": 1,
"alg": "AES-GCM",
"kdf": {
"name": "PBKDF2-SHA256",
"iterations": 310000,
"saltB64": "…16 byte salt…"
},
"ivB64": "…12 byte nonce…",
"ctB64": "…ciphertext + authentication tag…"
}Perangkat A Server Next.js
────────── ─────────────
passphrase vault
│ PBKDF2-SHA256 × 310.000
▼
KEK AES-256 (hanya di memori + sessionStorage tab)
│ AES-GCM-256, IV acak 12 byte
▼
envelope { v, alg, kdf, ivB64, ctB64 } ─────────► simpan envelope + metadata:
email, nama, avatar, provider,
tier, updatedAt
(tanpa passphrase / KEK / plaintext)
Perangkat B
│ GET /api/user/sync (Bearer token)
▼
envelope yang sama ──► passphrase yang sama ──► KEK yang sama ──► dekripsi lokal7.1 Token sesi akun vs Google token
| Kredensial | Dibuat oleh | Bentuk | Cara diverifikasi |
|---|---|---|---|
| Token sesi akun ZAP | frontend/lib/account-session.ts | base64url(payload).base64url(HMAC-SHA256), payload berisi email, exp, nonce, versi | HMAC timing-safe dengan ZAP_SESSION_SECRET; TTL bawaan 30 hari; gagal = null (fail-closed) |
| Google ID token | Google Identity Services | JWT dari Google | Diverifikasi ke endpoint tokeninfo; aud harus cocok, email terverifikasi, email token = email sinkronisasi |
Token sesi akun dibuat saat signup/login di /api/auth/credentials dan disimpan klien di localStorage. Bila ZAP_SESSION_SECRET tidak diatur, server membuat secret acak 32 byte dan menyimpannya di .zap_vault/security/session-secret agar sesi tidak putus setiap restart.
7.2 Kode pemulihan
Kode pemulihan vault adalah passphrase alternatif yang dihasilkan perangkat, dinormalkan dengan membuang spasi/strip dan menyeragamkan huruf besar, lalu dipakai untuk menurunkan kunci yang sama. Kode ini berbeda dari token pemulihan akun di /api/admin/security, yang bersifat sekali pakai, dibuat administrator, dan dipakai untuk membuka suspend akun - bukan untuk membuka vault.
updatedAt. (3) Akun tamu tidak memiliki sinkronisasi cloud. (4) Vault hanya sekuat perangkat: bila peramban terinfeksi XSS saat vault terbuka, kunci di memori/sessionStorage dapat dicuri. (5) Sinkronisasi bersifat envelope-terakhir-menang; tidak ada merge CRDT antar-perangkat.Referensi API
Ada dua permukaan API: FastAPI di port 8000 untuk diagnostik/harness, dan route handler Next.js di port 3000 untuk percakapan, sinkronisasi, autentikasi, dan bagi tautan. Base URL backend dibaca dari NEXT_PUBLIC_BACKEND_URL dengan fallback http://127.0.0.1:8000.
8.1 Backend FastAPI
| Metode | Jalur | Auth | Body / parameter | Respons | Status |
|---|---|---|---|---|---|
| GET | /health | Publik | - | status, service, engine, version | 200 |
| POST | /api/v1/diagnose | Publik | confirmed_steps[] atau lines[]; target_variable atau target_var; maks 50 langkah | DiagnoseResponse (transcription, diagnosis, guardrail_status) | 200 · 422 |
| POST | /api/v1/verify | Publik | left, right (maks 200 karakter) | isEquivalent, verified, method, message | 200 · 422 |
| POST | /api/v1/harness/learn | Header X-Harness-Secret | user_message, assistant_reply, explicit_steps, target_variable, tier_hint, user_id | status learned, memory_id, cfl_status, sympy_verified, promoted_to_corpus, hit_count | 200 · 401 · 503 |
| POST | /api/v1/harness/context | Header X-Harness-Secret | query, limit/top_k (1–10), user_id | prompt_injection, episodic_memories, corpus_references, geometry_intent, learner_profile | 200 · 401 · 503 |
| GET | /api/v1/harness/profile | Header X-Harness-Secret | user_id | learner_profile agregat | 200 · 401 · 503 |
| POST | /api/v1/harness/feedback | Header X-Harness-Secret | rating, memory_id, conversation_id, user_id/user_key, comment, cuplikan pesan | status recorded, feedback_id, applied_to_memory | 201 · 401 · 422 · 503 |
| POST | /api/v1/harness/geometry-pattern | Header X-Harness-Secret | phrase, spec | status, pattern | 200 · 401 · 503 |
| GET | /api/v1/harness/geometry-patterns | Header X-Harness-Secret | limit (bawaan 200) | status, total, patterns | 200 · 401 · 503 |
| POST | /api/v1/harness/geometry-match | Header X-Harness-Secret | query | status, matched, match | 200 · 401 · 503 |
| GET | /api/v1/harness/stats | Header X-Harness-Secret | - | memori agregat + corpus + feedback | 200 · 401 · 503 |
| WS | /api/v1/board/{room} | Kode ruang | Frame JSON: seg, clear, notes, state | welcome, join, leave, siaran frame | 101 |
8.2 Route handler Next.js
| Metode | Jalur | Auth | Body / parameter | Respons | Status |
|---|---|---|---|---|---|
| POST | /api/chat | Publik | message, conversationHistory, targetVariable, stream, modelId, kunci provider opsional | JSON reply/model/source atau SSE meta → delta → done | 200 · 400 · 403 |
| POST | /api/chat/feedback | Publik | rating, memoryId, conversationId, cuplikan pesan | diteruskan dari backend | 201 · 400 · 429 · 503 |
| GET | /api/chat/share | Publik (kode tautan) | id | exists, data tanpa authorEmail | 200 · 400 · 403 (dicabut) · 404 |
| POST | /api/chat/share | Publik; authorEmail wajib untuk mengubah/mencabut | conversationId, title, messages, authorEmail, isPublic, shareId | success, shareId, sharePath, altSharePath | 200 · 400 · 403 · 413 · 500 |
| GET | /api/user/sync | Bearer Google token / token sesi | - | vault pengguna (envelope, tanpa kunci) | 200 · 401 · 403 · 429 · 500 |
| POST | /api/user/sync | Bearer Google token / token sesi | user, encrypted envelope, conversations, configs, workspaces | success, encrypted, updatedAt | 200 · 400 · 401 · 403 · 413 · 429 · 500 |
| GET | /api/harness/stats | Publik (proxy) | - | agregat harness | 200 · 502 · 503 |
| POST | /api/auth/credentials | Publik | action signup/login, email, password, name | sessionToken, sessionExpiresAt, user | 200 · 400 · 401 · 409 · 429 |
| GET | /api/admin/security | ADMIN_SECRET atau token pemulihan | action check/list, email, token | status/list | 200 · 400 · 401 · 403 · 405 · 500 |
| POST | /api/admin/security | ADMIN_SECRET atau token pemulihan | action, email, adminSecret, token | success/message | 200 · 400 · 401 · 403 · 500 |
| GET | /api/user/avatar | Publik | url / email | gambar atau 404 | 200 · 400 · 404 · 429 · 500 |
| POST | /api/user/avatar | Publik | email, dataUrl | synced | 200 · 400 · 413 · 429 · 500 |
8.3 Contoh curl
Chat, mode SSE. stream: true membuat server mengirim event meta, potongan delta, lalu done berisi JSON final.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Bantu cek coretan saya: 2x + 6 = 14 -> 2x = 20 -> x = 10",
"stream": true,
"targetVariable": "x"
}'
# Potongan SSE yang diterima:
# event: meta
# data: {"targetVariable":"x"}
#
# event: delta
# data: {"t":"…potongan teks yang sudah disanitasi…"}
#
# event: done
# data: {"reply":"…teks final…","model":"…","source":"…"}Chat, mode JSON. Tanpa stream, respons adalah satu JSON utuh dengan reply, model, source, dan opsional corpus_references.
curl http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Bantu cek coretan saya: 2x + 6 = 14 -> 2x = 20 -> x = 10",
"targetVariable": "x"
}'
# HTTP/1.1 200 OK
# {
# "reply": "…pertanyaan Sokratik yang sudah lolos guardrail…",
# "model": "…",
# "source": "…"
# }
# Bila ada referensi korpus, respons juga memuat "corpus_references".curl http://127.0.0.1:8000/api/v1/diagnose \
-H "Content-Type: application/json" \
-d '{
"confirmed_steps": ["2x + 6 = 14", "2x = 20", "x = 10"],
"target_variable": "x"
}'curl http://127.0.0.1:8000/api/v1/verify \
-H "Content-Type: application/json" \
-d '{"left": "(x+3)^2", "right": "x^2 + 6x + 9"}'# Token = token sesi akun ZAP (HMAC) atau Google ID token.
curl http://localhost:3000/api/user/sync \
-H "Authorization: Bearer $ZAP_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"user": {
"id": "usr_a1b2c3d4e5f6",
"email": "siswa@example.com",
"name": "Siswa Contoh"
},
"encrypted": {
"v": 1,
"alg": "AES-GCM",
"kdf": {
"name": "PBKDF2-SHA256",
"iterations": 310000,
"saltB64": "…"
},
"ivB64": "…",
"ctB64": "…"
}
}'
# 200 OK
# {
# "success": true,
# "encrypted": true,
# "message": "Vault terenkripsi tersimpan (server tidak dapat membacanya).",
# "updatedAt": 1763000000000
# }curl http://localhost:3000/api/chat/share \
-H "Content-Type: application/json" \
-d '{
"conversationId": "conv_123",
"title": "Latihan persamaan linear",
"messages": [
{ "sender": "user", "rawText": "2x + 6 = 14" },
{ "sender": "assistant", "replyText": "Operasi apa yang kamu terapkan pada kedua ruas?" }
],
"authorEmail": "siswa@example.com",
"isPublic": true
}'
# 200 OK
# {
# "success": true,
# "shareId": "…uuid…",
# "isPublic": true,
# "sharePath": "/a/chat/s/…uuid…",
# "altSharePath": "/chat/s/…uuid…",
# "title": "Latihan persamaan linear"
# }Untuk verifikasi cepat: POST http://127.0.0.1:8000/api/v1/verify mengembalikan symbolic_simplify karena SymPy menyederhanakan selisih menjadi 0.
8.4 Rate limit & batas ukuran
| Rute | Batas yang benar-benar ada di kode | Catatan |
|---|---|---|
| POST /api/chat | Tidak ada rate limiter per IP di route | Deteksi jailbreak memberi strike; 2 strike = suspend. Tidak ada cap ukuran body eksplisit. |
| POST /api/chat/feedback | 30 permintaan/menit per IP | Sliding window di memori proses. |
| POST /api/chat/share | Body maks 4 MiB; maks 500 pesan; judul 200 karakter | authorEmail wajib cocok untuk mengubah share yang sudah ada. |
| GET/POST /api/user/sync | 120 permintaan/menit per IP; body maks 8 MiB; ciphertext maks 12 juta karakter | GET juga dibatasi; envelope divalidasi sebelum disimpan. |
| POST /api/auth/credentials | signup 5/menit; login 15/menit per IP | Membatasi brute force; hasil 429 dengan Retry-After. |
| POST /api/auth/verify-key | 10/menit per IP | Digunakan untuk verifikasi kunci provider. |
| GET/POST /api/user/avatar | GET 300/menit; POST 20/menit; gambar maks 2 MiB | Respons data URL dibatasi 512 KiB. |
| POST /api/waitlist | 5/menit per IP | Bukan bagian alur belajar. |
| Backend /api/v1/diagnose | Maks 50 langkah; target_variable maks 5 karakter | 422 untuk daftar kosong, terlalu banyak langkah, atau variabel di luar whitelist. |
| Backend /api/v1/verify | left/right maks 200 karakter | Pydantic menolak input lebih panjang dengan 422. |
| Backend harness | learn: pesan 8.000 karakter; feedback: komentar 1.000 karakter | Context limit/top_k dibatasi 1–10. |
Map dalam memori satu proses Next.js. Pada deployment multi-instans, batas tidak global; komentar di utils/rate-limit.ts secara eksplisit menyarankan Redis untuk itu. Backend juga tidak memiliki rate limiter internal - pembatasan bergantung pada route Next dan reverse proxy.Konfigurasi & self-hosting
NEXT_PUBLIC_APP_URL=…
FASTAPI_URL=…
HARNESS_SECRET=
Variabel lingkungan dibaca di dua tempat. Jangan menaruh rahasia server di variabel berawalan NEXT_PUBLIC_: nilai itu ikut ke bundle peramban.
9.1 Frontend (Next.js)
| Variabel | Wajib? | Kegunaan |
|---|---|---|
NEXT_PUBLIC_BACKEND_URL | Disarankan | Base URL FastAPI; fallback http://127.0.0.1:8000. |
NEXT_PUBLIC_APP_URL | Opsional | metadataBase dan canonical; fallback https://chatzap.web.id. |
NEXT_PUBLIC_GOOGLE_CLIENT_ID | Untuk login Google | Client ID di peramban. |
ZAP_GOOGLE_CLIENT_ID | Untuk sync Google | Audiens token Google yang diverifikasi server. |
ZAP_SESSION_SECRET | Disarankan | Menandatangani token sesi HMAC; minimal 32 karakter agar dipakai. |
ZAP_HARNESS_SECRET | Production | Header X-Harness-Secret untuk semua endpoint harness. |
ADMIN_SECRET | Untuk admin | Akses rute /api/admin/security. |
ZAP_ALLOWED_HOSTS | Di belakang proxy | Daftar host yang dipercaya untuk redirect middleware. |
GEMINI_API_KEY / GOOGLE_API_KEY | Salah satu | Provider bahasa utama bila tersedia. |
NVIDIA_API_KEY | Opsional | NVIDIA NIM, termasuk fallback model cepat. |
BAZAARLINK_API_KEY | Opsional | Gateway DeepSeek/Qwen. |
OPENAI_API_KEY / ANTHROPIC_API_KEY / MISTRAL_API_KEY | Opsional | Provider alternatif. |
ZAP_PLATFORM_LLM_BASE_URL / MODEL / API_KEY | Opsional | Endpoint kompatibel OpenAI milik platform. |
9.2 Backend (FastAPI)
| Variabel | Kegunaan |
|---|---|
CORS_ORIGINS | Daftar origin dipisah koma; default hanya localhost:3000/3105 dan 127.0.0.1. Wildcard hanya bila env berisi *. |
ZAP_HARNESS_SECRET | Bila diisi, header X-Harness-Secret wajib cocok. |
ZAP_ENV | Bila bernilai production dan secret kosong, endpoint harness menolak 503. |
ZAP_DB_PATH | Lokasi SQLite platform/korpus. |
ZAP_JWT_SECRET | Secret JWT platform (dipakai service platform). |
ZAP_MASTER_SECRET / ZAP_MASTER_KEY | Secret master untuk fitur platform; compose membaca ZAP_MASTER_KEY. |
9.3 Menjalankan & menguji
# 1. Backend FastAPI (port 8000)
cd backend
uvicorn main:app --reload --port 8000
# Swagger: http://127.0.0.1:8000/docs
# 2. Frontend Next.js (port 3000)
cd frontend
npm run dev
# 3. Docker Compose (production-like)
docker compose up -d --build
# 4. Pengujian backend
python -m pytest -v -p no:cacheprovider tests/
# 5. Audit 5 pilar + verifikasi suite (satu CLI)
python .agents/skills/zap-skills/scripts/train_zap_sources.py --audit --verify
# 6. Latih korpus prosedural multi-domain
python .agents/skills/zap-skills/scripts/train_zap_sources.py \
--train --tier ALL --domain ALL --count 25docker compose up -d --build menjalankan backend dan frontend. Compose mewajibkan ZAP_JWT_SECRET, ZAP_GOOGLE_CLIENT_ID, ADMIN_SECRET, dan POSTGRES_PASSWORD (untuk service Postgres opsional) terisi; sintaks ${VAR:?pesan} membuat compose berhenti bila kosong. Backend hanya dipublikasikan ke 127.0.0.1:8000, bukan ke semua antarmuka. Service Postgres belum dipakai kode saat ini.
pytest dijalankan dari root repositori agar forwarder tests/ menemukan suite backend. Pada Windows, TestClient dapat memerlukan izin loopback; jika sandbox memblokir socket, jalankan di lingkungan yang mengizinkan bind localhost.Model keamanan
Tujuan model keamanan ini bukan membuat sistem tidak bisa diserang, melainkan membatasi dampak dan menghindari klaim berlebihan. Berikut yang dilindungi dan yang tidak.
10.1 Yang dilindungi
- Rahasia server.
ZAP_HARNESS_SECRET,ADMIN_SECRET, kunci provider, danZAP_SESSION_SECREThanya dibaca di server; proxy statistik ada agar klien tidak perlu memegang secret. - Isi vault. Enkripsi AES-GCM dilakukan di peramban; server menyimpan envelope dan metadata identitas saja.
- Eksekusi parser. Whitelist dan batas eksponen mencegah input berbahaya mencapai
eval()internal SymPy, serta membatasi biayasolveset. - Kebocoran jawaban. Dua lapis guardrail (backend dan frontend) menyensor deklarasi jawaban, termasuk bentuk ber-delimiter yang dulu lolos.
- Isolasi memori. Retrieval memfilter
user_key; referensi promosi percakapan difilter kepemilikannya. - PII. Email, telepon, NIK, dan pola API key diredaksi sebelum disimpan di memori.
- Header & redirect. Middleware memvalidasi host dari header proxy sebelum membangun URL redirect; CSP, HSTS, X-Frame-Options, dan Referrer-Policy dipasang di
next.config.mjs.
10.2 Yang tidak dilindungi
- Perangkat yang sudah kompromi. Selama vault terbuka, kunci berada di memori dan sessionStorage. XSS atau malware di perangkat dapat mencurinya.
- Token di localStorage. Google ID token dan token sesi akun disimpan di
localStorage. Token ini dapat dibaca JavaScript mana pun yang berjalan di origin yang sama. Cookiezap_sessionhanyalah penanda keberadaan sesi, bukan kredensial. - Metadata server. Email, nama, avatar, provider, tier, dan waktu sinkronisasi tersimpan plaintext.
- Rate limit terdistribusi. Limiter berjalan per proses; restart menghapus hitungan, dan multi-instans tidak berbagi state.
- Prompt injection. Deteksi berbasis regex dan aturan; bisa salah tangkap (false positive) dan tidak menjamin semua upaya manipulasi tertangkap. Strike disimpan server-side dan dua pelanggaran menyebabkan suspend.
- Papan kolaborasi. Kode ruang adalah satu-satunya kunci akses; siapa pun yang memegang kode dapat ikut menggambar. Ruang disimpan di memori server dan hilang saat proses berhenti.
10.3 Header keamanan
| Header | Nilai |
|---|---|
Content-Security-Policy | default-src self; script-src self unsafe-inline (dev juga unsafe-eval) accounts.google.com; frame-src accounts.google.com; connect-src self https: plus localhost untuk dev. |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
X-Content-Type-Options | nosniff |
X-Frame-Options | SAMEORIGIN |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
Keterbatasan & roadmap
Daftar ini sengaja eksplisit. Sebagian adalah batas desain yang diterima, sebagian adalah utang teknis yang sudah tercatat di audit.
| Keterbatasan | Dampak | Status / arah perbaikan |
|---|---|---|
| CFL hanya aljabar satu variabel target; maks 50 langkah; sisi maks 200 karakter | Di luar itu sistem memberi klarifikasi atau status tidak dapat diputuskan. | Batas desain; perluas cakupan secara bertahap dengan uji regresi. |
| Derajat polinomial > 12 tidak diputuskan | Persamaan berderajat tinggi bisa berhenti di UNDECIDABLE_STEP. | Disengaja untuk mencegah worker terkunci; perlu strategi komputasi terpisah. |
| Retrieval berbasis kata kunci, FTS tidak dipelihara | Sinonim dan makna tidak tertangkap; kualitas bergantung kosakata pengguna. | Utang teknis tercatat; embedding atau FTS terpelihara adalah kandidat. |
| SQLite 350 record vs JSONL 12.553 record | Laporan berbeda tergantung sumber yang dibaca; JSONL adalah arsip lama. | Rekonsiliasi belum dilakukan agar tidak menghapus tulisan yang sedang berjalan. |
| Satu sumber harvester nonaktif (HTTP 404) | Kurikulum SD tidak mendapat item dari sumber itu. | Perlu URL resmi baru yang dapat diverifikasi. |
| Rate limit per proses, bukan global | Multi-instans dapat melewati batas; restart mereset hitungan. | Perlu penyimpanan bersama (mis. Redis). |
| /api/chat tidak memiliki rate limiter eksplisit | Beban percakapan bergantung pada provider dan reverse proxy. | Perlu limiter di route atau edge. |
| Prompt injection dideteksi dengan regex | False positive dan false negative mungkin terjadi. | Perlu pendekatan berlapis; saat ini strike 2 = suspend. |
| Vault tidak dapat dipulihkan tanpa passphrase/kode | Kehilangan kunci berarti kehilangan data. | Konsekuensi zero-knowledge; tidak ada backdoor. |
| Metadata identitas plaintext di server | Email dan nama tetap terlihat oleh server. | Dapat dienkripsi terpisah, tetapi mengubah alur sinkronisasi. |
| Akun tamu tanpa sinkronisasi cloud | Data tamu hanya di perangkat. | Batas desain; pengguna perlu akun. |
| Sinkronisasi envelope last-write-wins | Dua perangkat yang menulis bersamaan dapat saling menimpa. | Perlu versioning/merge. |
| Papan kolaborasi disimpan di memori | Ruang hilang saat proses berhenti; tidak ada otentikasi selain kode ruang. | Batas desain untuk demo; perlu penyimpanan persisten bila dipakai luas. |
| Model bahasa eksternal dapat gagal/timeout | Kualitas balasan menurun, tetapi fallback Sokratik lokal tetap tersedia. | Fallback sudah ada; pemantauan provider tetap diperlukan. |
Urutan prioritas yang masuk akal
- Rekonsiliasi korpus SQLite dan JSONL, lalu pastikan angka publik selalu membaca satu sumber.
- Ganti rate limiter in-memory dengan penyimpanan bersama dan tambahkan limiter untuk /api/chat.
- Hidupkan kembali FTS atau ganti retrieval dengan indeks yang dipelihara.
- Pindahkan token sesi dari localStorage ke cookie HttpOnly bila arsitektur memungkinkan.
- Tambahkan versioning envelope vault untuk merge antar-perangkat.
Cara memverifikasi dokumen ini. Jalankan GET /api/harness/stats untuk angka memori/korpus, baca backend/cfl/parser.py untuk batas parser, dan buka frontend/lib/zero-answer.ts untuk pola guardrail. Bila halaman ini berbeda dari kode, kode yang benar - dan halaman ini perlu diperbarui.