Spaces:
Sleeping
Sleeping
| # This file contains the functions for the text processing and document retrieval segment of the chatbot | |
| import os | |
| from typing import List, Dict, Any | |
| import pymupdf4llm | |
| import re | |
| import unicodedata | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| import chromadb | |
| from chromadb.utils import embedding_functions | |
| def parse_pdf(filepath: str, write_images: bool = False) -> List[Dict[str, Any]]: | |
| """ | |
| Parse a PDF file and extract text with metadata from each page using pymupdf4llm. | |
| Args: | |
| filepath (str): Path to the PDF file | |
| write_images (bool): Whether to extract and save images from the PDF | |
| Returns: | |
| list: List of dictionaries with format including filename, page, text, and additional metadata | |
| """ | |
| result = [] | |
| # Extract filename from filepath | |
| filename = os.path.basename(filepath) | |
| try: | |
| # Extract text using pymupdf4llm with page-wise extraction | |
| page_data_list = pymupdf4llm.to_markdown( | |
| filepath, | |
| page_chunks = True, | |
| write_images = write_images | |
| ) | |
| # Process each page's data | |
| for page_info in page_data_list: | |
| # Extract the text content | |
| page_text = page_info.get('text', '') | |
| page_metadata = page_info.get('metadata', {}) | |
| # Create enhanced page data dictionary | |
| enhanced_page_data = { | |
| 'filename': filename, | |
| 'page': page_metadata.get('page', 0), | |
| 'text': page_text, | |
| 'text_format': 'markdown', | |
| 'extraction_method': 'pymupdf4llm', | |
| 'has_tables': '|' in page_text, # Basic table detection | |
| 'char_count': len(page_text), | |
| 'word_count': len(page_text.split()), | |
| 'line_count': len(page_text.split('\n')), | |
| 'images_extracted': write_images, | |
| 'source_bbox': page_metadata.get('bbox', None), | |
| 'source_page_size': page_metadata.get('page_size', None) | |
| } | |
| # Add any additional metadata from pymupdf4llm | |
| for key, value in page_metadata.items(): | |
| if key not in ['page', 'bbox', 'page_size']: # Avoid duplicates | |
| enhanced_page_data[f'source_{key}'] = value | |
| result.append(enhanced_page_data) | |
| except Exception as e: | |
| print(f"Error parsing PDF {filepath}: {str(e)}") | |
| # Fallback: try without page chunks | |
| try: | |
| md_text_fallback = pymupdf4llm.to_markdown(filepath, write_images = write_images) | |
| page_data = { | |
| 'filename': filename, | |
| 'page': 1, | |
| 'text': md_text_fallback, | |
| 'text_format': 'markdown', | |
| 'extraction_method': 'pymupdf4llm_fallback', | |
| 'has_tables': '|' in md_text_fallback, | |
| 'char_count': len(md_text_fallback), | |
| 'word_count': len(md_text_fallback.split()), | |
| 'line_count': len(md_text_fallback.split('\n')), | |
| 'images_extracted': write_images, | |
| 'error_note': 'Page-wise extraction failed, using full document' | |
| } | |
| result.append(page_data) | |
| except Exception as fallback_error: | |
| print(f"Fallback extraction also failed for {filepath}: {str(fallback_error)}") | |
| return [] | |
| return result | |
| def clean_text(text: str) -> str: | |
| """ | |
| Clean text for better RAG performance while preserving markdown structure. | |
| Args: | |
| text (str): Raw text to clean | |
| Returns: | |
| str: Cleaned text optimized for embedding and chunking | |
| """ | |
| if not text or not text.strip(): | |
| return "" | |
| # Normalize unicode characters | |
| text = unicodedata.normalize('NFKD', text) | |
| # Fix common PDF extraction artifacts | |
| # Fix hyphenated words broken across lines | |
| text = re.sub(r'(\w+)-\s*\n\s*(\w+)', r'\1\2', text) | |
| # Remove excessive whitespace while preserving structure | |
| text = re.sub(r' +', ' ', text) # Multiple spaces to single space | |
| text = re.sub(r'\t+', ' ', text) # Tabs to single space | |
| text = re.sub(r'\n +', '\n', text) # Remove spaces after newlines | |
| text = re.sub(r' +\n', '\n', text) # Remove spaces before newlines | |
| # Normalize line breaks (preserve paragraph structure) | |
| text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 consecutive newlines | |
| text = re.sub(r'\r\n', '\n', text) # Windows line endings to Unix | |
| text = re.sub(r'\r', '\n', text) # Old Mac line endings to Unix | |
| # Clean up common PDF artifacts | |
| # Remove standalone page numbers (numbers on their own line) | |
| text = re.sub(r'\n\s*\d+\s*\n', '\n', text) | |
| # Remove standalone roman numerals (common in headers/footers) | |
| text = re.sub(r'\n\s*[ivxlcdm]+\s*\n', '\n', text, flags = re.IGNORECASE) | |
| # Clean up markdown table formatting (preserve structure but clean spacing) | |
| # Fix spacing around table delimiters | |
| text = re.sub(r' +\| +', ' | ', text) # Normalize spacing around pipes | |
| text = re.sub(r'^\| +', '| ', text, flags = re.MULTILINE) # Start of line pipes | |
| text = re.sub(r' +\|$', ' |', text, flags = re.MULTILINE) # End of line pipes | |
| # Preserve list formatting but clean spacing | |
| text = re.sub(r'\n +([•\-\*\+])', r'\n\1', text) # Bullet lists | |
| text = re.sub(r'\n +(\d+\.)', r'\n\1', text) # Numbered lists | |
| # Clean up header formatting (preserve markdown headers) | |
| text = re.sub(r'\n +(#+)', r'\n\1', text) # Remove spaces before headers | |
| text = re.sub(r'(#+) +([^\n]+)', r'\1 \2', text) # Normalize header spacing | |
| # Remove excessive punctuation (but preserve meaningful punctuation) | |
| text = re.sub(r'\.{3,}', '...', text) # Multiple dots to ellipsis | |
| text = re.sub(r'-{3,}', '---', text) # Multiple dashes to em dash | |
| # Clean up quote marks | |
| text = re.sub(r'[\u201C\u201D\u201E]', '"', text) # Normalize quotes | |
| text = re.sub(r'[\u2018\u2019]', "'", text) # Normalize apostrophes | |
| # Remove zero-width characters and other invisible characters | |
| text = re.sub(r'[\u200B\u200C\u200D\uFEFF]', '', text) | |
| # Final cleanup | |
| text = text.strip() # Remove leading/trailing whitespace | |
| # Ensure text doesn't start or end with newlines after cleaning | |
| text = text.strip('\n') | |
| return text | |
| def chunk_text_recursive(text: str, chunk_size: int = 500, chunk_overlap: int = 150) -> List[str]: | |
| """ | |
| Split text into chunks using LangChain's RecursiveCharacterTextSplitter. | |
| Args: | |
| text (str): Text to be chunked | |
| chunk_size (int): Maximum size of each chunk in characters | |
| chunk_overlap (int): Number of characters to overlap between chunks | |
| Returns: | |
| List[str]: List of text chunks | |
| """ | |
| if not text or not text.strip(): | |
| return [] | |
| # Initialize the text splitter | |
| text_splitter = RecursiveCharacterTextSplitter( | |
| chunk_size = chunk_size, | |
| chunk_overlap = chunk_overlap, | |
| length_function = len, | |
| is_separator_regex = False, | |
| ) | |
| # Split the text and return chunks | |
| chunks = text_splitter.split_text(text) | |
| return chunks | |
| def access_chroma_collection(name: str): | |
| """ | |
| Get or create a Chroma collection with the given name using ephemeral client. | |
| Args: | |
| name (str): Name of the collection | |
| Returns: | |
| Collection: ChromaDB collection object | |
| """ | |
| client = chromadb.EphemeralClient() | |
| sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction( | |
| model_name = "BAAI/bge-small-en-v1.5" | |
| ) | |
| collection = client.get_or_create_collection(name = name, embedding_function = sentence_transformer_ef) | |
| return collection | |
| def preprocess_text(pages: List[Dict[str, Any]], chunk_size: int = 500, chunk_overlap: int = 150) -> List[Dict[str, Any]]: | |
| """ | |
| Clean and chunk text from parsed pages, retaining metadata. | |
| Args: | |
| pages (List[Dict[str, Any]]): Output from parse_pdf function | |
| chunk_size (int): Size for text chunking | |
| chunk_overlap (int): Overlap for text chunking | |
| Returns: | |
| List[Dict[str, Any]]: List of chunk dictionaries with metadata | |
| """ | |
| chunk_documents = [] | |
| for page in pages: | |
| # Clean the text | |
| cleaned_text = clean_text(page['text']) | |
| # Skip empty pages | |
| if not cleaned_text.strip(): | |
| continue | |
| # Chunk the cleaned text | |
| chunks = chunk_text_recursive(cleaned_text, chunk_size, chunk_overlap) | |
| # Create chunk documents with metadata | |
| for chunk_num, chunk_text in enumerate(chunks): | |
| chunk_doc = { | |
| # Original page metadata | |
| 'filename': page['filename'], | |
| 'page': page['page'], | |
| 'text_format': page['text_format'], | |
| 'extraction_method': page['extraction_method'], | |
| 'page_has_tables': page['has_tables'], | |
| 'page_char_count': page['char_count'], | |
| 'page_word_count': page['word_count'], | |
| 'page_line_count': page['line_count'], | |
| 'page_images_extracted': page['images_extracted'], | |
| 'page_source_bbox': page['source_bbox'], | |
| 'page_source_page_size': page['source_page_size'], | |
| # Chunk-specific data | |
| 'text': chunk_text, | |
| 'chunk_number': chunk_num + 1, | |
| 'total_chunks_for_page': len(chunks), | |
| 'chunk_char_count': len(chunk_text), | |
| 'chunk_word_count': len(chunk_text.split()), | |
| 'is_chunked': True, | |
| 'chunk_size_used': chunk_size, | |
| 'chunk_overlap_used': chunk_overlap | |
| } | |
| chunk_documents.append(chunk_doc) | |
| return chunk_documents | |
| def add_documents(name: str, documents: List[Dict[str, Any]]) -> None: | |
| """ | |
| Add documents to a ChromaDB collection. | |
| Args: | |
| name (str): Collection name | |
| documents (List[Dict[str, Any]]): List of document dictionaries | |
| """ | |
| collection = access_chroma_collection(name) | |
| chunk_documents = preprocess_text(documents) | |
| # Prepare data for ChromaDB | |
| ids = [] | |
| texts = [] | |
| metadatas = [] | |
| for doc in chunk_documents: | |
| # Create unique ID: {filename}_page{page}_chunk{chunk} | |
| doc_id = f"{doc['filename']}_page{doc['page']}_chunk{doc['chunk_number']}" | |
| ids.append(doc_id) | |
| texts.append(doc['text']) | |
| # Prepare metadata (exclude text and None values) | |
| metadata = {} | |
| for key, value in doc.items(): | |
| if key != 'text' and value is not None: | |
| metadata[key] = value | |
| metadatas.append(metadata) | |
| # Add to collection | |
| collection.add( | |
| ids = ids, | |
| documents = texts, | |
| metadatas = metadatas | |
| ) | |
| def retrieve_documents(name: str, query: str, top_k: int = 5) -> Dict[str, Any]: | |
| """ | |
| Query documents from a ChromaDB collection. | |
| Args: | |
| name (str): Collection name | |
| query (str): Query text | |
| top_k (int): Number of top results to return | |
| Returns: | |
| Dict[str, Any]: Query results from ChromaDB | |
| """ | |
| collection = access_chroma_collection(name) | |
| results = collection.query( | |
| query_texts = [query], | |
| n_results = top_k | |
| ) | |
| return results |