""" LAB 1 - EKSPERIMEN CHUNKING (versi 3) Membaca PDF, memotongnya dengan 3 ukuran chunk (overlap 15%), lalu menganalisis kualitas potongan: terpotong di tengah kalimat/kata, mengandung pola tabel, atau memuat simbol matematika yang rusak. Dua metode dibandingkan: - "tetap" : potong tepat per N karakter (naif, sering memutus kata/kalimat) - "cerdas" : potong di batas kalimat terdekat (atau batas kata jika tidak ada), dan awal chunk berikutnya selalu di awal kata. Panjang chunk = maksimal "ukuran" karakter. """ import random import re from pathlib import Path from pypdf import PdfReader from pypdf.errors import PdfReadError random.seed(42) # agar sampel acak reproducible FOLDER = Path("dokumen") PENGATURAN = [(500, 75), (1000, 150), (1500, 225)] # (ukuran, overlap) = 15% JUMLAH_SAMPEL = 3 JENDELA_CARI = 0.3 # cari batas kalimat/kata di 30% terakhir sebuah chunk # Karakter Private Use Area (simbol matematika yang rusak saat ekstraksi) POLA_SIMBOL_RUSAK = re.compile(r"[-]") POLA_AKHIR_KALIMAT = re.compile(r"[.!?:;][\"')\]]*$") POLA_BATAS_KALIMAT = re.compile(r"[.!?][\"')\]]*(?=\s|$)") POLA_SPASI = re.compile(r"\s") POLA_KOLOM = re.compile(r"\S\s{3,}\S") # 3+ spasi di antara dua token # ========================================== # MEMBACA PDF # ========================================== def baca_pdf(path): """Mengembalikan (teks_gabungan, jumlah_halaman).""" reader = PdfReader(path) if reader.is_encrypted: try: reader.decrypt("") except Exception: raise ValueError("PDF terenkripsi dan tidak bisa dibuka tanpa password") halaman = [(p.extract_text() or "") for p in reader.pages] return "\n".join(halaman), len(reader.pages) # ========================================== # CHUNKING # ========================================== def _validasi(ukuran, tumpang): if ukuran <= 0: raise ValueError("Ukuran chunk harus > 0") if tumpang < 0 or tumpang >= ukuran: raise ValueError("Overlap harus 0 <= tumpang < ukuran") def potong_tetap(teks, ukuran=800, tumpang=100): """Potong per N karakter. Mengembalikan daftar (mulai, akhir).""" _validasi(ukuran, tumpang) posisi = [] mulai = 0 langkah = ukuran - tumpang while mulai < len(teks): akhir = min(mulai + ukuran, len(teks)) if teks[mulai:akhir].strip(): posisi.append((mulai, akhir)) if akhir >= len(teks): # hindari chunk ekor yang isinya hanya overlap break mulai += langkah return posisi def _cari_akhir(teks, mulai, batas, ukuran): """Pilih titik akhir chunk (<= batas): batas kalimat > batas kata > batas keras.""" awal_cari = max(mulai + 1, batas - int(ukuran * JENDELA_CARI)) wilayah = teks[awal_cari:batas] # 1. batas kalimat terakhir di dalam jendela cocok = None for cocok in POLA_BATAS_KALIMAT.finditer(wilayah): pass if cocok: return awal_cari + cocok.end() # 2. spasi/baris baru terakhir (chunk berakhir tepat di akhir sebuah kata) spasi = None for spasi in POLA_SPASI.finditer(wilayah): pass if spasi: return awal_cari + spasi.start() # 3. tidak ada spasi sama sekali (mis. string panjang tanpa jeda) return batas def potong_cerdas(teks, ukuran=800, tumpang=100): """Potong di batas kalimat/kata. Mengembalikan daftar (mulai, akhir).""" _validasi(ukuran, tumpang) n = len(teks) posisi = [] mulai = 0 while mulai < n and teks[mulai].isspace(): mulai += 1 while mulai < n: batas = min(mulai + ukuran, n) akhir = n if batas >= n else _cari_akhir(teks, mulai, batas, ukuran) if teks[mulai:akhir].strip(): posisi.append((mulai, akhir)) if akhir >= n: break # awal chunk berikutnya = mundur sebesar overlap, lalu geser maju # ke awal kata terdekat supaya tidak dimulai di tengah kata baru = max(akhir - tumpang, mulai + 1) while baru < akhir and not teks[baru - 1].isspace(): baru += 1 if baru <= mulai: baru = akhir while baru < n and teks[baru].isspace(): baru += 1 mulai = baru return posisi METODE = {"tetap": potong_tetap, "cerdas": potong_cerdas} # ========================================== # ANALISIS CHUNK # ========================================== def analisis_chunk(teks_penuh, mulai, akhir): chunk = teks_penuh[mulai:akhir] isi = chunk.rstrip() berakhir_kalimat = bool(POLA_AKHIR_KALIMAT.search(isi)) # Terpotong di tengah kata: karakter terakhir chunk dan karakter # berikutnya di teks asli sama-sama huruf/angka. ada_lanjutan = akhir < len(teks_penuh) terpotong_kata = ( ada_lanjutan and chunk[-1:].isalnum() and teks_penuh[akhir:akhir + 1].isalnum() ) baris = [b for b in (x.strip() for x in chunk.splitlines()) if b] baris_tabel = sum(1 for b in baris if "|" in b or "\t" in b or POLA_KOLOM.search(b)) ada_tabel = baris_tabel >= 2 jumlah_simbol = len(POLA_SIMBOL_RUSAK.findall(chunk)) kata = isi.split() return { "panjang": len(chunk), "berakhir_kalimat": berakhir_kalimat, "terpotong_kata": terpotong_kata, "ada_tabel": ada_tabel, "jumlah_simbol_rusak": jumlah_simbol, "ada_simbol_rusak": jumlah_simbol > 0, "akhir_kata": " ".join(kata[-3:]) if kata else "", } def persen(bagian, total): return f"{bagian / total * 100:.0f}%" if total else "-" def rapikan(teks): """Ubah baris baru/spasi berlebih jadi satu spasi agar enak dibaca di terminal.""" return " ".join(teks.split()) # ========================================== # PROGRAM UTAMA # ========================================== def main(): if not FOLDER.is_dir(): raise SystemExit(f"❌ ERROR: Folder '{FOLDER}/' tidak ditemukan!") daftar_pdf = sorted(FOLDER.glob("*.pdf")) # sorted -> hasil konsisten if not daftar_pdf: raise SystemExit(f"❌ ERROR: Tidak ada file PDF di folder '{FOLDER}/'!") print("=" * 70) print(" LAB 1 — EKSPERIMEN CHUNKING") print("=" * 70) print(f"\n📁 Folder : {FOLDER.resolve()}") print(f"📄 Jumlah PDF : {len(daftar_pdf)} file") for p in daftar_pdf: print(f" - {p.name}") # ---------- Langkah 1: baca PDF ---------- pdf_path = daftar_pdf[0] try: teks, n_halaman = baca_pdf(pdf_path) except (PdfReadError, ValueError, OSError) as e: raise SystemExit(f"❌ ERROR: Gagal membaca '{pdf_path.name}': {e}") if not teks.strip(): raise SystemExit(f"❌ ERROR: PDF '{pdf_path.name}' kosong / hasil scan (butuh OCR)!") print("\n" + "=" * 70) print(" LANGKAH 1 — MEMBACA PDF") print("=" * 70) print(f"\n📄 File yang diuji : {pdf_path.name}") print(f"📏 Total karakter : {len(teks):,}") print(f"📑 Jumlah halaman : {n_halaman}") print("\n--- 500 KARAKTER PERTAMA ---") print(teks[:500]) print("--- (akhir 500 karakter) ---") # ---------- Langkah 2: potong ---------- print("\n" + "=" * 70) print(" LANGKAH 2 — POTONG 3 UKURAN (OVERLAP 15%), 2 METODE") print("=" * 70) posisi_semua = {} # (ukuran, metode) -> daftar posisi print(f"\n{'Ukuran':<10}{'Overlap':<10}{'Chunk (tetap)':<16}{'Chunk (cerdas)':<16}") print("-" * 52) for ukuran, overlap in PENGATURAN: for nama, fungsi in METODE.items(): posisi_semua[(ukuran, nama)] = fungsi(teks, ukuran, overlap) print(f"{ukuran:<10}{overlap:<10}" f"{len(posisi_semua[(ukuran, 'tetap')]):<16}" f"{len(posisi_semua[(ukuran, 'cerdas')]):<16}") # ---------- Langkah 3: contoh chunk (metode cerdas) ---------- print("\n" + "=" * 70) print(f" LANGKAH 3 — CONTOH {JUMLAH_SAMPEL} CHUNK ACAK TIAP UKURAN (metode cerdas)") print("=" * 70) for ukuran, _ in PENGATURAN: posisi = posisi_semua[(ukuran, "cerdas")] print(f"\n{'#' * 70}\n## UKURAN MAKS = {ukuran} KARAKTER ({len(posisi)} chunk)\n{'#' * 70}") for i, (m, a) in enumerate(random.sample(posisi, min(JUMLAH_SAMPEL, len(posisi))), 1): info = analisis_chunk(teks, m, a) print(f"\n--- Contoh {i} (posisi {m}–{a}) ---") print(f" Panjang : {info['panjang']} karakter") print(f" Berakhir kalimat : {'✅ YA' if info['berakhir_kalimat'] else '❌ TIDAK'}") print(f" Putus di tg. kata : {'❌ YA' if info['terpotong_kata'] else '✅ tidak'}") print(f" Pola tabel : {'⚠️ YA' if info['ada_tabel'] else '✅ tidak'}") simbol = f"⚠️ {info['jumlah_simbol_rusak']} simbol" if info["ada_simbol_rusak"] else "✅ tidak" print(f" Simbol rusak : {simbol}") print(f" Awal : {rapikan(teks[m:m + 100])}") print(f" Akhir : ...{rapikan(teks[max(m, a - 100):a])}") # ---------- Langkah 4: rekap (semua chunk) ---------- print("\n" + "=" * 70) print(" LANGKAH 4 — REKAP & KESIMPULAN (dihitung dari semua chunk)") print("=" * 70) ringkasan = {} for (ukuran, nama), posisi in posisi_semua.items(): semua = [analisis_chunk(teks, m, a) for m, a in posisi] ringkasan[(ukuran, nama)] = { "jumlah_chunk": len(semua), "terpotong_kalimat": sum(not x["berakhir_kalimat"] for x in semua), "terpotong_kata": sum(x["terpotong_kata"] for x in semua), "ada_tabel": sum(x["ada_tabel"] for x in semua), "ada_simbol_rusak": sum(x["ada_simbol_rusak"] for x in semua), "rata_panjang": sum(x["panjang"] for x in semua) / len(semua), } kolom = (f"{'Ukuran':<8}{'Metode':<8}{'Chunk':<7}{'Rata2 pjg':<11}" f"{'Putus kalimat':<16}{'Putus kata':<14}{'Tabel':<12}{'Simbol':<10}") print("\n" + kolom) print("-" * len(kolom)) for (ukuran, nama), r in ringkasan.items(): n = r["jumlah_chunk"] def sel(k): return f"{r[k]} ({persen(r[k], n)})" print(f"{ukuran:<8}{nama:<8}{n:<7}{r['rata_panjang']:<11.0f}" f"{sel('terpotong_kalimat'):<16}{sel('terpotong_kata'):<14}" f"{sel('ada_tabel'):<12}{sel('ada_simbol_rusak'):<10}") def jml(nama, k): return sum(r[k] for (u, nm), r in ringkasan.items() if nm == nama) tot = {nm: jml(nm, "jumlah_chunk") for nm in METODE} jumlah_cerdas = [ringkasan[(u, "cerdas")]["jumlah_chunk"] for u, _ in PENGATURAN] print("\n--- KESIMPULAN OTOMATIS ---") print(f"1. Jumlah chunk (metode cerdas) per ukuran: {' → '.join(map(str, jumlah_cerdas))} " f"({'menurun' if jumlah_cerdas == sorted(jumlah_cerdas, reverse=True) else 'tidak monoton'} " f"seiring ukuran bertambah).") print(f"2. Chunk putus di tengah kata: tetap {jml('tetap', 'terpotong_kata')}/{tot['tetap']} " f"({persen(jml('tetap', 'terpotong_kata'), tot['tetap'])}) → cerdas " f"{jml('cerdas', 'terpotong_kata')}/{tot['cerdas']} " f"({persen(jml('cerdas', 'terpotong_kata'), tot['cerdas'])}).") print(f"3. Chunk tidak berakhir di akhir kalimat: tetap {jml('tetap', 'terpotong_kalimat')}/{tot['tetap']} " f"({persen(jml('tetap', 'terpotong_kalimat'), tot['tetap'])}) → cerdas " f"{jml('cerdas', 'terpotong_kalimat')}/{tot['cerdas']} " f"({persen(jml('cerdas', 'terpotong_kalimat'), tot['cerdas'])}).") print(f"4. Chunk dengan pola tabel (metode cerdas): {jml('cerdas', 'ada_tabel')}/{tot['cerdas']} " f"({persen(jml('cerdas', 'ada_tabel'), tot['cerdas'])}); " f"dengan simbol rusak: {jml('cerdas', 'ada_simbol_rusak')}/{tot['cerdas']} " f"({persen(jml('cerdas', 'ada_simbol_rusak'), tot['cerdas'])}).") if jml("cerdas", "terpotong_kalimat") / tot["cerdas"] > 0.5: print("5. Sebagian besar chunk masih tidak berakhir dengan tanda baca akhir kalimat " "(baris PDF sering berhenti di judul/daftar/tabel); pertimbangkan pemotongan " "per halaman atau per bagian/judul.") else: print("5. Metode cerdas menjaga sebagian besar chunk berakhir di batas kalimat.") print("\n" + "=" * 70) print(" ✅ Eksperimen chunking selesai.") print("=" * 70) if __name__ == "__main__": main()