import re def normalize_pdf_text(text): """Pembersih teks hasil ekstraksi PDF.""" # 1) Satukan rentetan huruf tunggal yang dipisah spasi (artefak PDF # yang sering memecah kata jadi "s h o l a t"). Hanya gabungkan # kalau ada >=3 huruf tunggal beruntun -> mencegah salah-gabung # kasus sah seperti "Andi a man" jadi "Andia man". text = re.sub( r'\b([a-zA-Z](?:\s[a-zA-Z]){2,})\b', lambda m: m.group(1).replace(' ', ''), text, ) # 2) Buang karakter yang bukan huruf/angka/spasi/tanda baca umum. # APOSTROF (') dipertahankan -> penting untuk transliterasi istilah # Islam: a'la, i'tidal, ta'awudz, dll. Rentang Arab juga utuh. text = re.sub(r"[^\w\s\.\,\?\!\:\;\(\)\-\'\u0600-\u06FF]", ' ', text) # 3) Ratakan spasi berlebih jadi satu spasi. text = re.sub(r'\s+', ' ', text).strip() return text # ==================================================================== # CHUNKING - recursive, hormati batas paragraf & kalimat # ==================================================================== def _split_sentences(text): """Pecah kalimat di . ! ? (tanda baca-nya tetap menempel pada kalimat).""" parts = re.split(r'(?<=[.!?])\s+', text) return [p.strip() for p in parts if p.strip()] def chunk_text(text, target_words=120, overlap_sentences=1): """ Bagi teks menjadi chunk yang menghormati batas alami (paragraf -> kalimat), bukan memotong sembarangan di tengah kalimat. Praktik standar RAG modern. target_words - target jumlah kata per chunk (chunk bisa sedikit di atas target karena kalimat terakhir disertakan utuh). overlap_sentences - berapa kalimat terakhir dari chunk sebelumnya dibawa ke awal chunk berikutnya (memberi konteks sambungan supaya jawaban yang menyebrang chunk tetap kelihatan). """ # Pecah dulu per paragraf (newline tunggal pun dianggap pemisah). paragraphs = [p.strip() for p in re.split(r'\n+', text) if p.strip()] # Lalu pecah paragraf jadi kalimat-kalimat. sentences = [] for para in paragraphs: sentences.extend(_split_sentences(para)) chunks = [] buf = [] # kalimat-kalimat yang sedang dikumpulkan buf_words = 0 for sent in sentences: sent_words = len(sent.split()) # Edge case: kalimat tunggal yang panjangnya jauh di atas target. # Tutup buffer sekarang, lalu paksa pecah kalimat itu per kata. if sent_words > target_words: if buf: chunks.append(" ".join(buf)) buf = buf[-overlap_sentences:] if overlap_sentences else [] buf_words = sum(len(s.split()) for s in buf) words = sent.split() for i in range(0, len(words), target_words): chunks.append(" ".join(words[i:i + target_words])) buf = [] buf_words = 0 continue # Kalau menambah kalimat ini bakal lewat target -> tutup chunk dulu. if buf_words + sent_words > target_words and buf: chunks.append(" ".join(buf)) # Overlap: bawa N kalimat terakhir ke chunk berikutnya. buf = buf[-overlap_sentences:] if overlap_sentences else [] buf_words = sum(len(s.split()) for s in buf) buf.append(sent) buf_words += sent_words if buf: chunks.append(" ".join(buf)) return [c for c in chunks if c.strip()] # ==================================================================== # LEGACY - tidak dipakai lagi setelah rag_engine pakai structured output. # Dipertahankan untuk kompatibilitas mundur (kalau ada modul lain yg impor). # ==================================================================== def clean_json_response(text): start_idx = text.find('[') end_idx = text.rfind(']') if start_idx != -1 and end_idx != -1: return text[start_idx:end_idx + 1] return text.strip()