import os import sys import re import json import gradio as gr from pathlib import Path from groq import Groq sys.path.insert(0, ".") from src.data_loader import load_corpus, load_graph, link_corpus_to_graph, enrich_with_graph_topics from src.retrieval.cgir_pipeline import CGIRPipeline from src.retrieval.graph_navigator import mastery_to_bloom from src.tracing.bkt import BayesianKnowledgeTracing, BKTParams, ObservationRecord # ── Constants ───────────────────────────────────────────────────────────────── MAX_QUESTIONS = 15 DEFAULT_BKT_PARAMS = BKTParams( p_init = 0.10, p_transit = 0.05, p_slip = 0.15, p_guess = 0.45, ) BLOOM_EMOJI = {1:"🟢", 2:"🔵", 3:"🟡", 4:"🟠", 5:"🔴", 6:"🟣"} BLOOM_LABELS = { 1:"Mengingat", 2:"Memahami", 3:"Mengaplikasikan", 4:"Menganalisis", 5:"Mengevaluasi", 6:"Mencipta", } # ── Load data + index ───────────────────────────────────────────────────────── print("Loading corpus dan graph...") documents = load_corpus("data/Knowledge_Base_with_ID.csv") graph = load_graph("data/knowledge_graph.json") doc_graph_map = link_corpus_to_graph(documents, graph) documents = enrich_with_graph_topics(documents, graph, doc_graph_map) cgir = CGIRPipeline(graph=graph, doc_to_graph_id=doc_graph_map, bloom_window=1) if Path("faiss_index/faiss.index").exists(): print("Loading saved FAISS index...") cgir.load("faiss_index") for doc_idx, doc in enumerate(cgir.faiss.documents): graph_id = doc_graph_map.get(doc["id"]) if graph_id: cgir._graph_to_docs.setdefault(graph_id, []).append(doc_idx) else: print("Building FAISS index...") cgir.build(documents) Path("faiss_index").mkdir(parents=True, exist_ok=True) cgir.save("faiss_index") # ── Groq judge — continuous 0.0–1.0 ────────────────────────────────────────── _groq = Groq(api_key=os.environ.get("GROQ_API_KEY", "")) JUDGE_PROMPT = """\ Kamu adalah penilai jawaban siswa untuk pelajaran IPS/Geografi SMA. Gunakan konteks berikut sebagai referensi utama: {context} Soal: {question} Jawaban siswa: {answer} Nilai jawaban dalam skala 0.0 hingga 1.0: 1.00 = benar sempurna dan lengkap 0.75 = sebagian besar benar, ada yang kurang 0.50 = setengah benar, inti ada tapi banyak yang hilang 0.25 = sedikit benar, kebanyakan salah 0.00 = salah total atau tidak relevan Berikan penilaian dalam format JSON berikut (tanpa teks lain): {{"score": 0.0 hingga 1.0, "feedback": "kalimat singkat 1-2 kalimat menjelaskan kenapa benar/salah"}} """ def judge_answer(question: str, answer: str, context: str = "") -> dict: prompt = JUDGE_PROMPT.format( context=context[:2000] if context else "Tidak ada konteks.", question=question, answer=answer, ) try: resp = _groq.chat.completions.create( model="llama-3.3-70b-versatile", max_tokens=256, temperature=0.1, messages=[{"role": "user", "content": prompt}], ) raw = resp.choices[0].message.content.strip() raw = re.sub(r"^```json\s*|```$", "", raw, flags=re.MULTILINE).strip() result = json.loads(raw) score = float(result.get("score", 0.0)) return { "score": min(max(score, 0.0), 1.0), "feedback": str(result.get("feedback", "")), } except Exception as e: return {"score": 0.0, "feedback": f"[Error: {e}]"} # ── Weighted BKT update — continuous score ──────────────────────────────────── def bkt_update_continuous( bkt_inst, concept: str, score: float, bloom_level: int = 1, question_id: str = "" ) -> float: params = bkt_inst.concept_params.get(concept, bkt_inst.default_params) p_l = bkt_inst.get_mastery(concept) p_obs_c = (1 - params.p_slip) * p_l + params.p_guess * (1 - p_l) p_l_if_correct = ((1 - params.p_slip) * p_l) / max(p_obs_c, 1e-12) p_next_correct = p_l_if_correct + (1 - p_l_if_correct) * params.p_transit p_obs_w = params.p_slip * p_l + (1 - params.p_guess) * (1 - p_l) p_l_if_wrong = (params.p_slip * p_l) / max(p_obs_w, 1e-12) p_next_wrong = p_l_if_wrong + (1 - p_l_if_wrong) * params.p_transit p_l_next = score * p_next_correct + (1 - score) * p_next_wrong p_l_next = min(max(p_l_next, 0.0), 1.0) bkt_inst._mastery[concept] = p_l_next bkt_inst.history.append(ObservationRecord( concept=concept, correct=score >= 0.5, mastery_before=p_l, mastery_after=p_l_next, bloom_level=bloom_level, question_id=question_id, )) return p_l_next # ── Session helpers ─────────────────────────────────────────────────────────── def create_session(keyword: str) -> dict: return { "bkt": BayesianKnowledgeTracing(default_params=DEFAULT_BKT_PARAMS), "seen_ids": set(), "consecutive_wrong": 0, "keyword": keyword.strip(), "q_num": 0, "current_q": None, "active": True, "correct_count": 0, "wrong_count": 0, } def get_mastery(session: dict) -> float: bkt = session.get("bkt") if not bkt: return 0.0 return bkt.get_mastery(session.get("keyword", "").lower().strip()) def get_next_question(session: dict): keyword = session["keyword"] mastery = get_mastery(session) consecutive_wrong = session["consecutive_wrong"] effective = mastery if consecutive_wrong >= 2: effective = max(0.0, mastery - 0.15 * consecutive_wrong) target_bloom = mastery_to_bloom(effective) candidates = cgir.retrieve_candidates(keyword, mastery=effective, top_k=20) unseen = [c for c in candidates if c.question.get("id") not in session["seen_ids"]] if not unseen: return None # Prioritas 1: exact bloom match exact = [c for c in unseen if c.question.get("bloom_level") == target_bloom] if exact: return exact[0] # Prioritas 2: satu level di bawah below = [c for c in unseen if c.question.get("bloom_level") == target_bloom - 1] if below: return below[0] return unseen[0] def add_msg(chat: list, role: str, content: str) -> list: return chat + [{"role": role, "content": content}] # ── Stats panel ─────────────────────────────────────────────────────────────── INITIAL_STATS = """
📊
Statistik kamu akan
muncul di sini
""" def build_stats_html(session: dict) -> str: if not session or session.get("q_num", 0) == 0: return INITIAL_STATS mastery = get_mastery(session) q_num = session.get("q_num", 0) correct = session.get("correct_count", 0) wrong = session.get("wrong_count", 0) answered = correct + wrong active = session.get("active", False) acc_pct = int(correct / answered * 100) if answered > 0 else 0 target_bloom = mastery_to_bloom(mastery) b_emoji = BLOOM_EMOJI.get(target_bloom, "") b_label = BLOOM_LABELS.get(target_bloom, "") if mastery >= 0.7: bar_color = "#4CAF50" elif mastery >= 0.4: bar_color = "#2196F3" else: bar_color = "#90A4AE" if not active: badge_bg, badge_color, badge_text = "#E8F5E9", "#2E7D32", "🏁 Sesi Selesai" elif q_num >= MAX_QUESTIONS * 0.7: sisa = MAX_QUESTIONS - q_num + 1 badge_bg, badge_color = "#FFF3E0", "#E65100" badge_text = f"⏳ {sisa} soal lagi" else: badge_bg, badge_color, badge_text = "#E3F2FD", "#1565C0", "📈 Terus semangat!" return f"""
📊 Progress Kamu
Mastery {mastery:.2f}
Progress Soal {q_num} / {MAX_QUESTIONS}
{correct}
✅ Benar
{wrong}
❌ Salah
{acc_pct}%
Akurasi
LEVEL KOGNITIF
{b_emoji} C{target_bloom} — {b_label}
{badge_text}
""" # ── Question message ────────────────────────────────────────────────────────── def build_question_msg(result, q_num: int, mastery: float) -> str: q = result.question bloom = q.get("bloom_level", 1) context = q.get("context", "") lines = [ f"### Soal {q_num} / {MAX_QUESTIONS}", ( f"{BLOOM_EMOJI.get(bloom,'')} **C{bloom} — {BLOOM_LABELS.get(bloom,'')}** " f" |  Mastery: **{mastery:.2f}**" ), f"*{q.get('topic', '')} — {q.get('concept', '')}*", ] if context: lines += ["", "---", "**📖 Bacaan:**", "", context, "", "---"] lines += ["", f"**{q.get('question', '')}**"] return "\n".join(lines) # ── Event handlers ──────────────────────────────────────────────────────────── def start_quiz(keyword: str, session: dict): if not keyword.strip(): return session, [], gr.update(interactive=False), "Masukkan topik terlebih dahulu.", INITIAL_STATS session = create_session(keyword) result = get_next_question(session) if not result: session["active"] = False return session, [], gr.update(interactive=False), "Tidak ada soal untuk topik ini.", INITIAL_STATS session["q_num"] = 1 session["current_q"] = result session["seen_ids"].add(result.question.get("id")) mastery = get_mastery(session) chat = add_msg([], "assistant", build_question_msg(result, 1, mastery)) progress = f"**{keyword}**  |  Soal 1/{MAX_QUESTIONS}  |  Mastery: {mastery:.2f}" return session, chat, gr.update(interactive=True), progress, build_stats_html(session) def submit_answer(answer: str, session: dict, chat: list): if not answer.strip() or not session.get("active"): return session, chat, "", "", build_stats_html(session) keyword = session["keyword"] concept_key = keyword.lower().strip() q = session["current_q"].question context = q.get("context", "") chat = add_msg(chat, "user", answer) # ── Judge ───────────────────────────────────────────────────────────────── verdict = judge_answer(q.get("question", ""), answer, context) score = verdict["score"] feedback = verdict["feedback"] if score >= 0.75: label = "✅ BENAR" elif score >= 0.5: label = "⚠️ SEBAGIAN BENAR" else: label = "❌ SALAH" # ── Update counters ─────────────────────────────────────────────────────── if score >= 0.5: session["correct_count"] = session.get("correct_count", 0) + 1 session["consecutive_wrong"] = 0 else: session["wrong_count"] = session.get("wrong_count", 0) + 1 session["consecutive_wrong"] += 1 # ── Weighted BKT update ─────────────────────────────────────────────────── old_mastery = get_mastery(session) bkt_update_continuous( session["bkt"], concept_key, score=score, bloom_level=q.get("bloom_level", 1), question_id=q.get("id", ""), ) new_mastery = get_mastery(session) verdict_msg = ( f"{label} **(score: {score:.2f})**\n\n" f"💬 {feedback}\n\n" f"📈 Mastery: {old_mastery:.3f} → **{new_mastery:.3f}**" ) chat = add_msg(chat, "assistant", verdict_msg) # ── Soal terakhir → sesi selesai ───────────────────────────────────────── if session["q_num"] >= MAX_QUESTIONS: answered = session["correct_count"] + session["wrong_count"] acc = int(session["correct_count"] / answered * 100) if answered > 0 else 0 chat = add_msg(chat, "assistant", f"🎉 **Sesi selesai!**\n\n" f"Mastery akhir: **{new_mastery:.3f}**  |  " f"{session['correct_count']}/{answered} benar ({acc}% akurasi)" ) session["active"] = False return session, chat, "", f"✅ Selesai | {keyword} | Mastery: {new_mastery:.3f}", build_stats_html(session) if session["consecutive_wrong"] >= 2: chat = add_msg(chat, "assistant", "🔽 *Mencari soal lebih mudah...*") session["q_num"] += 1 next_result = get_next_question(session) if not next_result: chat = add_msg(chat, "assistant", "📚 Semua soal tersedia sudah diberikan.") session["active"] = False return session, chat, "", f"Selesai | Mastery: {new_mastery:.3f}", build_stats_html(session) session["current_q"] = next_result session["seen_ids"].add(next_result.question.get("id")) chat = add_msg(chat, "assistant", build_question_msg(next_result, session["q_num"], new_mastery)) progress = f"**{keyword}**  |  Soal {session['q_num']}/{MAX_QUESTIONS}  |  Mastery: {new_mastery:.3f}" return session, chat, "", progress, build_stats_html(session) def reset_quiz(): return {}, [], gr.update(interactive=False), "Masukkan topik untuk mulai.", INITIAL_STATS # ── UI ──────────────────────────────────────────────────────────────────────── with gr.Blocks( theme=gr.themes.Soft(primary_hue="blue", secondary_hue="indigo"), title="CG-IR Adaptive Quiz", ) as demo: gr.Markdown( "# 🎓 CG-IR Adaptive Quiz\n" "**IPS / Geografi SMA** — Sistem kuis adaptif berbasis " "Knowledge Graph · FAISS · Bayesian Knowledge Tracing" ) session_state = gr.State({}) with gr.Row(): with gr.Column(scale=4): with gr.Row(): keyword_input = gr.Textbox( label="Topik", placeholder="contoh: pancasila, kearifan lokal, perang dunia...", lines=1, scale=5, ) start_btn = gr.Button("▶️ Mulai", variant="primary", scale=1) reset_btn = gr.Button("🔄", scale=0, min_width=52) progress_display = gr.Markdown("Masukkan topik untuk mulai.") chatbot = gr.Chatbot(label="Sesi Quiz", height=500) with gr.Row(): answer_input = gr.Textbox( label="Jawaban", placeholder="Ketik jawaban lalu tekan Enter atau klik Submit...", lines=3, scale=5, interactive=False, ) submit_btn = gr.Button("📨 Submit", variant="primary", scale=1) with gr.Column(scale=1, min_width=220): stats_panel = gr.HTML(value=INITIAL_STATS) start_btn.click( fn=start_quiz, inputs=[keyword_input, session_state], outputs=[session_state, chatbot, answer_input, progress_display, stats_panel], ) submit_btn.click( fn=submit_answer, inputs=[answer_input, session_state, chatbot], outputs=[session_state, chatbot, answer_input, progress_display, stats_panel], ) answer_input.submit( fn=submit_answer, inputs=[answer_input, session_state, chatbot], outputs=[session_state, chatbot, answer_input, progress_display, stats_panel], ) reset_btn.click( fn=reset_quiz, inputs=[], outputs=[session_state, chatbot, answer_input, progress_display, stats_panel], ) if __name__ == "__main__": demo.launch()