7th_handle / src /chunk_sermons.py
Adoption's picture
feat: implement sermon message processing and RAG pipeline with Streamlit dashboard and data ingestion tools
0004cda
Raw
History Blame Contribute Delete
11.7 kB
import os
import re
import pickle
import json
import traceback
import fitz
from langchain_core.documents import Document
FOLDER = "./sermons_all"
OUTPUT_FILE = "sermon_chunks.pkl"
DLQ_FILE = "chunking_dlq.json"
def clean_filename(name):
name = re.sub(r'[\\/*?:"<>|]', '', name)
name = re.sub(r'\s+', ' ', name).strip()
return name
def extract_sermon_info(filename):
name = filename.replace(".pdf", "").replace(".PDF", "")
parts = name.split(" ", 1)
if len(parts) == 2:
date_code = parts[0]
title = parts[1]
else:
date_code = "00-0000"
title = name
return date_code, title
def extract_location(first_page_text, title):
# E.g. "Faith Is The SubstanceOakland, California, USA" or "Jeffersonville, Indiana, USA"
lines = [line.strip() for line in first_page_text.split("\n") if line.strip()][:6]
location = "Unknown"
for line in lines:
if "usa" in line.lower() or "u.s.a." in line.lower() or "unknown" in line.lower():
# If the line starts with the title, strip the title prefix
if line.lower().startswith(title.lower()):
loc_candidate = line[len(title):].strip()
if loc_candidate:
location = loc_candidate
break
# Spacing/punctuation fallback
match = re.search(re.escape(title), line, re.IGNORECASE)
if match:
loc_candidate = line[match.end():].strip()
if loc_candidate:
location = loc_candidate
break
# On its own line
location = line
break
location = re.sub(r'^[^\w]+', '', location).strip()
return location
def clean_line(line, clean_title):
cleaned = line.strip()
if not cleaned:
return None
# Ignore page boundaries
if cleaned.startswith("--- PAGE") and cleaned.endswith("---"):
return None
# Dynamic title header matching
# Strips leading and trailing page numbers/digits to isolate the clean title
alphanum_line = re.sub(r'[^a-zA-Z0-9]', '', cleaned).lower()
stripped_alphanum = re.sub(r'^\d+|\d+$', '', alphanum_line)
if stripped_alphanum == clean_title:
return None
# Ignore common sermon locations / dates at startup
if cleaned in ["Jeffersonville, Indiana, USA", "Chicago, Illinois, USA", "Macon, Georgia, USA", "Spindale, North Carolina, USA"]:
return None
return line
def is_adjacent_to_header(lines, idx, clean_title_key):
# Check previous non-empty lines
for i in range(idx - 1, -1, -1):
stripped = lines[i].strip()
if stripped:
if clean_line(stripped, clean_title_key) is None:
return True
break
# Check next non-empty lines
for i in range(idx + 1, len(lines)):
stripped = lines[i].strip()
if stripped:
if clean_line(stripped, clean_title_key) is None:
return True
break
return False
def parse_pdf_generator(filepath, title):
"""
Generator that parses a single PDF page-by-page and yields parsed paragraph dictionaries with location.
"""
doc = fitz.open(filepath)
clean_title_key = re.sub(r'[^a-zA-Z0-9]', '', title).lower()
# Extract location from first page
location = "Unknown"
if len(doc) > 0:
first_page_text = doc[0].get_text()
if first_page_text:
location = extract_location(first_page_text, title)
current_para_num = 1
current_para_text = []
current_para_start_page = 1
expected_para = 1
for page_idx, page in enumerate(doc):
page_num = page_idx + 1
text = page.get_text()
if not text:
continue
lines = text.split("\n")
for line_idx, line in enumerate(lines):
line_cleaned = clean_line(line, clean_title_key)
if line_cleaned is None:
continue
line_stripped = line_cleaned.strip()
if not line_stripped:
continue
# Strip standalone page number if adjacent to title header
if line_stripped.isdigit() and is_adjacent_to_header(lines, line_idx, clean_title_key):
continue
# 1. Check if line is exactly a paragraph number
if line_stripped.isdigit():
val = int(line_stripped)
if expected_para <= val <= expected_para + 5:
if val == 1:
# Merge pre-intro text into paragraph 1
current_para_num = 1
expected_para = 2
continue
# Yield previous paragraph
if current_para_text:
yield {
"paragraph": str(current_para_num),
"text": " ".join(current_para_text),
"page_start": current_para_start_page,
"page_end": page_num,
"location": location
}
current_para_text = []
current_para_num = val
expected_para = val + 1
current_para_start_page = page_num
continue
# 2. Check if line ends with a paragraph number
matched_end = False
for candidate in range(expected_para, expected_para + 3):
pattern = rf"^(?P<text>.*?)(?<!\d){candidate}$"
match = re.match(pattern, line_stripped)
if match:
text_before = match.group("text").strip()
if text_before:
current_para_text.append(text_before)
# Yield previous paragraph
if current_para_text:
yield {
"paragraph": str(current_para_num),
"text": " ".join(current_para_text),
"page_start": current_para_start_page,
"page_end": page_num,
"location": location
}
current_para_text = []
current_para_num = candidate
expected_para = candidate + 1
current_para_start_page = page_num
matched_end = True
break
if matched_end:
continue
# 3. Check if line starts with a paragraph number
matched_start = False
for candidate in range(expected_para, expected_para + 3):
pattern = rf"^{candidate}(?!\d)\s*(?P<text>.*)$"
match = re.match(pattern, line_stripped)
if match:
text_after = match.group("text").strip()
# Yield previous paragraph
if current_para_text:
yield {
"paragraph": str(current_para_num),
"text": " ".join(current_para_text),
"page_start": current_para_start_page,
"page_end": page_num,
"location": location
}
current_para_text = []
current_para_num = candidate
expected_para = candidate + 1
current_para_start_page = page_num
if text_after:
current_para_text.append(text_after)
matched_start = True
break
if matched_start:
continue
current_para_text.append(line_stripped)
# Yield the final paragraph
if current_para_text:
yield {
"paragraph": str(current_para_num),
"text": " ".join(current_para_text),
"page_start": current_para_start_page,
"page_end": len(doc),
"location": location
}
def scan_all_sermons_generator(folder):
"""
Generator that processes all PDFs in the folder one by one.
Yields tuple of (Document, None) on success, or (None, err_dict) on failure.
"""
files = sorted([f for f in os.listdir(folder) if f.lower().endswith(".pdf")])
print(f"Discovered {len(files)} PDFs in '{folder}' to chunk.")
for filename in files:
filepath = os.path.join(folder, filename)
date_code, title = extract_sermon_info(filename)
try:
for chunk in parse_pdf_generator(filepath, title):
text_clean = re.sub(r"\s+", " ", chunk["text"]).strip()
doc = Document(
page_content=text_clean,
metadata={
"source": filename,
"title": title,
"date_code": date_code,
"paragraph": chunk["paragraph"],
"page_start": chunk["page_start"],
"page_end": chunk["page_end"],
"location": chunk["location"]
}
)
yield doc, None
except Exception as e:
err_info = {
"filename": filename,
"error": str(e),
"traceback": traceback.format_exc()
}
yield None, err_info
def main():
if not os.path.exists(FOLDER):
print(f"Error: Folder '{FOLDER}' does not exist.")
return
all_chunks = []
dlq_list = []
processed_count = 0
failed_count = 0
current_file = None
print("\nStarting ingestion pipeline...")
# Process using the generator
for doc, err in scan_all_sermons_generator(FOLDER):
if err:
print(f" [DLQ] Failed to parse: {err['filename']} - Error: {err['error']}")
dlq_list.append(err)
failed_count += 1
else:
all_chunks.append(doc)
file_source = doc.metadata["source"]
if file_source != current_file:
current_file = file_source
processed_count += 1
if processed_count % 50 == 0:
print(f"Processed {processed_count} sermons... ({len(all_chunks)} chunks total)")
# Save the Dead Letter Queue (DLQ) if any errors occurred
if dlq_list:
with open(DLQ_FILE, "w", encoding="utf-8") as f:
json.dump(dlq_list, f, indent=2)
print(f"\n[WARNING] Dead Letter Queue created with {len(dlq_list)} failures at '{DLQ_FILE}'.")
else:
if os.path.exists(DLQ_FILE):
os.remove(DLQ_FILE)
# Save output to pickle file
print(f"\nIngested {processed_count} sermons with {len(all_chunks)} total chunks.")
if failed_count > 0:
print(f"Failed to ingest {failed_count} files (logged to DLQ).")
print(f"Saving chunks to '{OUTPUT_FILE}'...")
with open(OUTPUT_FILE, "wb") as f:
pickle.dump(all_chunks, f)
print("Ingestion complete and successfully serialized!")
if __name__ == "__main__":
main()