"""Lab 1 - Eksperimen chunking (15 menit). 1. Baca satu PDF dengan baca_pdf() dan cetak 500 karakter pertama. 2. Potong dengan potong() memakai tiga ukuran (500, 1000, 1500) dan overlap 10-20%. 3. Lihat 3 chunk acak tiap ukuran: terpotong di tengah kalimat atau tabel? Versi ini dijalankan untuk ketiga PDF AI. Satu laporan PDF dibuat per dokumen: Lab1_Eksperimen_Chunking_1.pdf, _2.pdf, _3.pdf Jalankan semua : python lab1_chunking.py Jalankan satu : python lab1_chunking.py 2_Machine_Learning_dan_Deep_Learning.pdf """ import os import random import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from laporan_utils import Laporan from rag_utils import FOLDER_DOKUMEN, baca_pdf, norm, potong, potong_dengan_posisi BASE = os.path.dirname(os.path.abspath(__file__)) # Ketiga PDF yang dipakai. Letakkan di FOLDER_DOKUMEN, di folder skrip ini, atau di folder kerja. DAFTAR_PDF = [ "1_Pengenalan_Kecerdasan_Buatan.pdf", "2_Machine_Learning_dan_Deep_Learning.pdf", "3_Etika_Risiko_dan_Masa_Depan_AI.pdf", ] # PDF ini tidak memakai penanda "Tabel N." / "Sumber:", jadi wilayah tabel ditentukan lewat # teks baris judul kolom (awal) dan teks akhir baris terakhir tabel (akhir). PENANDA_TABEL = { "1_Pengenalan_Kecerdasan_Buatan.pdf": [ ("Periode Peristiwa Penting", "secara luas."), ("Sektor Contoh Penerapan", "pembuatan musik dan gambar."), ], "2_Machine_Learning_dan_Deep_Learning.pdf": [ ("Paradigma Penjelasan Contoh", "hadiah dan hukuman."), ("Arsitektur Kegunaan Utama", "konten sintetis."), ], "3_Etika_Risiko_dan_Masa_Depan_AI.pdf": [ ("Isu Penjelasan", "pelatihan."), ], } UKURAN = [500, 1000, 1500] OVERLAP = [0.10, 0.15, 0.20] OVERLAP_UTAMA = 0.15 SEED = 42 # --------------------------------------------------------------------------- utilitas def cari_pdf(nama): """Cari file PDF di FOLDER_DOKUMEN, folder skrip, lalu folder kerja.""" kandidat = [os.path.join(d, nama) for d in (FOLDER_DOKUMEN, BASE, os.getcwd())] for k in kandidat: if os.path.isfile(k): return k raise FileNotFoundError(f"{nama} tidak ditemukan. Sudah dicari di:\n " + "\n ".join(kandidat)) def bersihkan_footer(teks): """Buang baris nomor halaman ('Halaman 1') yang ikut terbaca di tengah isi. Dilakukan sebelum pemotongan, sehingga semua posisi karakter konsisten.""" baru, n = re.subn(r"^[ \t]*Halaman \d+[ \t]*\n?", "", teks, flags=re.MULTILINE) return baru, n def wilayah_tabel(teks, penanda): """Rentang karakter tiap tabel: dari teks judul kolom sampai akhir baris terakhir tabel. Penanda yang tidak ditemukan dilaporkan, bukan diam-diam dilewati.""" hasil = [] for awal, akhir in penanda: a = teks.find(awal) if a < 0: print(f" [PERINGATAN] awal tabel tidak ditemukan: {awal!r}") continue z = teks.find(akhir, a) if z < 0: print(f" [PERINGATAN] akhir tabel tidak ditemukan: {akhir!r}") continue hasil.append((a, z + len(akhir))) return hasil def batas_bersih(teks, pos): """True bila posisi `pos` jatuh di batas satuan teks yang wajar (bukan di tengah kalimat). Newline TIDAK otomatis dianggap batas, karena PDF memecah baris di tengah kalimat. Batas bersih bila: awal/akhir dokumen; ATAU tidak memotong kata dan teks sebelumnya berakhir . ! ? : ; ATAU bagian berikutnya diawali butir daftar ('- ') / judul bernomor ('3. ') sehingga teks sebelumnya adalah butir atau judul yang sudah selesai.""" if pos <= 0 or pos >= len(teks): return True kiri, kanan = teks[:pos], teks[pos:] if kiri[-1] not in " \n" and kanan[0] not in " \n": return False # memotong di tengah kata kiri_s, kanan_s = kiri.rstrip(), kanan.lstrip() if not kiri_s or not kanan_s: return True kata_akhir = kiri_s.split()[-1] if re.fullmatch(r"\d+\.", kata_akhir): return False # nomor judul ("3.") yang terpisah dari judulnya if kiri_s[-1] in ".!?:": return True return bool(re.match(r"(-\s|\d+\.\s)", kanan_s)) def awal_di_tengah_kalimat(teks, mulai): return not batas_bersih(teks, mulai) def akhir_di_tengah_kalimat(teks, akhir): return not batas_bersih(teks, akhir) def memotong_tabel(wil, mulai, akhir): """True bila chunk beririsan dengan tabel tetapi tidak memuat seluruh tabel.""" for a, b in wil: beririsan = mulai < b and akhir > a utuh = mulai <= a and akhir >= b if beririsan and not utuh: return True return False def pct(x, n): return f"{x}/{n} ({x / n:.0%})" if n else "0/0" # --------------------------------------------------------------------------- narasi dari data def catatan_kualitas(k, n_footer): """Penilaian kualitas teks yang dihitung dari angka, bukan ditulis tetap.""" masalah = [] if k["Karakter rusak (U+FFFD)"]: masalah.append(f"{k['Karakter rusak (U+FFFD)']} karakter rusak (U+FFFD)") if k["Sisa kode glyph (cid:)"]: masalah.append(f"{k['Sisa kode glyph (cid:)']} kode glyph '(cid:N)' (simbol gagal diterjemahkan)") if n_footer: masalah.append(f"{n_footer} baris nomor halaman 'Halaman N' yang menyelip di tengah isi " "(sudah dibuang sebelum pemotongan)") if not masalah: return "Teks terbaca bersih: tidak ada karakter rusak, kode glyph, atau sisa footer." return "Temuan kualitas teks: " + "; ".join(masalah) + "." def buat_kesimpulan(statistik_angka, jumlah_tabel): """Kesimpulan yang seluruh klaimnya dihitung dari statistik, jadi benar untuk PDF apa pun.""" rincian = "; ".join( f"ukuran {u}: awal di tengah kalimat {pct(a, n)}, akhir di tengah kalimat {pct(z, n)}, " f"memotong tabel {pct(t, n)}" for u, n, a, z, t in statistik_angka) kal = [f"Pemotongan per karakter tidak mengenal batas kalimat maupun tabel. Hasil seluruh chunk " f"(overlap {int(OVERLAP_UTAMA * 100)}%) - {rincian}."] pz = [z / n for _, n, _, z, _ in statistik_angka if n] pa = [a / n for _, n, a, _, _ in statistik_angka if n] if pz: if min(pz) > 0.5: kal.append("Pada setiap ukuran, mayoritas chunk berakhir di tengah kalimat.") elif max(pz) < 0.5: kal.append("Pada setiap ukuran, kurang dari separuh chunk berakhir di tengah kalimat.") else: kal.append("Proporsi chunk yang berakhir di tengah kalimat berbeda antar ukuran " f"({min(pz):.0%} sampai {max(pz):.0%}).") kal.append(f"Chunk yang dimulai di tengah kalimat berkisar {min(pa):.0%} sampai {max(pa):.0%}; " "chunk pertama selalu dimulai bersih.") if max(pz) - min(pz) <= 0.15: kal.append("Persentase chunk yang terpotong tidak banyak berubah saat ukuran diperbesar; " "yang berubah adalah jumlah chunk dan banyaknya topik per chunk.") kecil = [u for u, n, *_ in statistik_angka if n < 10] if kecil: kal.append(f"Catatan: dokumen ini pendek sehingga ukuran {', '.join(map(str, kecil))} hanya menghasilkan " "kurang dari 10 chunk; persentasenya mudah bergeser oleh satu chunk saja.") if jumlah_tabel == 0: kal.append("Tidak ada tabel yang terdeteksi pada dokumen ini.") else: kena = [str(u) for u, _, _, _, t in statistik_angka if t > 0] if len(kena) == len(statistik_angka): kal.append(f"Dari {jumlah_tabel} tabel, minimal satu terpotong oleh sedikitnya satu chunk pada " "setiap ukuran; baris tabel yang terpisah dari judul kolomnya sulit dipahami.") elif kena: kal.append(f"Dari {jumlah_tabel} tabel, pemotongan tabel terjadi pada ukuran {', '.join(kena)}.") else: kal.append(f"Dari {jumlah_tabel} tabel, tidak ada yang terpotong pada pengaturan ini.") kal.append("Perbaikan yang layak dicoba: pemotongan berbasis kalimat/paragraf dan memperlakukan " "tabel sebagai satu chunk utuh.") return " ".join(kal) # --------------------------------------------------------------------------- satu dokumen def jalankan(nama_pdf, nomor): pdf = cari_pdf(nama_pdf) print("\n" + "#" * 70 + f"\n# {nama_pdf}\n" + "#" * 70) # ---- Langkah 1 mentah = baca_pdf(pdf) teks, n_footer = bersihkan_footer(mentah) if not teks.strip(): raise ValueError(f"{nama_pdf}: tidak ada teks yang terbaca (mungkin PDF hasil scan).") n_huruf = sum(c.isalpha() for c in teks) kualitas = { "File": os.path.basename(pdf), "Total karakter": len(teks), "Karakter rusak (U+FFFD)": teks.count("\ufffd"), "Sisa kode glyph (cid:)": teks.count("(cid:"), "Persentase huruf": f"{n_huruf / len(teks):.0%}", } print("=== Langkah 1: 500 karakter pertama ===") print(teks[:500]) print(kualitas) # ---- Langkah 2 print("\n=== Langkah 2: jumlah chunk ===") tabel_jumlah = [["Ukuran (karakter)"] + [f"overlap {int(o * 100)}%" for o in OVERLAP]] for u in UKURAN: baris = [u] for o in OVERLAP: n = len(potong(teks, u, o)) baris.append(n) print(f"ukuran={u:5d} overlap={o:.0%} -> {n} chunk") tabel_jumlah.append(baris) # ---- Langkah 3 wil = wilayah_tabel(teks, PENANDA_TABEL.get(nama_pdf, [])) print(f"\nTabel terdeteksi: {len(wil)} -> {wil}") rnd = random.Random(SEED) cuplikan = {} statistik = [["Ukuran", "Jumlah chunk", "Awal di tengah kalimat", "Akhir di tengah kalimat", "Memotong tabel"]] statistik_angka = [] for u in UKURAN: pot = potong_dengan_posisi(teks, u, OVERLAP_UTAMA) n = len(pot) a = sum(awal_di_tengah_kalimat(teks, m) for m, _, _ in pot) z = sum(akhir_di_tengah_kalimat(teks, e) for _, e, _ in pot) t = sum(memotong_tabel(wil, m, e) for m, e, _ in pot) statistik.append([u, n, pct(a, n), pct(z, n), pct(t, n)]) statistik_angka.append((u, n, a, z, t)) pilih = sorted(rnd.sample(range(n), min(3, n))) cuplikan[u] = [(i + 1, pot[i][0], pot[i][1], pot[i][2], awal_di_tengah_kalimat(teks, pot[i][0]), akhir_di_tengah_kalimat(teks, pot[i][1]), memotong_tabel(wil, pot[i][0], pot[i][1])) for i in pilih] print(f"\n=== Langkah 3: ukuran {u} ({len(pilih)} chunk acak) ===") for no, m, e, isi, aw, ak, tb in cuplikan[u]: print(f"--- chunk #{no} [{m}:{e}] awal_tengah={aw} akhir_tengah={ak} potong_tabel={tb}") print(isi) catatan = catatan_kualitas(kualitas, n_footer) kesimpulan = buat_kesimpulan(statistik_angka, len(wil)) print("\nKualitas:", catatan) print("Kesimpulan:", kesimpulan) # ---- Laporan PDF keluar = os.path.join(BASE, f"Lab1_Eksperimen_Chunking_{nomor}.pdf") L = Laporan(keluar, f"Lab 1 - Eksperimen Chunking ({nomor}/{len(DAFTAR_PDF)})", f"{os.path.basename(pdf)} | Python (pdfplumber + fungsi potong)") L.h1("Langkah 1. Membaca PDF dan memeriksa teks") L.p(f"File yang dibaca: {os.path.basename(pdf)}. Berikut 500 karakter pertama hasil baca_pdf():") L.kode(teks[:500]) L.spasi() L.tabel([["Pemeriksaan", "Hasil"]] + [[k, v] for k, v in kualitas.items()], [6 * 28.35, 8 * 28.35]) L.p(catatan) L.h1("Langkah 2. Jumlah chunk tiap pengaturan") L.p("Fungsi potong(teks, ukuran, overlap) memakai jendela geser per karakter; langkah = ukuran x (1 - overlap).") L.tabel(tabel_jumlah) L.cap("Tabel 1. Jumlah chunk untuk 3 ukuran x 3 overlap. Makin besar ukuran, makin sedikit chunk; " "makin besar overlap, makin banyak chunk.") L.h1(f"Langkah 3. Tiga chunk acak per ukuran (overlap {int(OVERLAP_UTAMA * 100)}%, seed {SEED})") L.tabel(statistik) L.cap("Tabel 2. Statistik seluruh chunk pada tiap ukuran (bukan hanya yang dicuplik).") for u in UKURAN: L.h2(f"Ukuran {u} karakter") for no, m, e, isi, aw, ak, tb in cuplikan[u]: L.p(f"Chunk #{no} (karakter {m}-{e}) | awal di tengah kalimat: {'ya' if aw else 'tidak'} | " f"akhir di tengah kalimat: {'ya' if ak else 'tidak'} | memotong tabel: {'ya' if tb else 'tidak'}") L.kode(isi) L.spasi(4) L.h1("Kesimpulan") L.p(kesimpulan) L.halaman_baru() L.h1("Lampiran: kode lab1_chunking.py") L.kode(open(__file__, encoding="utf-8").read()) L.simpan() print(f"\nLaporan: {os.path.basename(keluar)}") def main(): # Argumen opsional: nama satu/lebih PDF. Tanpa argumen, ketiga PDF diproses. daftar = sys.argv[1:] or DAFTAR_PDF for i, nama in enumerate(daftar, 1): jalankan(os.path.basename(nama), i) if __name__ == "__main__": main()