Firmansyah-Ibrahim commited on
Commit
6cf8a7c
Β·
verified Β·
1 Parent(s): 134fe0f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +104 -572
app.py CHANGED
@@ -1,71 +1,24 @@
1
  # =============================================================================
2
- # INDO-BLOOM PIPELINE v1.0 β€” INTEGRATED (SINGLE FILE)
3
  #
4
- # Menggabungkan:
5
- # TAB 1 β€” IBEX v2.4 : Ekstrak context bersih dari PDF BSE/Wikipedia
6
- # TAB 2 β€” LocalQA v1.0: Generate QA pairs C1+C2 dari context (GPU lokal)
7
- # TAB 3 β€” Pipeline : Jalankan IBEX β†’ QA sekaligus dari PDF ke corpus
8
  #
9
- # Requirements:
10
- # pip install gradio pymupdf pandas transformers accelerate torch
11
- #
12
- # Kaggle: aktifkan GPU T4 di Settings β†’ Accelerator
13
  # =============================================================================
14
-
15
  import subprocess, sys
16
  subprocess.run([sys.executable, "-m", "pip", "install", "-q",
17
- "gradio", "pymupdf", "pandas",
18
- "transformers", "accelerate"], check=False)
19
 
20
- import os, re, json, hashlib, time, tempfile
21
  import pandas as pd
22
  import fitz # PyMuPDF
23
  import gradio as gr
24
 
25
- # ── Lazy-load model (hanya saat Tab 2/3 dipakai) ──────────────────────────
26
- import torch
27
- _tokenizer = None
28
- _model = None
29
- _device = "cuda" if torch.cuda.is_available() else "cpu"
30
- MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
31
-
32
- def muat_model():
33
- global _tokenizer, _model
34
- if _tokenizer is None:
35
- from transformers import AutoTokenizer, AutoModelForCausalLM
36
- print(f"πŸ”„ Memuat {MODEL_NAME} ke {_device.upper()}...")
37
- _tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
38
-
39
- if _device == "cuda":
40
- # GPU tersedia β†’ load ke VRAM penuh, fp16
41
- _model = AutoModelForCausalLM.from_pretrained(
42
- MODEL_NAME,
43
- torch_dtype=torch.float16,
44
- device_map="cuda",
45
- trust_remote_code=True,
46
- )
47
- else:
48
- # CPU-only (HF Spaces gratis) β†’ JANGAN pakai device_map="auto"
49
- # device_map="auto" pada CPU menyebabkan layer offload ke 'meta'
50
- # yang menghasilkan RuntimeError: Tensor on device cpu != meta
51
- # Solusi: load ke CPU secara eksplisit tanpa device_map
52
- _model = AutoModelForCausalLM.from_pretrained(
53
- MODEL_NAME,
54
- torch_dtype=torch.float32, # fp32 lebih stabil di CPU
55
- device_map=None, # ← KUNCI: tidak pakai auto
56
- low_cpu_mem_usage=True, # kurangi peak RAM saat loading
57
- trust_remote_code=True,
58
- )
59
- _model = _model.to("cpu") # pastikan semua layer di CPU
60
-
61
- _model.eval()
62
- print(f"βœ… Model siap di {_device.upper()}.")
63
- return _tokenizer, _model
64
-
65
  # ══════════════════════════════════════════════════════════════════════════════
66
- # BAGIAN A β€” IBEX v2.4: KONSTANTA & FUNGSI EKSTRAKSI
67
  # ══════════════════════════════════════════════════════════════════════════════
