import os import json import random import re import time import numpy as np # untuk vektorisasi cosine import pypdf # baca file PDF from google import genai # SDK Gemini terbaru from google.genai import types # config types utk panggilan API from pydantic import BaseModel, Field # validasi skema output JSON from rank_bm25 import BM25Okapi # algoritma BM25 (sparse retrieval) # chunk_text & normalize_pdf_text dipisah ke text_utils.py supaya file ini # tetap fokus pada alur RAG, bukan detail pembersihan teks. from py.text_utils import chunk_text, normalize_pdf_text # =================================================================== # KONFIGURASI - semua "tombol putar" sistem ada di sini # =================================================================== 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") # Dua model Gemini yg dipakai: # - EMBEDDING_MODEL : ubah teks jadi vektor # - GENERATION_MODEL : ubah konteks+instruksi jadi soal kuis # Keduanya bisa diganti versi lain tanpa mengubah kode lain di sini. EMBEDDING_MODEL = "gemini-embedding-001" GENERATION_MODEL = "gemini-2.5-flash" # TASK_TYPE adalah "petunjuk" ke model embedding tentang akan dipakai untuk apa. # Setiap pilihan memberi distribusi vektor yang berbeda: # # "RETRIEVAL_QUERY" + "RETRIEVAL_DOCUMENT" # Pasangan ASIMETRIS, dilatih untuk RANKING retrieval terbaik # (Recall@k tinggi). Tapi nilai cosine absolutnya cenderung # rendah (~0.7-0.8), walau peringkatnya sudah benar. # # "SEMANTIC_SIMILARITY" # SIMETRIS — keduanya (kueri & dokumen) berada di ruang yang sama, # dilatih supaya cosine mencerminkan kemiripan sebenarnya. # Nilai antar teks yg berkaitan biasanya 0.85-0.95. # SEMANTIC_SIMILARITY untuk semua embedding. EMBED_TASK_TYPE = "SEMANTIC_SIMILARITY" TOP_K = 15 # berapa chunk konteks dikirim ke LLM. Makin banyak makin # kaya konteks tapi lebih boros token & bisa "noisy". BATCH_SIZE = 100 # API Gemini menerima banyak teks per panggilan. # Kita batch supaya hemat panggilan + aman dari limit. GEN_TEMPERATURE = 0.9 # 0 = kaku/deterministik, 1 = kreatif. # 0.9 = variatif tapi tetap setia ke konteks. # Dinaikkan kalau STRUKTUR data berubah -> database lama auto-rebuild. # Cara aman menghindari "diam-diam pakai data basi yang formatnya beda". SCHEMA_VERSION = 3 # Konstanta RRF (Reciprocal Rank Fusion). 60 = default standar di literatur # (Cormack et al. 2009). Lihat fungsi hybrid_search untuk pemakaiannya. RRF_K = 60 # Konten skripsi punya 3 tingkat kesulitan; tiap PDF dilabeli salah satunya. LEVELS = ["mudah", "sedang", "sulit"] # Mapping dari "topik" yg dikirim frontend -> level chunk yg akan diambil. # Misal frontend kirim "level_1" -> hanya retrieve dari chunk dataset-mudah.pdf. LEVEL_MAP = {"level_1": "mudah", "level_2": "sedang", "level_3": "sulit"} # =================================================================== # AUTHENTIKASI - muat .env supaya GEMINI_API_KEY tersedia di env var # =================================================================== # Kenapa di sini juga? Supaya rag_engine bisa jalan STANDALONE # (mis. saat evaluator dijalankan tanpa lewat main.py). Kalau lewat # uvicorn, main.py sudah memanggil load_dotenv duluan -> panggilan # kedua di sini tidak masalah (no-op, aman). try: from dotenv import load_dotenv load_dotenv() except ImportError: pass # Client SDK baru otomatis membaca API key dari env var # GEMINI_API_KEY atau GOOGLE_API_KEY. client = genai.Client() # =================================================================== # STATE GLOBAL - data yg disimpan di memori selama server hidup # =================================================================== vector_db = [] # list dict: [{"id","text","vector","level","source"}, ...] retrieval_health_score = 0.0 # angka similarity utama (yg dipakai di laporan) _doc_matrix = None # matriks vektor semua chunk (N x D), pre-compute _doc_norms = None # panjang (norm) tiap baris vektor (cache) _bm25_index = None # indeks BM25 untuk pencarian kata kunci # =================================================================== # SKEMA OUTPUT KUIS (Pydantic + structured output) # =================================================================== # Dengan response_schema=list[SoalKuis], Gemini DIJAMIN mengembalikan # JSON yang bentuknya persis seperti ini. Tidak perlu lagi parsing # string manual atau berdoa LLM patuh format. Field(description=...) # menjadi petunjuk kepada model tentang isi field tsb. 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") # =================================================================== # UTIL: retry & tokenizer # =================================================================== 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) # 1s -> 2s -> 4s 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()) # =================================================================== # EMBEDDING - panggil API Gemini untuk ubah teks -> vektor # =================================================================== 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), )) # resp.embeddings = list objek ContentEmbedding; .values = vektor float vectors.extend([e.values for e in resp.embeddings]) return vectors # =================================================================== # INDEX - bangun struktur data yg dipakai berulang utk pencarian # =================================================================== 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) # =================================================================== # HYBRID RETRIEVAL - kombinasi DENSE + BM25 pakai RRF # =================================================================== 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() # Langkah 1: hitung skor dari kedua metode sims = _similarities(query_vec) bm25_scores = _bm25_index.get_scores(_tokenize(query_text)) # Langkah 2: urutkan dari skor tertinggi ke terendah (descending). # np.argsort default ASCENDING -> [::-1] membalik jadi DESCENDING. dense_order = [int(i) for i in np.argsort(sims)[::-1]] bm25_order = [int(i) for i in np.argsort(bm25_scores)[::-1]] # Langkah 3: kalau ada filter level, prioritaskan chunk level itu # dgn cara memajukan posisinya di kedua urutan (others dipindah ke # belakang) -> rank-nya jadi lebih kecil -> RRF score lebih tinggi. 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) # Langkah 4: RRF — jumlahkan 1/(K + rank) dari kedua sumber. # enumerate dari 1 -> rank dimulai dari 1 (bukan 0). 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) # Langkah 5: urutkan dari skor RRF terbesar -> ambil top_n teratas. final = sorted(rrf.keys(), key=lambda i: rrf[i], reverse=True) return final[:top_n] if top_n is not None else final # =================================================================== # KESEHATAN RETRIEVAL (angka utama yg dilaporkan di skripsi) # =================================================================== # Pertanyaan patokan, satu per level. Dipakai untuk mengukur # "seberapa cocok materi di tiap level dgn pertanyaan tipikalnya". 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) # Filter: ambil indeks chunk yg levelnya cocok level_idx = [i for i, c in enumerate(vector_db) if c.get("level") == level] # Cari nilai cosine TERTINGGI di antara chunk level itu 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") # =================================================================== # STAGE 1 - INDEXING (dijalankan sekali di startup) # =================================================================== 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 ===") # --- Coba muat database yang sudah ada --- if os.path.exists(DB_FILE_PATH): with open(DB_FILE_PATH, "r", encoding="utf-8") as f: saved = json.load(f) # Hanya pakai cache kalau MODEL & STRUKTUR-nya sama dgn konfigurasi # saat ini. Kalau model embedding berbeda, dimensi vektor berbeda, # dan vektor lama tidak boleh dipakai (akan beri hasil ngawur). 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...") # --- Bangun database dari PDF --- vector_db = [] chunk_id = 0 print(f"-> Embedding dengan model: {EMBEDDING_MODEL}\n") # Loop per level: tiap PDF dianggap punya konten untuk satu tingkat kesulitan 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 # 1. Baca PDF -> gabungkan teks semua halaman pdf_reader = pypdf.PdfReader(pdf_file_path) raw_text = " ".join([page.extract_text() or "" for page in pdf_reader.pages]) # 2. Bersihkan teks (rapikan spasi, pertahankan apostrof, dst) text_content = normalize_pdf_text(raw_text) # 3. Pecah jadi chunk (~100 kata yg menghormati batas kalimat) chunks = chunk_text(text_content) if not chunks: continue # 4. Embedding semua chunk sekaligus (batched + retry) embeddings = embed_texts(chunks, EMBED_TASK_TYPE) # 5. Simpan ke vector_db DENGAN METADATA (level, source). # Metadata penting supaya retrieval bisa difilter per level # dan supaya nanti bisa menampilkan "soal ini dari PDF apa". for i, ch in enumerate(chunks): vector_db.append({ "id": chunk_id, "text": ch, # teks asli (utk konteks LLM) "vector": embeddings[i], # embedding (utk cari kemiripan) "level": level, # metadata: tingkat kesulitan "source": f"dataset-{level}.pdf", # metadata: asal PDF (sitasi) }) 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 # Simpan ke disk supaya lain kali bisa langsung dimuat (cache). # Sertakan METADATA model & versi schema -> deteksi inkompatibilitas otomatis. with open(DB_FILE_PATH, "w", encoding="utf-8") as f: json.dump({ "schema_version": SCHEMA_VERSION, # versi struktur "model": EMBEDDING_MODEL, # nama model yg dipakai "dim": len(vector_db[0]["vector"]), # dimensi vektor (info) "chunks": vector_db, # data sebenarnya }, f, ensure_ascii=False, indent=4) print(f"-> Selesai. Total {len(vector_db)} blok memori tersimpan.") _rebuild_indexes() calculate_retrieval_health() # =================================================================== # STAGE 2 - RETRIEVAL + GENERATE (dijalankan tiap permintaan kuis) # =================================================================== 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()}) ===") # Pilih kueri & label kesulitan berdasarkan topik yg dikirim frontend 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) # --- Retrieval HYBRID + filter level --- 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] # Gabungkan teks chunk jadi satu blok konteks panjang utk LLM. # "\n\n" antar chunk membantu LLM mengenali batas paragraf. 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") # --- Generate kuis pakai Gemini --- 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() # Structured output: response_mime_type + response_schema membuat # Gemini DIJAMIN mengembalikan JSON valid sesuai skema list[SoalKuis]. # Tidak perlu lagi prompt "jangan pakai markdown" atau regex # untuk membersihkan jawaban! 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], ), )) # response.parsed sudah jadi list[SoalKuis] (objek pydantic). # Fallback ke parse manual kalau entah kenapa parsing internal gagal. parsed = response.parsed if not parsed: parsed = [SoalKuis(**d) for d in json.loads(response.text)] # Validasi terakhir: pastikan 'jawaban' BENAR-BENAR salah satu 'pilihan' # (cocokkan walau whitespace beda). Soal yg jawabannya tidak match # dianggap rusak dan dibuang -> mencegah kuis dgn "soal tanpa jawaban". quiz_data = [] for soal in parsed: d = soal.model_dump() # ubah pydantic obj -> dict biasa match = next((opt for opt in d["pilihan"] if opt.strip() == d["jawaban"].strip()), None) if match is None: continue d["jawaban"] = match # normalisasi nilai jawaban (samakan dgn opsi) quiz_data.append(d) if not quiz_data: raise Exception("AI tidak menghasilkan soal yang valid. Coba klik levelnya lagi!") # Acak urutan soal supaya tiap kali user main, kuisnya terasa baru random.shuffle(quiz_data) print(f"-> BERHASIL: {len(quiz_data)} soal dikirim ke layar!") return quiz_data