chatbotai_rag / src /core /document_processor.py
quoctrong's picture
feat: Phase 4 Upgrade - RAG Architecture Stability (mMARCO, pdfplumber, true recursive chunk, 3B LLM)
7a67d93
Raw
History Blame Contribute Delete
9.82 kB
"""
DocumentProcessor - Extracts text and metadata from PDF, DOCX, and TXT files using column-aware extraction.
Column-Aware PDF Extraction technique:
- Uses PyMuPDF (fitz) bounding box analysis to detect multi-column layout (academic papers, reports).
- Reads in correct flow order: full-width header -> left column -> right column -> full-width footer.
- Fallback: standard pdfplumber when fitz is unavailable.
"""
import re
import logging
from pathlib import Path
from typing import List, Dict, Any
logger = logging.getLogger(__name__)
def clean_pdf_text_artifacts(text: str) -> str:
"""Strips repeated character encoding artifacts commonly found in PDF text extraction."""
if not text:
return ""
def fix_word(w: str) -> str:
if len(w) >= 4 and len(w) % 2 == 0:
if all(w[i] == w[i+1] for i in range(0, len(w), 2)):
return "".join(w[i] for i in range(0, len(w), 2))
return w
lines = text.split("\n")
cleaned_lines = []
for line in lines:
words = line.split()
cleaned_words = [fix_word(w) for w in words]
cleaned_lines.append(" ".join(cleaned_words))
return "\n".join(cleaned_lines)
def filter_pdf_header_footer(text: str, min_len: int = 20) -> str:
"""Filters out overly short repeated header/footer lines (such as standalone page numbers)."""
lines = text.split("\n")
filtered = []
for l in lines:
l = l.strip()
if len(l) < min_len and re.match(r"^\d+$", l):
continue
filtered.append(l)
return "\n".join(filtered)
def _extract_pdf_column_aware_fitz(path: Path, file_name: str) -> List[Dict[str, Any]]:
"""
Column-Aware PDF Extraction using PyMuPDF (fitz) + pdfplumber for Tables.
"""
import fitz # PyMuPDF
import pdfplumber
doc = fitz.open(str(path))
pdf_plumb = None
try:
pdf_plumb = pdfplumber.open(str(path))
except Exception as e:
logger.warning("[PDF Extraction] pdfplumber failed to open for table extraction: %s", e)
pages_content = []
for page_idx, page in enumerate(doc):
rect = page.rect
page_width = rect.width
page_height = rect.height
mid_x = page_width / 2.0
blocks = page.get_text("blocks")
text_blocks = [b for b in blocks if len(b) >= 6 and b[6] == 0 and b[4].strip()]
full_width_top = []
left_column = []
right_column = []
full_width_bottom = []
for b in text_blocks:
x0, y0, x1, y1, text = b[0], b[1], b[2], b[3], b[4]
block_width = x1 - x0
center_x = (x0 + x1) / 2.0
if block_width > 0.60 * page_width:
if y0 < page_height * 0.40:
full_width_top.append((y0, text))
else:
full_width_bottom.append((y0, text))
elif center_x < mid_x:
left_column.append((y0, text))
else:
right_column.append((y0, text))
full_width_top.sort(key=lambda x: x[0])
left_column.sort(key=lambda x: x[0])
right_column.sort(key=lambda x: x[0])
full_width_bottom.sort(key=lambda x: x[0])
ordered_texts = []
for _, t in full_width_top:
ordered_texts.append(t.strip())
for _, t in left_column:
ordered_texts.append(t.strip())
for _, t in right_column:
ordered_texts.append(t.strip())
for _, t in full_width_bottom:
ordered_texts.append(t.strip())
# Markdown Table Extraction with pdfplumber
md_tables_str = ""
if pdf_plumb and page_idx < len(pdf_plumb.pages):
try:
plumb_page = pdf_plumb.pages[page_idx]
tables = plumb_page.extract_tables()
if tables:
md_tables = []
for table in tables:
if not table or not table[0]: continue
cleaned_table = []
for row in table:
# Clean cell text, replace newlines and pipes
cleaned_row = [str(cell).replace("\n", " ").replace("|", "").strip() if cell else "" for cell in row]
# Only add row if it's not completely empty
if any(cleaned_row):
cleaned_table.append(cleaned_row)
if not cleaned_table: continue
header = cleaned_table[0]
md_table = "| " + " | ".join(header) + " |\n"
md_table += "| " + " | ".join(["---"] * len(header)) + " |\n"
for row in cleaned_table[1:]:
if len(row) < len(header):
row.extend([""] * (len(header) - len(row)))
elif len(row) > len(header):
row = row[:len(header)]
md_table += "| " + " | ".join(row) + " |\n"
md_tables.append(md_table)
if md_tables:
md_tables_str = "\n\n[BẢNG SỐ LIỆU ĐƯỢC TRÍCH XUẤT]:\n" + "\n\n".join(md_tables)
except Exception as e:
logger.warning("[PDF Extraction] pdfplumber table extraction failed on page %d: %s", page_idx, e)
page_text = "\n\n".join([t for t in ordered_texts if t]) + md_tables_str
page_text = clean_pdf_text_artifacts(page_text)
if page_text.strip():
pages_content.append({
"page": page_idx + 1,
"text": page_text.strip(),
"source": file_name,
"layout": "column-aware+tables" if md_tables_str else "column-aware"
})
doc.close()
if pdf_plumb:
pdf_plumb.close()
return pages_content
class DocumentProcessor:
"""Extracts text content and metadata from PDF, DOCX, and TXT files."""
def __init__(self):
pass
def extract_text_with_pages(self, file_path: str) -> List[Dict[str, Any]]:
"""
Reads a document file and returns a list of page dicts with text and metadata.
Output format: [{"page": 1, "text": "...", "source": "filename.pdf"}]
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
file_type = path.suffix.lower()
file_name = path.name
if file_type == ".pdf":
return self._extract_pdf(path, file_name)
elif file_type == ".docx":
return self._extract_docx(path, file_name)
elif file_type == ".txt":
return self._extract_txt(path, file_name)
else:
raise ValueError(f"Unsupported file type: {file_type}")
def _extract_pdf(self, path: Path, file_name: str) -> List[Dict[str, Any]]:
"""
Extracts PDF text using Column-Aware layout analysis.
Tries PyMuPDF (fitz) first, falling back to pdfplumber on failure.
"""
try:
pages = _extract_pdf_column_aware_fitz(path, file_name)
if pages:
logger.info("[PDF Extraction] Column-aware PyMuPDF succeeded for '%s' (%d pages)", file_name, len(pages))
return pages
except ImportError:
logger.warning("[PDF Extraction] PyMuPDF (fitz) unavailable. Falling back to pdfplumber.")
except Exception as e:
logger.warning("[PDF Extraction] PyMuPDF failed: %s. Falling back to pdfplumber.", e)
try:
import pdfplumber
pages_content = []
with pdfplumber.open(path) as pdf:
for idx, page in enumerate(pdf.pages):
raw_text = page.extract_text(layout=True) or page.extract_text() or ""
cleaned = clean_pdf_text_artifacts(raw_text.strip())
if cleaned:
pages_content.append({
"page": idx + 1,
"text": cleaned,
"source": file_name,
"layout": "pdfplumber"
})
if pages_content:
logger.info("[PDF Extraction] pdfplumber fallback succeeded for '%s'", file_name)
return pages_content
except Exception as e:
logger.error("[PDF Extraction] pdfplumber failed: %s", e)
raise RuntimeError(f"Failed to extract text from PDF: {file_name}")
def _extract_docx(self, path: Path, file_name: str) -> List[Dict[str, Any]]:
try:
import docx
doc = docx.Document(path)
full_text = []
for p in doc.paragraphs:
if p.text.strip():
full_text.append(clean_pdf_text_artifacts(p.text.strip()))
combined_text = "\n".join(full_text)
return [{
"page": 1,
"text": combined_text,
"source": file_name
}]
except Exception as e:
raise RuntimeError(f"Failed to read DOCX file {file_name}: {e}")
def _extract_txt(self, path: Path, file_name: str) -> List[Dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read().strip()
return [{
"page": 1,
"text": clean_pdf_text_artifacts(content),
"source": file_name
}]
except Exception as e:
raise RuntimeError(f"Failed to read TXT file {file_name}: {e}")