68
-
69
  NOISE_BSE = [
70
  r'tujuan pembelajaran', r'setelah mempelajari',
71
  r'diharapkan (mampu|dapat)', r'kata kunci', r'pemetaan pikiran',
@@ -109,11 +62,12 @@ KALIMAT_INSTRUKSIONAL = [
109
  r'kalian\s+telah\s+(mengetahui|mempelajari|memahami)\s+bahwa',
110
  r'apa\s+yang\s+dimaksud.{0,80}\?$',
111
  ]
112
- KALIMAT_PATTERNS = [re.compile(p, re.IGNORECASE) for p in KALIMAT_INSTRUKSIONAL]
113
- HEADER_BAB_PAT = re.compile(r'bab\s+\d+\s*\|\s*[\w\s]+\d+', re.IGNORECASE)
114
- _MAPEL = (r'sosiologi|matematika|fisika|kimia|biologi|sejarah|geografi|'
115
- r'ekonomi|pkn|pendidikan|prakarya|seni|bahasa')
116
- KATA_KUNCI_C2 = [
 
117
  'karena','menyebabkan','berdampak','sehingga','mengakibatkan','berakibat',
118
  'oleh karena','disebabkan','bertujuan','berfungsi','berperan','berguna',
119
  'tujuan','fungsi','manfaat','peran','kegunaan','mekanisme','tahapan',
@@ -121,6 +75,9 @@ KATA_KUNCI_C2 = [
121
  'keterkaitan','mengapa','bagaimana','jelaskan','uraikan',
122
  ]
123
 
 
 
 
124
  def hitung_noise(t):
125
  return sum(1 for p in NOISE_PATTERNS if p.search(t))
126
 
@@ -162,10 +119,6 @@ def bersihkan_kalimat(teks):
162
 
163
  def ekstrak_context_dari_pdf(pdf_path, hal_mulai, hal_selesai,
164
  chunk_size, batas_noise, nama_sumber="BSE"):
165
- """
166
- Core extractor IBEX v2.4.
167
- Return: (df_chunks, pesan_status, tmp_csv_path)
168
- """
169
  hal_mulai = int(hal_mulai)
170
  hal_selesai = int(hal_selesai)
171
  chunk_size = int(chunk_size)
@@ -180,7 +133,7 @@ def ekstrak_context_dari_pdf(pdf_path, hal_mulai, hal_selesai,
180
  if start >= end:
181
  return None, f"❌ Rentang tidak valid. PDF punya {total} halaman.", None
182
 
183
- # Ekstrak teks
184
  teks_per_hal = []
185
  for num in range(start, end):
186
  page = doc.load_page(num)
@@ -209,9 +162,9 @@ def ekstrak_context_dari_pdf(pdf_path, hal_mulai, hal_selesai,
209
  while j < len(kalimat_list) and len(buf) < chunk_size:
210
  buf.extend(kalimat_list[j].split())
211
  j += 1
212
- pot = " ".join(buf)
213
- wc = len(buf)
214
- noise = hitung_noise(pot)
215
  ada_c2 = any(k in pot.lower() for k in KATA_KUNCI_C2)
216
 
217
  if wc >= 50 and ada_c2:
@@ -239,163 +192,31 @@ def ekstrak_context_dari_pdf(pdf_path, hal_mulai, hal_selesai,
239
  if not chunk_meta:
240
  return None, (
241
  f"⚠️ Tidak ada context C2 bersih.\n"
242
- f" Dibuang L1: {bng_l1} | Dibuang L2: {bng_pendek}\n"
 
243
  " Coba perluas halaman atau naikkan toleransi noise."
244
  ), None
245
 
246
- df = pd.DataFrame(chunk_meta)
247
- tmp = os.path.join(tempfile.gettempdir(),
248
- f"IBEX_{nama.replace('.pdf','')}_hal{hal_mulai}-{hal_selesai}.csv")
 
 
249
  df.to_csv(tmp, index=False, encoding="utf-8-sig")
250
 
251
  pesan = (
252
- f"βœ… {end-start} hal dari '{nama}'\n"
253
- f"πŸ” Chunk bersih : {len(chunk_meta)}\n"
254
- f"πŸ—‘οΈ Buang L1 (chunk): {bng_l1}\n"
255
- f"βœ‚οΈ Buang L2 (kata) : {sum(r['kata_dibuang_l2'] for r in chunk_meta)}\n"
256
- f"πŸ“Š Total kata : {len(teks_gabung.split()):,}\n"
257
  f"πŸ’Ύ CSV siap diunduh."
258
  )
259
  return df, pesan, tmp
260
 
261
-
262
- # ══════════════════════════════════════════════════════════════════════════════
263
- # BAGIAN B β€” LOCAL QA: FUNGSI GENERATE
264
- # ══════════════════════════════════════════════════════════════════════════════
265
-
266
- SYSTEM_MSG = (
267
- "Anda adalah pakar pembuatan soal Taksonomi Bloom Bahasa Indonesia. "
268
- "Tugas Anda membuat soal yang tepat sesuai level kognitif yang diminta. "
269
- "Selalu kembalikan output dalam format JSON yang valid."
270
- )
271
-
272
- def prompt_c1(konteks, n):
273
- return (
274
- f'Bacalah teks berikut:\n"""{konteks}"""\n\n'
275
- f"Buat {n} pasang soal-jawaban level C1 (Mengingat).\n"
276
- "Ketentuan C1:\n"
277
- "- Pertanyaan diawali: apa, siapa, kapan, di mana, atau berapa\n"
278
- "- Jawaban berupa fakta eksplisit dari teks (maks 15 kata)\n\n"
279
- 'Output JSON (tanpa teks lain):\n{"c1": [{"question": "...", "answer": "..."}, ...]}'
280
- )
281
-
282
- def prompt_c2(konteks, n):
283
- return (
284
- f'Bacalah teks berikut:\n"""{konteks}"""\n\n'
285
- f"Buat {n} pasang soal-jawaban level C2 (Memahami).\n"
286
- "Ketentuan C2:\n"
287
- "- Pertanyaan WAJIB diawali: mengapa atau bagaimana\n"
288
- "- Jawaban menjelaskan sebab-akibat/proses, min 20 kata\n"
289
- "- Jawaban HARUS mengandung: karena/sehingga/mengakibatkan/berdampak\n"
290
- "- Jawaban dengan bahasa sendiri, BUKAN copy-paste teks\n\n"
291
- 'Output JSON (tanpa teks lain):\n{"c2": [{"question": "...", "answer": "..."}, ...]}'
292
- )
293
-
294
- def generate_json(user_prompt):
295
- tok, mdl = muat_model()
296
- messages = [
297
- {"role": "system", "content": SYSTEM_MSG},
298
- {"role": "user", "content": user_prompt},
299
- ]
300
- text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
301
- inputs = tok([text], return_tensors="pt").to(_device)
302
- with torch.no_grad():
303
- out = mdl.generate(
304
- **inputs,
305
- max_new_tokens=600,
306
- temperature=0.7,
307
- do_sample=True,
308
- pad_token_id=tok.eos_token_id,
309
- )
310
- gen = out[0][inputs["input_ids"].shape[-1]:]
311
- raw = tok.decode(gen, skip_special_tokens=True).strip()
312
- s, e = raw.find('{'), raw.rfind('}') + 1
313
- if s == -1:
314
- return None
315
- try:
316
- return json.loads(raw[s:e])
317
- except json.JSONDecodeError:
318
- cleaned = re.sub(r',\s*([}\]])', r'\1', raw[s:e])
319
- try:
320
- return json.loads(cleaned)
321
- except Exception:
322
- return None
323
-
324
- def validasi_c1(q, a):
325
- if not any(q.lower().startswith(s)
326
- for s in ["apa","siapa","kapan","di mana","dimana","berapa"]):
327
- return False, f"Tidak diawali kata tanya C1 ('{q[:30]}')"
328
- if not (2 <= len(a.split()) <= 20):
329
- return False, f"Jawaban {len(a.split())} kata (harusnya 2-20)"
330
- return True, "OK"
331
-
332
- def validasi_c2(q, a):
333
- if not any(q.lower().startswith(k) for k in ["mengapa","bagaimana"]):
334
- return False, f"Tidak diawali 'Mengapa'/'Bagaimana' ('{q[:30]}')"
335
- if len(a.split()) < 20:
336
- return False, f"Jawaban terlalu pendek ({len(a.split())} kata)"
337
- kausal = ['karena','sehingga','mengakibatkan','berdampak',
338
- 'akibatnya','dampaknya','disebabkan','mendorong','menyebabkan']
339
- if not any(k in a.lower() for k in kausal):
340
- return False, "Tidak ada penanda kausal"
341
- return True, "OK"
342
-
343
- def proses_chunk_qa(chunk_id, konteks, n_c1, n_c2, source_type):
344
- uid = hashlib.md5(konteks.encode()).hexdigest()[:8]
345
- valid, errors = [], []
346
-
347
- prefix = "BSE" if "bse" in source_type.lower() else "WIKI"
348
-
349
- for level, pfn, vfn, key, atype, label in [
350
- ("C1", prompt_c1, validasi_c1, "c1", "extractive", "Mengingat (Remembering)"),
351
- ("C2", prompt_c2, validasi_c2, "c2", "abstractive", "Memahami (Understanding)"),
352
- ]:
353
- n = n_c1 if level == "C1" else n_c2
354
- data = generate_json(pfn(konteks, n))
355
- if data and key in data:
356
- for item in data[key]:
357
- q = item.get("question","").strip()
358
- a = item.get("answer","").strip()
359
- ok, alasan = vfn(q, a)
360
- if ok:
361
- valid.append({
362
- "id" : f"{prefix}-{chunk_id}-{uid}-{level}",
363
- "chunk_id" : chunk_id,
364
- "source_type" : source_type,
365
- "bloom_level" : level,
366
- "bloom_label" : label,
367
- "answer_type" : atype,
368
- "question" : q,
369
- "answer" : a,
370
- "answer_words": len(a.split()),
371
- "context" : konteks,
372
- })
373
- else:
374
- errors.append({"chunk_id":chunk_id,"level":level,
375
- "alasan":alasan,"q":q[:100],"a":a[:100]})
376
- else:
377
- errors.append({"chunk_id":chunk_id,"level":level,
378
- "alasan":f"Gagal parse JSON / key '{key}' tidak ada",
379
- "q":"","a":""})
380
- return valid, errors
381
-
382
- def muat_output_csv(path):
383
- if not os.path.exists(path) or os.path.getsize(path) == 0:
384
- return set(), []
385
- try:
386
- df = pd.read_csv(path)
387
- if df.empty or "chunk_id" not in df.columns:
388
- return set(), []
389
- print(f"♻️ Resume: {len(df)} baris sudah ada.")
390
- return set(df["chunk_id"].tolist()), df.to_dict("records")
391
- except Exception:
392
- return set(), []
393
-
394
-
395
  # ══════════════════════════════════════════════════════════════════════════════
396
- # HANDLER GRADIO β€” TAB 1: IBEX EXTRACTOR
397
  # ══════════════════════════════════════════════════════════════════════════════
398
-
399
  def handler_ekstrak(file_pdf, hal_mulai, hal_selesai,
400
  chunk_size, batas_noise, sumber_tipe):
401
  if file_pdf is None:
@@ -410,388 +231,99 @@ def handler_ekstrak(file_pdf, hal_mulai, hal_selesai,
410
  )
411
  if df is None:
412
  return pesan, pd.DataFrame(), None
413
- return pesan, df[["chunk_id","source_type","page_range",
414
- "word_count","noise_score","context"]], tmp
 
415
  except Exception as ex:
416
  return f"❌ Error: {ex}", pd.DataFrame(), None
417
 
418
- def handler_unduh_ibex(pesan, df, path):
419
- ada = path is not None and df is not None and not df.empty
420
- return pesan, df, gr.update(visible=ada, value=path if ada else None)
421
-
422
-
423
- # ══════════════════════════════════════════════════════════════════════════════
424
- # HANDLER GRADIO β€” TAB 2: QA GENERATOR
425
- # ══════════════════════════════════════════════════════════════════════════════
426
-
427
- def handler_generate_qa(file_csv, n_c1, n_c2, chunk_mulai, chunk_selesai):
428
- if file_csv is None:
429
- return "❌ Harap unggah CSV hasil IBEX.", pd.DataFrame(), None
430
-
431
- csv_path = file_csv if isinstance(file_csv, str) else file_csv.name
432
- try:
433
- df_in = pd.read_csv(csv_path)
434
- except Exception as ex:
435
- return f"❌ Gagal baca CSV: {ex}", pd.DataFrame(), None
436
-
437
- if "context" not in df_in.columns:
438
- return "❌ Kolom 'context' tidak ada. Pastikan file dari IBEX.", pd.DataFrame(), None
439
-
440
- n_c1, n_c2 = int(n_c1), int(n_c2)
441
- idx_start = max(0, int(chunk_mulai) - 1)
442
- idx_end = min(len(df_in), int(chunk_selesai))
443
- df_proses = df_in.iloc[idx_start:idx_end].reset_index(drop=True)
444
-
445
- out_path = os.path.join(tempfile.gettempdir(), "IndoBloom_QA_output.csv")
446
- err_path = os.path.join(tempfile.gettempdir(), "IndoBloom_QA_errors.csv")
447
- processed_ids, all_rows = muat_output_csv(out_path)
448
- err_rows, logs = [], []
449
-
450
- sisa = len(df_proses) - sum(1 for r in df_proses.itertuples()
451
- if str(r.chunk_id) in processed_ids)
452
- logs.append(f"⏱️ {sisa} chunk akan diproses (~{sisa*30//60} menit di GPU T4)")
453
-
454
- for idx, row in df_proses.iterrows():
455
- chunk_id = str(row["chunk_id"])
456
- konteks = str(row["context"])
457
- source_type = str(row.get("source_type", "BSE"))
458
-
459
- if chunk_id in processed_ids:
460
- logs.append(f"[{idx+1}] {chunk_id} β€” dilewati.")
461
- continue
462
-
463
- logs.append(f"[{idx+1}/{len(df_proses)}] {chunk_id}...")
464
- t0 = time.time()
465
- valid, errors = proses_chunk_qa(chunk_id, konteks, n_c1, n_c2, source_type)
466
- elapsed = time.time() - t0
467
-
468
- all_rows.extend(valid)
469
- err_rows.extend(errors)
470
- processed_ids.add(chunk_id)
471
-
472
- c1ok = sum(1 for r in valid if r["bloom_level"]=="C1")
473
- c2ok = sum(1 for r in valid if r["bloom_level"]=="C2")
474
- c1er = sum(1 for e in errors if e["level"]=="C1")
475
- c2er = sum(1 for e in errors if e["level"]=="C2")
476
- logs.append(f" βœ… C1:{c1ok}(βœ—{c1er}) C2:{c2ok}(βœ—{c2er}) | {elapsed:.0f}s")
477
- for r in valid:
478
- logs.append(f" [{r['bloom_level']}] Q: {r['question'][:70]}")
479
- logs.append(f" A: {r['answer'][:70]}")
480
-
481
- if len(all_rows) % 5 == 0 and all_rows:
482
- pd.DataFrame(all_rows).to_csv(out_path, index=False, encoding="utf-8-sig")
483
- logs.append(f" πŸ’Ύ Checkpoint: {len(all_rows)} QA.")
484
-
485
- if not all_rows:
486
- return "❌ Tidak ada QA yang berhasil digenerate.", pd.DataFrame(), None
487
-
488
- df_out = pd.DataFrame(all_rows)
489
- df_out.to_csv(out_path, index=False, encoding="utf-8-sig")
490
- if err_rows:
491
- pd.DataFrame(err_rows).to_csv(err_path, index=False, encoding="utf-8-sig")
492
-
493
- c1t = len(df_out[df_out["bloom_level"]=="C1"])
494
- c2t = len(df_out[df_out["bloom_level"]=="C2"])
495
- pesan = "\n".join([
496
- f"βœ… SELESAI β€” {len(df_out)} QA dihasilkan",
497
- f" C1: {c1t} | C2: {c2t}",
498
- f" Rata-rata jawaban: {df_out['answer_words'].mean():.1f} kata",
499
- f" Error: {len(err_rows)}",
500
- "─" * 40,
501
- *logs[-30:], # tampilkan 30 log terakhir
502
- ])
503
- preview = df_out[["chunk_id","bloom_level","question","answer"]].head(20)
504
- return pesan, preview, out_path
505
-
506
- def handler_unduh_qa(pesan, df, path):
507
  ada = path is not None and df is not None and not df.empty
508
  return pesan, df, gr.update(visible=ada, value=path if ada else None)
509
 
510
-
511
- # ══════════════════════════════════════════════════════════════════════════════
512
- # HANDLER GRADIO β€” TAB 3: PIPELINE (PDF β†’ CONTEXT β†’ QA)
513
- # ══════════════════════════════════════════════════════════════════════════════
514
-
515
- def handler_pipeline(file_pdf, hal_mulai, hal_selesai, chunk_size,
516
- batas_noise, sumber_tipe, n_c1, n_c2):
517
- if file_pdf is None:
518
- return "❌ Harap unggah file PDF.", pd.DataFrame(), None
519
-
520
- logs = ["πŸš€ PIPELINE DIMULAI", "─"*40]
521
-
522
- # STEP 1: Ekstrak context
523
- logs.append("πŸ“„ STEP 1: Ekstrak context dari PDF...")
524
- try:
525
- pdf_path = file_pdf if isinstance(file_pdf, str) else file_pdf.name
526
- df_ctx, pesan_ibex, _ = ekstrak_context_dari_pdf(
527
- pdf_path, hal_mulai, hal_selesai,
528
- chunk_size, batas_noise, sumber_tipe
529
- )
530
- except Exception as ex:
531
- return f"❌ Gagal ekstrak: {ex}", pd.DataFrame(), None
532
-
533
- if df_ctx is None:
534
- return f"❌ Ekstrak gagal:\n{pesan_ibex}", pd.DataFrame(), None
535
-
536
- logs.append(pesan_ibex)
537
- logs.append(f"βœ… {len(df_ctx)} chunk siap di-generate")
538
- logs.append("─"*40)
539
-
540
- # STEP 2: Generate QA
541
- logs.append("🧠 STEP 2: Generate QA pairs (C1+C2)...")
542
- all_rows, err_rows = [], []
543
- n_c1, n_c2 = int(n_c1), int(n_c2)
544
-
545
- for idx, row in df_ctx.iterrows():
546
- chunk_id = str(row["chunk_id"])
547
- konteks = str(row["context"])
548
- source_type = str(row.get("source_type", sumber_tipe))
549
-
550
- logs.append(f"[{idx+1}/{len(df_ctx)}] {chunk_id}...")
551
- t0 = time.time()
552
- valid, errors = proses_chunk_qa(chunk_id, konteks, n_c1, n_c2, source_type)
553
- elapsed = time.time() - t0
554
-
555
- all_rows.extend(valid)
556
- err_rows.extend(errors)
557
-
558
- c1ok = sum(1 for r in valid if r["bloom_level"]=="C1")
559
- c2ok = sum(1 for r in valid if r["bloom_level"]=="C2")
560
- logs.append(f" βœ… C1:{c1ok} C2:{c2ok} | {elapsed:.0f}s")
561
- for r in valid:
562
- logs.append(f" [{r['bloom_level']}] {r['question'][:65]}")
563
-
564
- if not all_rows:
565
- return "❌ Tidak ada QA berhasil.", pd.DataFrame(), None
566
-
567
- df_out = pd.DataFrame(all_rows)
568
- out_path = os.path.join(
569
- tempfile.gettempdir(),
570
- f"IndoBloom_Pipeline_{os.path.basename(pdf_path).replace('.pdf','')}.csv"
571
- )
572
- df_out.to_csv(out_path, index=False, encoding="utf-8-sig")
573
-
574
- c1t = len(df_out[df_out["bloom_level"]=="C1"])
575
- c2t = len(df_out[df_out["bloom_level"]=="C2"])
576
- logs += [
577
- "─"*40,
578
- f"βœ… PIPELINE SELESAI",
579
- f" Total QA : {len(df_out)} pairs",
580
- f" C1 : {c1t} | C2: {c2t}",
581
- f" Error : {len(err_rows)}",
582
- f" Output : {out_path}",
583
- ]
584
- pesan = "\n".join(logs)
585
- preview = df_out[["chunk_id","bloom_level","question","answer"]].head(20)
586
- return pesan, preview, out_path
587
-
588
- def handler_unduh_pipeline(pesan, df, path):
589
- ada = path is not None and df is not None and not df.empty
590
- return pesan, df, gr.update(visible=ada, value=path if ada else None)
591
-
592
-
593
  # ══════════════════════════════════════════════════════════════════════════════
594
- # ANTARMUKA GRADIO
595
  # ══════════════════════════════════════════════════════════════════════════════
596
-
597
- with gr.Blocks(title="Indo-Bloom Pipeline v1.0") as app:
598
 
599
  gr.Markdown("""
600
- # 🌿 Indo-Bloom Pipeline v1.0
601
- **PDF β†’ Context Extractor β†’ QA Generator | Tanpa API Key | GPU Lokal**
 
602
 
603
- Pipeline terintegrasi untuk membangun corpus AQG Bloom's Taxonomy Bahasa Indonesia.
604
- Mendukung sumber **BSE Kemendikbud** dan **Wikipedia** (PDF).
605
  """)
606
 
607
- # ── Shared State ─────────────────────────────────────────────────────
608
- ibex_csv_state = gr.State(None)
609
- qa_csv_state = gr.State(None)
610
- pipeline_csv_state = gr.State(None)
611
-
612
- with gr.Tabs():
613
-
614
- # ════════════════════════════════════════════════════════════════
615
- # TAB 1 β€” IBEX EXTRACTOR
616
- # ════════════════════════════════════════════════════════════════
617
- with gr.TabItem("πŸ“„ Tab 1 β€” IBEX: Ekstrak Context"):
618
- gr.Markdown("""
619
- ### IBEX v2.4 β€” Indo-Bloom Context Extractor
620
- Ekstrak teks eksplanatori (C2-ready) dari **PDF BSE atau Wikipedia**.
621
- Filter 2 level: buang noise BSE + bersihkan kalimat instruksional.
622
- """)
623
- with gr.Row():
624
- with gr.Column(scale=1):
625
- t1_pdf = gr.File(label="πŸ“‚ Upload PDF", file_types=[".pdf"])
626
- t1_sumber = gr.Radio(
627
- choices=["BSE", "Wikipedia", "Lainnya"],
628
- value="BSE", label="Tipe Sumber"
629
- )
630
- with gr.Row():
631
- t1_hal1 = gr.Number(label="Hal. Mulai", value=1, precision=0, minimum=1)
632
- t1_hal2 = gr.Number(label="Hal. Selesai", value=20, precision=0, minimum=1)
633
- t1_chunk = gr.Slider(50, 300, value=150, step=10,
634
- label="Kata per Chunk",
635
- info="Rekomendasi 100–200. Overlap 25% otomatis.")
636
- t1_noise = gr.Slider(1, 6, value=2, step=1,
637
- label="Toleransi Noise",
638
- info="1–2=ketat | 3–4=sedang | 5–6=longgar")
639
- t1_btn = gr.Button("πŸš€ Ekstrak Context", variant="primary", size="lg")
640
-
641
- with gr.Column(scale=2):
642
- t1_status = gr.Textbox(label="Status", lines=7, interactive=False)
643
- t1_unduh = gr.DownloadButton("⬇️ Unduh CSV Context",
644
- variant="secondary", visible=False)
645
- t1_tabel = gr.Dataframe(label="Preview Chunks", interactive=False, wrap=True)
646
-
647
- t1_btn.click(
648
- fn=handler_ekstrak,
649
- inputs=[t1_pdf, t1_hal1, t1_hal2, t1_chunk, t1_noise, t1_sumber],
650
- outputs=[t1_status, t1_tabel, ibex_csv_state]
651
- ).then(
652
- fn=handler_unduh_ibex,
653
- inputs=[t1_status, t1_tabel, ibex_csv_state],
654
- outputs=[t1_status, t1_tabel, t1_unduh]
655
  )
656
-
657
- # ════════════════════════════════════════════════════════════════
658
- # TAB 2 β€” QA GENERATOR
659
- # ════════════════════════════════════════════════════════════════
660
- with gr.TabItem("🧠 Tab 2 β€” QA Generator (GPU Lokal)"):
661
- gr.Markdown("""
662
- ### Local QA Generator v1.0
663
- Generate pasangan **Q&A level C1 + C2** dari CSV context hasil IBEX.
664
- Model: **Qwen2.5-3B-Instruct** β€” lokal, tanpa API key, tanpa rate limit.
665
- > ⚠️ Aktifkan **GPU T4** di Kaggle: Settings β†’ Accelerator β†’ GPU T4
666
- """)
667
  with gr.Row():
668
- with gr.Column(scale=1):
669
- t2_csv = gr.File(label="πŸ“‚ Upload CSV Hasil IBEX", file_types=[".csv"])
670
- with gr.Row():
671
- t2_c1 = gr.Slider(1, 5, value=2, step=1, label="QA C1 per Chunk")
672
- t2_c2 = gr.Slider(1, 5, value=2, step=1, label="QA C2 per Chunk")
673
- with gr.Row():
674
- t2_cm = gr.Number(label="Chunk Mulai", value=1, precision=0, minimum=1)
675
- t2_cs = gr.Number(label="Chunk Selesai", value=999, precision=0, minimum=1)
676
- t2_btn = gr.Button("πŸš€ Generate QA", variant="primary", size="lg")
677
- gr.Markdown("*~30s/chunk di GPU T4. Resume otomatis jika terganggu.*")
678
-
679
- with gr.Column(scale=2):
680
- t2_status = gr.Textbox(label="Log & Status", lines=12, interactive=False)
681
- t2_unduh = gr.DownloadButton("⬇️ Unduh CSV QA Corpus",
682
- variant="secondary", visible=False)
683
- t2_tabel = gr.Dataframe(label="Preview QA Pairs", interactive=False, wrap=True)
684
-
685
- t2_btn.click(
686
- fn=handler_generate_qa,
687
- inputs=[t2_csv, t2_c1, t2_c2, t2_cm, t2_cs],
688
- outputs=[t2_status, t2_tabel, qa_csv_state]
689
- ).then(
690
- fn=handler_unduh_qa,
691
- inputs=[t2_status, t2_tabel, qa_csv_state],
692
- outputs=[t2_status, t2_tabel, t2_unduh]
693
- )
694
 
695
- # ════════════════════════════════════════════════════════════════
696
- # TAB 3 β€” FULL PIPELINE
697
- # ════════════════════════════════════════════════════════════════
698
- with gr.TabItem("⚑ Tab 3 β€” Full Pipeline (PDF β†’ QA)"):
699
  gr.Markdown("""
700
- ### Full Pipeline: PDF β†’ Context β†’ QA Corpus (Satu Klik)
701
- Jalankan **IBEX + QA Generator** sekaligus dari file PDF.
702
- Cocok untuk batch processing banyak halaman BSE/Wikipedia.
 
 
 
 
 
703
  """)
704
- with gr.Row():
705
- with gr.Column(scale=1):
706
- t3_pdf = gr.File(label="πŸ“‚ Upload PDF", file_types=[".pdf"])
707
- t3_sumber = gr.Radio(
708
- choices=["BSE", "Wikipedia", "Lainnya"],
709
- value="BSE", label="Tipe Sumber"
710
- )
711
- gr.Markdown("**βš™οΈ Pengaturan Ekstraksi**")
712
- with gr.Row():
713
- t3_hal1 = gr.Number(label="Hal. Mulai", value=1, precision=0, minimum=1)
714
- t3_hal2 = gr.Number(label="Hal. Selesai", value=20, precision=0, minimum=1)
715
- t3_chunk = gr.Slider(50, 300, value=150, step=10, label="Kata per Chunk")
716
- t3_noise = gr.Slider(1, 6, value=2, step=1, label="Toleransi Noise")
717
- gr.Markdown("**🧠 Pengaturan Generate**")
718
- with gr.Row():
719
- t3_c1 = gr.Slider(1, 5, value=2, step=1, label="QA C1 per Chunk")
720
- t3_c2 = gr.Slider(1, 5, value=2, step=1, label="QA C2 per Chunk")
721
- t3_btn = gr.Button("⚑ Jalankan Full Pipeline",
722
- variant="primary", size="lg")
723
-
724
- with gr.Column(scale=2):
725
- t3_status = gr.Textbox(label="Log Pipeline", lines=14, interactive=False)
726
- t3_unduh = gr.DownloadButton("⬇️ Unduh QA Corpus Final",
727
- variant="secondary", visible=False)
728
- t3_tabel = gr.Dataframe(label="Preview Hasil", interactive=False, wrap=True)
729
-
730
- t3_btn.click(
731
- fn=handler_pipeline,
732
- inputs=[t3_pdf, t3_hal1, t3_hal2, t3_chunk,
733
- t3_noise, t3_sumber, t3_c1, t3_c2],
734
- outputs=[t3_status, t3_tabel, pipeline_csv_state]
735
- ).then(
736
- fn=handler_unduh_pipeline,
737
- inputs=[t3_status, t3_tabel, pipeline_csv_state],
738
- outputs=[t3_status, t3_tabel, t3_unduh]
739
  )
740
 
741
- # ════════════════════════════════════════════════════════════════
742
- # TAB 4 β€” PANDUAN
743
- # ════════════════════════════════════════════════════════════════
744
- with gr.TabItem("ℹ️ Panduan"):
745
- gr.Markdown("""
746
- ## πŸ—ΊοΈ Alur Kerja Indo-Bloom Pipeline
747
-
748
- ```
749
- PDF (BSE/Wikipedia)
750
- ↓
751
- [Tab 1 β€” IBEX v2.4] ← Filter noise BSE, bersihkan kalimat instruksional
752
- ↓ CSV Context Bersih
753
- [Tab 2 β€” QA Generator] ← Qwen2.5-3B-Instruct, GPU lokal, tanpa API
754
- ↓ CSV QA Corpus
755
- Indo-Bloom Silver Corpus (C1 + C2)
756
-
757
- Atau gunakan [Tab 3 β€” Full Pipeline] untuk satu klik dari PDF ke QA.
758
- ```
759
-
760
- ## πŸ“‹ Format Output CSV
761
-
762
- | Kolom | Keterangan |
763
- |---|---|
764
- | `id` | ID unik sampel (prefix sumber + hash) |
765
- | `chunk_id` | ID chunk dari IBEX |
766
- | `source_type` | BSE / Wikipedia / Lainnya |
767
- | `bloom_level` | C1 atau C2 |
768
- | `bloom_label` | Label lengkap Bloom |
769
- | `answer_type` | extractive (C1) / abstractive (C2) |
770
- | `question` | Pertanyaan Bahasa Indonesia |
771
- | `answer` | Jawaban berdasarkan konteks |
772
- | `answer_words` | Jumlah kata jawaban |
773
- | `context` | Teks sumber (untuk validasi) |
774
-
775
- ## 🎚️ Tips Penggunaan
776
-
777
- **Untuk BSE (Buku Sekolah Elektronik):**
778
- - Toleransi Noise: **2** (default) β€” ketat untuk menyaring instruksi siswa
779
- - Chunk size: **150 kata** β€” cukup untuk context C2
780
-
781
- **Untuk Wikipedia (PDF):**
782
- - Toleransi Noise: **4** β€” Wikipedia lebih bersih, bisa lebih longgar
783
- - Chunk size: **200 kata** β€” paragraf Wikipedia biasanya lebih panjang
784
- - Pilih **"Wikipedia"** di Tipe Sumber agar ID corpus tercatat benar
785
-
786
- ## ⚠️ Catatan GPU
787
- Model Qwen2.5-3B-Instruct membutuhkan GPU untuk kecepatan optimal.
788
- Di Kaggle: **Settings β†’ Accelerator β†’ GPU T4** sebelum menjalankan Tab 2/3.
789
- Di CPU: tetap berjalan tapi ~5-10x lebih lambat per chunk.
790
- """)
791
 
792
  if __name__ == "__main__":
793
  app.launch(
794
  share=False,
795
  theme=gr.themes.Soft(primary_hue="teal"),
796
- css=".tab-nav button { font-size: 15px; font-weight: 600; }"
797
  )
 
1
  # =============================================================================
2
+ # INDO-BLOOM SPACE 1 β€” IBEX v2.4: Context Extractor
3
  #
4
+ # Ekstrak context bersih (C2-ready) dari PDF BSE / Wikipedia.
5
+ # Output: CSV yang bisa diunduh β†’ upload ke Space 2 untuk generate QA.
 
 
6
  #
7
+ # Hardware: CPU (tidak butuh GPU)
8
+ # Requirements: gradio pymupdf pandas
 
 
9
  # =============================================================================
 
10
  import subprocess, sys
11
  subprocess.run([sys.executable, "-m", "pip", "install", "-q",
12
+ "gradio", "pymupdf", "pandas"], check=False)
 
13
 
14
+ import os, re, tempfile
15
  import pandas as pd
16
  import fitz # PyMuPDF
17
  import gradio as gr
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # ══════════════════════════════════════════════════════════════════════════════
20
+ # KONSTANTA & POLA FILTER
21
  # ══════════════════════════════════════════════════════════════════════════════
 
22
  NOISE_BSE = [
23
  r'tujuan pembelajaran', r'setelah mempelajari',
24
  r'diharapkan (mampu|dapat)', r'kata kunci', r'pemetaan pikiran',
 
62
  r'kalian\s+telah\s+(mengetahui|mempelajari|memahami)\s+bahwa',
63
  r'apa\s+yang\s+dimaksud.{0,80}\?$',
64
  ]
65
+ KALIMAT_PATTERNS = [re.compile(p, re.IGNORECASE) for p in KALIMAT_INSTRUKSIONAL]
66
+ HEADER_BAB_PAT = re.compile(r'bab\s+\d+\s*\|\s*[\w\s]+\d+', re.IGNORECASE)
67
+ _MAPEL = (r'sosiologi|matematika|fisika|kimia|biologi|sejarah|geografi|'
68
+ r'ekonomi|pkn|pendidikan|prakarya|seni|bahasa')
69
+
70
+ KATA_KUNCI_C2 = [
71
  'karena','menyebabkan','berdampak','sehingga','mengakibatkan','berakibat',
72
  'oleh karena','disebabkan','bertujuan','berfungsi','berperan','berguna',
73
  'tujuan','fungsi','manfaat','peran','kegunaan','mekanisme','tahapan',
 
75
  'keterkaitan','mengapa','bagaimana','jelaskan','uraikan',
76
  ]
77
 
78
+ # ══════════════════════════════════════════════════════��═══════════════════════
79
+ # FUNGSI INTI IBEX
80
+ # ══════════════════════════════════════════════════════════════════════════════
81
  def hitung_noise(t):
82
  return sum(1 for p in NOISE_PATTERNS if p.search(t))
83
 
 
119
 
120
  def ekstrak_context_dari_pdf(pdf_path, hal_mulai, hal_selesai,
121
  chunk_size, batas_noise, nama_sumber="BSE"):
 
 
 
 
122
  hal_mulai = int(hal_mulai)
123
  hal_selesai = int(hal_selesai)
124
  chunk_size = int(chunk_size)
 
133
  if start >= end:
134
  return None, f"❌ Rentang tidak valid. PDF punya {total} halaman.", None
135
 
136
+ # Ekstrak teks per halaman
137
  teks_per_hal = []
138
  for num in range(start, end):
139
  page = doc.load_page(num)
 
162
  while j < len(kalimat_list) and len(buf) < chunk_size:
163
  buf.extend(kalimat_list[j].split())
164
  j += 1
165
+ pot = " ".join(buf)
166
+ wc = len(buf)
167
+ noise = hitung_noise(pot)
168
  ada_c2 = any(k in pot.lower() for k in KATA_KUNCI_C2)
169
 
170
  if wc >= 50 and ada_c2:
 
192
  if not chunk_meta:
193
  return None, (
194
  f"⚠️ Tidak ada context C2 bersih.\n"
195
+ f" Dibuang L1 (chunk): {bng_l1}\n"
196
+ f" Dibuang L2 (kata) : {bng_pendek}\n"
197
  " Coba perluas halaman atau naikkan toleransi noise."
198
  ), None
199
 
200
+ df = pd.DataFrame(chunk_meta)
201
+ tmp = os.path.join(
202
+ tempfile.gettempdir(),
203
+ f"IBEX_{nama.replace('.pdf','')}_hal{hal_mulai}-{hal_selesai}.csv"
204
+ )
205
  df.to_csv(tmp, index=False, encoding="utf-8-sig")
206
 
207
  pesan = (
208
+ f"βœ… Selesai! {end - start} halaman dari '{nama}'\n"
209
+ f"πŸ” Chunk bersih : {len(chunk_meta)}\n"
210
+ f"πŸ—‘οΈ Buang L1 (noise) : {bng_l1}\n"
211
+ f"βœ‚οΈ Buang L2 (kata) : {sum(r['kata_dibuang_l2'] for r in chunk_meta)}\n"
212
+ f"πŸ“Š Total kata input : {len(teks_gabung.split()):,}\n"
213
  f"πŸ’Ύ CSV siap diunduh."
214
  )
215
  return df, pesan, tmp
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # ══════════════════════════════════════════════════════════════════════════════
218
+ # HANDLER GRADIO
219
  # ══════════════════════════════════════════════════════════════════════════════
 
220
  def handler_ekstrak(file_pdf, hal_mulai, hal_selesai,
221
  chunk_size, batas_noise, sumber_tipe):
222
  if file_pdf is None:
 
231
  )
232
  if df is None:
233
  return pesan, pd.DataFrame(), None
234
+ preview = df[["chunk_id", "source_type", "page_range",
235
+ "word_count", "noise_score", "context"]]
236
+ return pesan, preview, tmp
237
  except Exception as ex:
238
  return f"❌ Error: {ex}", pd.DataFrame(), None
239
 
240
+ def handler_unduh(pesan, df, path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  ada = path is not None and df is not None and not df.empty
242
  return pesan, df, gr.update(visible=ada, value=path if ada else None)
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  # ══════════════════════════════════════════════════════════════════════════════
245
+ # UI GRADIO
246
  # ══════════════════════════════════════════════════════════════════════════════
247
+ with gr.Blocks(title="IBEX v2.4 β€” Indo-Bloom Context Extractor") as app:
248
+ csv_state = gr.State(None)
249
 
250
  gr.Markdown("""
251
+ # πŸ“„ IBEX v2.4 β€” Indo-Bloom Context Extractor
252
+ Ekstrak teks eksplanatori **(C2-ready)** dari PDF BSE Kemendikbud atau Wikipedia.
253
+ Filter 2 level: buang chunk noise β†’ bersihkan kalimat instruksional per kalimat.
254
 
255
+ **Alur kerja:**
256
+ `Upload PDF` β†’ `Atur parameter` β†’ `Ekstrak` β†’ `Unduh CSV` β†’ *upload ke Space QA Generator*
257
  """)
258
 
259
+ with gr.Row():
260
+ # ── Kolom kiri: kontrol ──────────────────────────────────────────────
261
+ with gr.Column(scale=1):
262
+ pdf_input = gr.File(label="πŸ“‚ Upload PDF (BSE / Wikipedia)", file_types=[".pdf"])
263
+ sumber_tipe = gr.Radio(
264
+ choices=["BSE", "Wikipedia", "Lainnya"],
265
+ value="BSE", label="Tipe Sumber"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  )
 
 
 
 
 
 
 
 
 
 
 
267
  with gr.Row():
268
+ hal_mulai = gr.Number(label="Hal. Mulai", value=1, precision=0, minimum=1)
269
+ hal_selesai = gr.Number(label="Hal. Selesai", value=20, precision=0, minimum=1)
270
+ chunk_size = gr.Slider(50, 300, value=150, step=10,
271
+ label="Kata per Chunk",
272
+ info="Rekomendasi 100–200. Overlap 25% otomatis.")
273
+ batas_noise = gr.Slider(1, 6, value=2, step=1,
274
+ label="Toleransi Noise",
275
+ info="1–2=ketat (BSE) | 3–4=sedang | 5–6=longgar (Wiki)")
276
+ btn_ekstrak = gr.Button("πŸš€ Ekstrak Context", variant="primary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
 
 
 
 
278
  gr.Markdown("""
279
+ ---
280
+ ### πŸ’‘ Tips
281
+ | Sumber | Noise | Chunk |
282
+ |--------|-------|-------|
283
+ | BSE Kemendikbud | 2 | 150 |
284
+ | Wikipedia PDF | 4 | 200 |
285
+
286
+ Setelah unduh CSV, upload ke **Space QA Generator** untuk generate soal C1+C2.
287
  """)
288
+
289
+ # ── Kolom kanan: output ──────────────────────────────────────────────
290
+ with gr.Column(scale=2):
291
+ status_box = gr.Textbox(label="Status Ekstraksi", lines=8, interactive=False)
292
+ unduh_btn = gr.DownloadButton(
293
+ "⬇️ Unduh CSV Context (upload ke Space QA Generator)",
294
+ variant="secondary", visible=False
295
+ )
296
+ preview = gr.Dataframe(
297
+ label="Preview Chunks (10 pertama)", interactive=False, wrap=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  )
299
 
300
+ btn_ekstrak.click(
301
+ fn=handler_ekstrak,
302
+ inputs=[pdf_input, hal_mulai, hal_selesai, chunk_size, batas_noise, sumber_tipe],
303
+ outputs=[status_box, preview, csv_state]
304
+ ).then(
305
+ fn=handler_unduh,
306
+ inputs=[status_box, preview, csv_state],
307
+ outputs=[status_box, preview, unduh_btn]
308
+ )
309
+
310
+ gr.Markdown("""
311
+ ---
312
+ ### πŸ“‹ Format Kolom Output CSV
313
+ | Kolom | Keterangan |
314
+ |---|---|
315
+ | `chunk_id` | ID unik chunk (chunk_0001, dst.) |
316
+ | `source_file` | Nama file PDF asal |
317
+ | `source_type` | BSE / Wikipedia / Lainnya |
318
+ | `page_range` | Rentang halaman yang diekstrak |
319
+ | `word_count` | Jumlah kata setelah dibersihkan |
320
+ | `noise_score` | Skor noise akhir (idealnya < batas) |
321
+ | `kata_dibuang_l2` | Kata yang dibuang di filter L2 |
322
+ | `context` | Teks context bersih siap di-generate |
323
+ """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
  if __name__ == "__main__":
326
  app.launch(
327
  share=False,
328
  theme=gr.themes.Soft(primary_hue="teal"),
 
329
  )