first commit

This commit is contained in:
Cliff Julyano S
2026-11-02 11:00:37 +07:00
commit 5cfa138cf4
37 changed files with 935 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
"""
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()