|
|
| import os
|
| import json
|
| import random
|
| import re
|
| import time
|
|
|
| import numpy as np
|
| import pypdf
|
| from google import genai
|
| from google.genai import types
|
| from pydantic import BaseModel, Field
|
| from rank_bm25 import BM25Okapi
|
|
|
|
|
|
|
| from py.text_utils import chunk_text, normalize_pdf_text
|
|
|
|
|
|
|
|
|
|
|
| BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| DATASET_DIR = os.path.join(BASE_DIR, "assets", "dataset")
|
| DB_FILE_PATH = os.path.join(DATASET_DIR, "vector_db.json")
|
|
|
|
|
|
|
|
|
|
|
| EMBEDDING_MODEL = "gemini-embedding-001"
|
| GENERATION_MODEL = "gemini-2.5-flash"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| EMBED_TASK_TYPE = "SEMANTIC_SIMILARITY"
|
|
|
| TOP_K = 15
|
|
|
|
|
| BATCH_SIZE = 100
|
|
|
|
|
| GEN_TEMPERATURE = 0.9
|
|
|
|
|
|
|
|
|
| SCHEMA_VERSION = 3
|
|
|
|
|
|
|
| RRF_K = 60
|
|
|
|
|
| LEVELS = ["mudah", "sedang", "sulit"]
|
|
|
|
|
|
|
| LEVEL_MAP = {"level_1": "mudah", "level_2": "sedang", "level_3": "sulit"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| try:
|
| from dotenv import load_dotenv
|
| load_dotenv()
|
| except ImportError:
|
| pass
|
|
|
|
|
|
|
| client = genai.Client()
|
|
|
|
|
|
|
|
|
|
|
| vector_db = []
|
| retrieval_health_score = 0.0
|
| _doc_matrix = None
|
| _doc_norms = None
|
| _bm25_index = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class SoalKuis(BaseModel):
|
| pertanyaan: str = Field(description="Teks pertanyaan kuis")
|
| pilihan: list[str] = Field(description="Tepat 4 opsi jawaban")
|
| jawaban: str = Field(description="Jawaban benar; HARUS sama persis dengan salah satu string di 'pilihan'")
|
| penjelasan: str = Field(description="Penjelasan singkat mengapa jawaban itu benar")
|
|
|
|
|
|
|
|
|
|
|
| def _with_retry(fn, max_attempts=3, base_delay=1.0):
|
| """
|
| Panggil fungsi fn(). Kalau gagal, coba lagi maksimal 3 kali dengan
|
| exponential backoff (1 detik -> 2 detik -> 4 detik). Berguna untuk:
|
| - Hiccup jaringan sesaat
|
| - Rate limit Gemini (HTTP 429)
|
| - Error sementara di server Google
|
| Kalau tetap gagal di percobaan terakhir, error-nya dilemparkan keluar.
|
| """
|
| for attempt in range(max_attempts):
|
| try:
|
| return fn()
|
| except Exception as e:
|
| if attempt == max_attempts - 1:
|
| raise
|
| delay = base_delay * (2 ** attempt)
|
| print(f" [retry] gagal: {e}. Coba lagi dalam {delay:.1f}s...")
|
| time.sleep(delay)
|
|
|
|
|
| def _tokenize(text):
|
| """
|
| Pecah teks jadi daftar kata untuk dipakai BM25.
|
| - Lowercase semua supaya pencocokan case-insensitive.
|
| - Ambil hanya karakter: huruf/angka/Arab/apostrof. Tanda baca dibuang.
|
| - \\u0600-\\u06FF = rentang Unicode untuk aksara Arab.
|
| - Apostrof (') dipertahankan -> penting untuk istilah seperti a'la, i'tidal.
|
| """
|
| return re.findall(r"[\w\u0600-\u06FF']+", text.lower())
|
|
|
|
|
|
|
|
|
|
|
| def embed_texts(texts, task_type):
|
| """
|
| Ubah list teks jadi list vektor (embedding).
|
| - Otomatis dipecah jadi batch supaya tidak melebihi limit API.
|
| - Dilindungi retry untuk hiccup jaringan.
|
|
|
| Output: list of list-of-float. Tiap vektor punya dimensi tetap
|
| (3072 untuk gemini-embedding-001). Vektor inilah yang dipakai
|
| untuk hitung cosine similarity nanti.
|
| """
|
| vectors = []
|
| for i in range(0, len(texts), BATCH_SIZE):
|
| batch = texts[i:i + BATCH_SIZE]
|
| resp = _with_retry(lambda: client.models.embed_content(
|
| model=EMBEDDING_MODEL,
|
| contents=batch,
|
| config=types.EmbedContentConfig(task_type=task_type),
|
| ))
|
|
|
| vectors.extend([e.values for e in resp.embeddings])
|
| return vectors
|
|
|
|
|
|
|
|
|
|
|
| def _rebuild_indexes():
|
| """
|
| Pra-hitung 3 hal SEKALI saja supaya pencarian (yg dipanggil tiap
|
| request) jadi cepat:
|
|
|
| 1. _doc_matrix = matriks NumPy (N chunk x D dimensi). Dengan ini,
|
| cosine seluruh chunk thd kueri bisa dihitung
|
| dengan SATU perkalian matriks (super cepat).
|
| 2. _doc_norms = panjang setiap vektor chunk. Cache supaya tidak
|
| dihitung ulang setiap query.
|
| 3. _bm25_index = indeks BM25 di atas teks chunk. BM25Okapi membangun
|
| struktur internal (IDF, panjang dokumen, dst).
|
| """
|
| global _doc_matrix, _doc_norms, _bm25_index
|
| if vector_db:
|
| _doc_matrix = np.array([it["vector"] for it in vector_db], dtype=np.float32)
|
| _doc_norms = np.linalg.norm(_doc_matrix, axis=1)
|
| _bm25_index = BM25Okapi([_tokenize(c["text"]) for c in vector_db])
|
|
|
|
|
| def _similarities(query_vec):
|
| """
|
| Hitung cosine similarity antara satu vektor kueri vs SEMUA dokumen.
|
|
|
| RUMUS COSINE: cos(a, b) = (a . b) / (|a| * |b|)
|
| a . b = dot product (jumlah perkalian elemen)
|
| |a| = panjang vektor a (L2 norm)
|
| |b| = panjang vektor b
|
|
|
| Implementasi NumPy tervektorisasi (no for-loop, jauh lebih cepat):
|
| _doc_matrix @ q -> dot product semua dokumen dgn kueri sekaligus
|
| _doc_norms * |q| -> penyebut untuk masing-masing
|
| +1e-10 -> cegah pembagian dengan nol (jaga-jaga)
|
|
|
| Output: array berukuran N (jumlah chunk), tiap elemen = skor kemiripan
|
| antara kueri dengan chunk ke-i.
|
| """
|
| q = np.array(query_vec, dtype=np.float32)
|
| denom = (_doc_norms * np.linalg.norm(q)) + 1e-10
|
| return (_doc_matrix @ q) / denom
|
|
|
|
|
| def similarities_for(query_vec):
|
| """Helper publik (dense-only). Dipakai modul lain mis. evaluator.py."""
|
| if _doc_matrix is None:
|
| _rebuild_indexes()
|
| return _similarities(query_vec)
|
|
|
|
|
|
|
|
|
|
|
| def hybrid_search(query_text, query_vec, target_level=None, top_n=None):
|
| """
|
| Pencarian HYBRID = gabungan 2 metode retrieval yg saling melengkapi:
|
|
|
| DENSE (cosine antar embedding):
|
| - Bagus untuk menangkap MAKNA. Mis. "berapa kali sholat sehari"
|
| akan dekat ke chunk "sholat fardhu lima waktu" walau kata-katanya
|
| berbeda.
|
| - Lemah untuk istilah eksak (angka, nama Arab transliterasi).
|
|
|
| BM25 (cocokkan kata kunci):
|
| - Bagus untuk ISTILAH PERSIS, angka, nama. Mis. "2 rakaat" akan
|
| match chunk yg literal mengandung "2 rakaat".
|
| - Lemah untuk parafrase (sinonim tidak dianggap mirip).
|
|
|
| RRF (Reciprocal Rank Fusion):
|
| - Tidak menggabungkan SKOR mentah dari dua metode (karena skalanya
|
| beda — cosine 0..1, BM25 bisa puluhan).
|
| - Menggabungkan RANK (peringkat) saja, jadi adil.
|
| - Rumus: skor(dok) = sum dari 1/(K + rank) di tiap retriever.
|
| - K = 60 = konstanta empiris standar di literatur.
|
| - Dokumen yang tinggi di KEDUA metode -> RRF score paling tinggi.
|
|
|
| target_level (opsional): kalau diisi (mis. "mudah"), chunk dari level
|
| itu diprioritaskan di awal kedua daftar peringkat -> mendapat
|
| skor RRF lebih tinggi -> kemungkinan besar terpilih.
|
| """
|
| if _doc_matrix is None or _bm25_index is None:
|
| _rebuild_indexes()
|
|
|
|
|
| sims = _similarities(query_vec)
|
| bm25_scores = _bm25_index.get_scores(_tokenize(query_text))
|
|
|
|
|
|
|
| dense_order = [int(i) for i in np.argsort(sims)[::-1]]
|
| bm25_order = [int(i) for i in np.argsort(bm25_scores)[::-1]]
|
|
|
|
|
|
|
|
|
| if target_level:
|
| def _level_first(order):
|
| same = [i for i in order if vector_db[i].get("level") == target_level]
|
| other = [i for i in order if vector_db[i].get("level") != target_level]
|
| return same + other
|
| dense_order = _level_first(dense_order)
|
| bm25_order = _level_first(bm25_order)
|
|
|
|
|
|
|
| rrf = {}
|
| for rank, idx in enumerate(dense_order, start=1):
|
| rrf[idx] = rrf.get(idx, 0.0) + 1.0 / (RRF_K + rank)
|
| for rank, idx in enumerate(bm25_order, start=1):
|
| rrf[idx] = rrf.get(idx, 0.0) + 1.0 / (RRF_K + rank)
|
|
|
|
|
| final = sorted(rrf.keys(), key=lambda i: rrf[i], reverse=True)
|
| return final[:top_n] if top_n is not None else final
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| BENCHMARK_QUERIES = [
|
| "Materi sholat anak dasar: nama sholat wajib, waktu pelaksanaan pagi siang malam, jumlah rakaat, dan niat sholat.",
|
| "Materi sholat anak sedang: Syarat sah sholat, rukun sholat, dan tata cara gerakan sholat lengkap dari takbiratul ihram sampai salam.",
|
| "Materi sholat anak sulit: Hafalan bacaan sholat, doa iftitah, bacaan rukuk, bacaan sujud, dan bacaan tahiyat akhir.",
|
| ]
|
|
|
|
|
| def calculate_retrieval_health():
|
| """
|
| Hitung skor "kemiripan retrieval" yg ditampilkan ke user / laporan.
|
|
|
| Cara kerja:
|
| Untuk setiap level, embed pertanyaan patokannya dgn task_type yang
|
| sama dgn embedding chunk. Lalu cari chunk PALING MIRIP di DALAM
|
| level itu. Skor = cosine top-1. Rata-rata ketiga level = skor global.
|
|
|
| Skor ini = indikator: "apakah materi di tiap level memang relevan
|
| dgn pertanyaan tipikal level itu?"
|
| Karena task_type-nya SEMANTIC_SIMILARITY, nilai biasanya 0.85-0.95.
|
| """
|
| global retrieval_health_score
|
| print("-> Mengecek kesehatan retrieval per-level...")
|
|
|
| q_embeddings = embed_texts(BENCHMARK_QUERIES, EMBED_TASK_TYPE)
|
| scores = []
|
| for level, qv in zip(LEVELS, q_embeddings):
|
| sims = _similarities(qv)
|
|
|
| level_idx = [i for i, c in enumerate(vector_db) if c.get("level") == level]
|
|
|
| best = float(sims[level_idx].max()) if level_idx else 0.0
|
| scores.append(best)
|
| print(f" {level:<8}: top-1 = {best * 100:.1f}%")
|
|
|
| retrieval_health_score = sum(scores) / len(scores) if scores else 0.0
|
| print(f" Rata-rata: {retrieval_health_score * 100:.1f}%\n")
|
|
|
|
|
|
|
|
|
|
|
| def setup_vector_db():
|
| """
|
| Bangun database vektor dari PDF. Dipanggil di startup server.
|
| - Kalau sudah ada vector_db.json yg KOMPATIBEL -> langsung dimuat (cepat).
|
| - Kalau belum ada / model berubah / schema berubah -> bangun ulang dari PDF.
|
| """
|
| global vector_db
|
| print("\n=== [STAGE 1] PROSES LEARNING & INDEXING SISTEM RAG ===")
|
|
|
|
|
| if os.path.exists(DB_FILE_PATH):
|
| with open(DB_FILE_PATH, "r", encoding="utf-8") as f:
|
| saved = json.load(f)
|
|
|
|
|
|
|
|
|
| compatible = (
|
| isinstance(saved, dict)
|
| and saved.get("model") == EMBEDDING_MODEL
|
| and saved.get("schema_version") == SCHEMA_VERSION
|
| )
|
| if compatible:
|
| vector_db = saved["chunks"]
|
| print(f"-> Memuat Ingatan AI: {len(vector_db)} memori dipulihkan.")
|
| _rebuild_indexes()
|
| calculate_retrieval_health()
|
| return
|
| else:
|
| print("-> Model/struktur berubah. Membangun ulang database...")
|
|
|
|
|
| vector_db = []
|
| chunk_id = 0
|
| print(f"-> Embedding dengan model: {EMBEDDING_MODEL}\n")
|
|
|
|
|
| for level in LEVELS:
|
| pdf_file_path = os.path.join(DATASET_DIR, f"dataset-{level}.pdf")
|
| if not os.path.exists(pdf_file_path):
|
| print(f" PERINGATAN: {pdf_file_path} tidak ditemukan!")
|
| continue
|
|
|
|
|
| pdf_reader = pypdf.PdfReader(pdf_file_path)
|
| raw_text = " ".join([page.extract_text() or "" for page in pdf_reader.pages])
|
|
|
|
|
| text_content = normalize_pdf_text(raw_text)
|
|
|
|
|
| chunks = chunk_text(text_content)
|
| if not chunks:
|
| continue
|
|
|
|
|
| embeddings = embed_texts(chunks, EMBED_TASK_TYPE)
|
|
|
|
|
|
|
|
|
| for i, ch in enumerate(chunks):
|
| vector_db.append({
|
| "id": chunk_id,
|
| "text": ch,
|
| "vector": embeddings[i],
|
| "level": level,
|
| "source": f"dataset-{level}.pdf",
|
| })
|
| chunk_id += 1
|
| print(f" dataset-{level}.pdf -> {len(chunks)} chunk dipelajari")
|
|
|
| if not vector_db:
|
| print("-> GAGAL: Tidak ada satupun materi yang berhasil dipelajari.")
|
| return
|
|
|
|
|
|
|
| with open(DB_FILE_PATH, "w", encoding="utf-8") as f:
|
| json.dump({
|
| "schema_version": SCHEMA_VERSION,
|
| "model": EMBEDDING_MODEL,
|
| "dim": len(vector_db[0]["vector"]),
|
| "chunks": vector_db,
|
| }, f, ensure_ascii=False, indent=4)
|
|
|
| print(f"-> Selesai. Total {len(vector_db)} blok memori tersimpan.")
|
| _rebuild_indexes()
|
| calculate_retrieval_health()
|
|
|
|
|
|
|
|
|
|
|
| def generate_quiz(topik):
|
| """
|
| Pipeline lengkap untuk satu permintaan kuis dari frontend:
|
| 1. Embed pertanyaan user
|
| 2. Cari top-K chunk paling relevan (hybrid + filter level)
|
| 3. Kirim chunk + instruksi ke Gemini -> dapat 5 soal kuis JSON
|
| 4. Validasi & acak urutan -> kembalikan ke frontend
|
| """
|
| if not vector_db:
|
| raise Exception("Vektor DB kosong! Jalankan setup_vector_db() dulu.")
|
| if _doc_matrix is None or _bm25_index is None:
|
| _rebuild_indexes()
|
|
|
| print(f"\n=== [STAGE 2] RETRIEVAL & GENERATE ({topik.upper()}) ===")
|
|
|
|
|
| if topik == "level_1":
|
| user_query = BENCHMARK_QUERIES[0]
|
| tingkat_kesulitan = "SANGAT MUDAH (Kelas 1 SD)"
|
| elif topik == "level_2":
|
| user_query = BENCHMARK_QUERIES[1]
|
| tingkat_kesulitan = "SEDANG (Kelas 3 SD)"
|
| elif topik == "level_3":
|
| user_query = BENCHMARK_QUERIES[2]
|
| tingkat_kesulitan = "SULIT (Kelas 6 SD)"
|
| else:
|
| user_query = "Informasi sholat dan keutamaannya."
|
| tingkat_kesulitan = "UMUM"
|
|
|
| target_level = LEVEL_MAP.get(topik)
|
|
|
|
|
| query_vec = embed_texts([user_query], EMBED_TASK_TYPE)[0]
|
| top_indices = hybrid_search(user_query, query_vec,
|
| target_level=target_level, top_n=TOP_K)
|
| top_chunks = [vector_db[i] for i in top_indices]
|
|
|
|
|
|
|
| context_text = "\n\n".join(c["text"] for c in top_chunks)
|
| sources = [c.get("source", "?") for c in top_chunks]
|
|
|
| print(f" Kemiripan retrieval (global health): {retrieval_health_score * 100:.1f}%")
|
| print(f" Filter level: {target_level or 'semua'}")
|
| print(f" {len(top_chunks)} paragraf konteks; asal: {dict((s, sources.count(s)) for s in set(sources))}\n")
|
|
|
|
|
| print(f"-> Merakit kuis dengan: {GENERATION_MODEL}")
|
| prompt = f"""
|
| Kamu adalah pembuat soal kuis untuk anak. Berdasarkan MATERI di bawah,
|
| buat 5 soal pilihan ganda tentang sholat.
|
|
|
| MATERI:
|
| {context_text}
|
|
|
| ATURAN ISI SOAL:
|
| - Sesuaikan gaya bahasa & tingkat kesulitan menjadi: {tingkat_kesulitan}.
|
| - Sapa anak dengan sebutan 'Anak Pintar' bila cocok.
|
| - Wajib minimal 2 soal berbentuk SOAL CERITA / studi kasus sehari-hari,
|
| memakai nama anak (Andi, Budi, Aisyah, Fatimah, dll).
|
| Contoh: "Saat bel istirahat berbunyi, Andi melihat bayangan benda sama
|
| panjang dengan aslinya. Sholat apa yang harus Andi kerjakan?"
|
| - Gali detail unik dari materi; hindari soal teori yang kaku dan berulang.
|
| - Setiap soal punya tepat 4 pilihan, dan 'jawaban' HARUS sama persis dengan
|
| salah satu teks di 'pilihan'.
|
| """.strip()
|
|
|
|
|
|
|
|
|
|
|
| response = _with_retry(lambda: client.models.generate_content(
|
| model=GENERATION_MODEL,
|
| contents=prompt,
|
| config=types.GenerateContentConfig(
|
| temperature=GEN_TEMPERATURE,
|
| response_mime_type="application/json",
|
| response_schema=list[SoalKuis],
|
| ),
|
| ))
|
|
|
|
|
|
|
| parsed = response.parsed
|
| if not parsed:
|
| parsed = [SoalKuis(**d) for d in json.loads(response.text)]
|
|
|
|
|
|
|
|
|
| quiz_data = []
|
| for soal in parsed:
|
| d = soal.model_dump()
|
| match = next((opt for opt in d["pilihan"] if opt.strip() == d["jawaban"].strip()), None)
|
| if match is None:
|
| continue
|
| d["jawaban"] = match
|
| quiz_data.append(d)
|
|
|
| if not quiz_data:
|
| raise Exception("AI tidak menghasilkan soal yang valid. Coba klik levelnya lagi!")
|
|
|
|
|
| random.shuffle(quiz_data)
|
| print(f"-> BERHASIL: {len(quiz_data)} soal dikirim ke layar!")
|
| return quiz_data |