Spaces:
Sleeping
Sleeping
| """ | |
| Tools for the General Purpose AI Assistant | |
| This module contains three main tools: | |
| 1. RAG Retriever - Retrieve relevant documents from ChromaDB collection | |
| 2. Web Search - Search the web using Tavily | |
| 3. Document Creator - Create downloadable Word documents | |
| Plus utility functions for PDF processing and text cleaning. | |
| """ | |
| import os | |
| import re | |
| import unicodedata | |
| from typing import List | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| from langchain_core.tools import tool | |
| from langchain_core.documents import Document | |
| from langchain_tavily import TavilySearch | |
| from langchain_chroma import Chroma | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| from langchain_community.document_loaders import PyMuPDFLoader | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| from docx import Document as WordDocument | |
| # Load environment variables | |
| load_dotenv() | |
| def parse_pdf(filepath: str) -> List[Document]: | |
| """ | |
| Parse a PDF file and extract text with metadata using LangChain's PyMuPDFLoader. | |
| Args: | |
| filepath (str): Path to the PDF file | |
| Returns: | |
| List[Document]: List of LangChain Document objects with page content and metadata | |
| """ | |
| try: | |
| # Use LangChain's PyMuPDFLoader for PDF parsing | |
| loader = PyMuPDFLoader(filepath) | |
| documents = loader.load() | |
| # Extract filename from filepath | |
| filename = os.path.basename(filepath) | |
| # Enhance metadata for each document | |
| for doc in documents: | |
| doc.metadata['filename'] = filename | |
| doc.metadata['text_format'] = 'text' | |
| doc.metadata['extraction_method'] = 'langchain_pymupdf' | |
| doc.metadata['has_tables'] = '|' in doc.page_content | |
| doc.metadata['char_count'] = len(doc.page_content) | |
| doc.metadata['word_count'] = len(doc.page_content.split()) | |
| doc.metadata['line_count'] = len(doc.page_content.split('\n')) | |
| return documents | |
| except Exception as e: | |
| print(f"Error parsing PDF {filepath}: {str(e)}") | |
| return [] | |
| 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_documents(documents: List[Document], chunk_size: int = 500, chunk_overlap: int = 150) -> List[Document]: | |
| """ | |
| Split LangChain Documents into chunks using RecursiveCharacterTextSplitter. | |
| Args: | |
| documents (List[Document]): List of LangChain Document objects | |
| chunk_size (int): Maximum size of each chunk in characters | |
| chunk_overlap (int): Number of characters to overlap between chunks | |
| Returns: | |
| List[Document]: List of chunked Document objects with preserved metadata | |
| """ | |
| if not documents: | |
| return [] | |
| # Initialize LangChain's text splitter | |
| text_splitter = RecursiveCharacterTextSplitter( | |
| chunk_size = chunk_size, | |
| chunk_overlap = chunk_overlap, | |
| length_function = len, | |
| is_separator_regex = False, | |
| ) | |
| # Split documents and preserve metadata | |
| chunked_docs = text_splitter.split_documents(documents) | |
| # Add chunk-specific metadata | |
| for i, doc in enumerate(chunked_docs): | |
| doc.metadata['chunk_number'] = i + 1 | |
| doc.metadata['chunk_char_count'] = len(doc.page_content) | |
| doc.metadata['chunk_word_count'] = len(doc.page_content.split()) | |
| return chunked_docs | |
| def get_vectorstore(collection_name: str = "general_collection", persist_directory: str = None) -> Chroma: | |
| """ | |
| Get or create a LangChain Chroma vectorstore. | |
| Args: | |
| collection_name (str): Name of the collection | |
| persist_directory (str): Directory to persist the vectorstore. | |
| If None, creates an ephemeral (in-memory) vectorstore. | |
| Returns: | |
| Chroma: LangChain Chroma vectorstore object | |
| """ | |
| embeddings = HuggingFaceEmbeddings(model_name = "BAAI/bge-small-en-v1.5") | |
| if persist_directory is None: | |
| # Create ephemeral (in-memory) vectorstore | |
| vectorstore = Chroma( | |
| collection_name = collection_name, | |
| embedding_function = embeddings | |
| ) | |
| else: | |
| # Create persistent vectorstore | |
| vectorstore = Chroma( | |
| collection_name = collection_name, | |
| embedding_function = embeddings, | |
| persist_directory = persist_directory | |
| ) | |
| return vectorstore | |
| def process_and_store_pdf(filepath: str, collection_name: str = "general_collection", | |
| chunk_size: int = 500, chunk_overlap: int = 150, | |
| persist_directory: str = None) -> int: | |
| """ | |
| Process a PDF file and store it in the Chroma vectorstore using LangChain. | |
| Args: | |
| filepath (str): Path to the PDF file | |
| collection_name (str): Name of the Chroma collection | |
| chunk_size (int): Size for text chunking | |
| chunk_overlap (int): Overlap for text chunking | |
| persist_directory (str): Directory to persist the vectorstore. | |
| If None, uses ephemeral (in-memory) storage. | |
| Returns: | |
| int: Number of chunks added to the vectorstore | |
| """ | |
| # Parse PDF using LangChain | |
| documents = parse_pdf(filepath) | |
| if not documents: | |
| return 0 | |
| # Clean text content | |
| for doc in documents: | |
| doc.page_content = clean_text(doc.page_content) | |
| # Chunk documents using LangChain | |
| chunked_docs = chunk_documents(documents, chunk_size, chunk_overlap) | |
| # Get vectorstore and add documents | |
| vectorstore = get_vectorstore(collection_name, persist_directory) | |
| vectorstore.add_documents(chunked_docs) | |
| return len(chunked_docs) | |
| def retrieve_documents(query: str) -> str: | |
| """ | |
| Retrieve relevant documents from the RAG collection using semantic search. | |
| Use this tool when the user asks questions that might be answered by previously uploaded documents. | |
| All uploaded documents are stored in a single collection. | |
| Args: | |
| query: The search query to find relevant documents | |
| Returns: | |
| str: Retrieved document contents with metadata | |
| """ | |
| try: | |
| # Use fixed collection name and settings | |
| collection_name = "general_collection" | |
| persist_directory = None # Ephemeral storage | |
| top_k = 3 | |
| # Get vectorstore using LangChain | |
| vectorstore = get_vectorstore(collection_name, persist_directory) | |
| # Perform similarity search with scores | |
| results = vectorstore.similarity_search_with_score(query, k = top_k) | |
| if not results: | |
| return "No relevant documents found in the collection. Make sure documents have been uploaded first." | |
| # Format results | |
| formatted_results = [] | |
| for i, (doc, score) in enumerate(results, 1): | |
| metadata = doc.metadata | |
| content = doc.page_content | |
| result_text = f"--- Result {i} (Relevance Score: {score:.4f}) ---\n" | |
| result_text += f"Source: {metadata.get('filename', 'Unknown')}\n" | |
| result_text += f"Page: {metadata.get('page', 'N/A')}\n" | |
| result_text += f"Content:\n{content}\n" | |
| formatted_results.append(result_text) | |
| return "\n\n".join(formatted_results) | |
| except Exception as e: | |
| return f"Error retrieving documents: {str(e)}" | |
| def web_search(query: str) -> str: | |
| """ | |
| Search the web using Tavily to find current information. | |
| Use this tool when the user asks about current events, real-time information, or topics not in the uploaded documents. | |
| Args: | |
| query: The search query | |
| Returns: | |
| str: Search results with relevant information | |
| """ | |
| # Initialize Tavily search | |
| tavily_search = TavilySearch( | |
| max_results = 1, | |
| topic = "general", | |
| search_depth = "advanced", | |
| include_answer = "advanced") | |
| try: | |
| # Use Tavily search for online content | |
| results = tavily_search.invoke(query) | |
| return f"Search results for '{query}':\n{results['answer']}" | |
| except Exception as e: | |
| return f"Error searching for '{query}': {str(e)}" | |
| # Helper function to get available tools | |
| def get_all_tools(): | |
| """Return all available tools for the agent.""" | |
| return [retrieve_documents, web_search] |