import os import glob import logging import tempfile from pathlib import Path from typing import List, Dict, Any, Optional from dataclasses import dataclass, field import openai import sounddevice as sd import soundfile as sf import assemblyai as aai from PyPDF2 import PdfReader from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility from crewai import Agent, Task, Crew from crewai.tools import BaseTool from crewai.flow.flow import Flow, start, listen import config # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # Initialize clients openai_client = openai.OpenAI(api_key=config.OPENAI_API_KEY) aai.settings.api_key = config.ASSEMBLYAI_API_KEY # Global variables for caching _milvus_connection = None _collection = None # File patterns FILE_PATTERNS = ["*.pdf", "*.mp3", "*.wav", "*.m4a", "*.flac", "*.txt", "*.md"] AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.flac'} TEXT_EXTENSIONS = {'.txt', '.md'} @dataclass class DataIngestionState: """State for data ingestion flow""" collection: Optional[Collection] = None chunks: List[Dict[str, Any]] = field(default_factory=list) processed_files: List[str] = field(default_factory=list) discovered_files: List[str] = field(default_factory=list) @dataclass class QueryState: """State for query processing flow""" query: str = "" transcribed_query: str = "" search_results: str = "" final_response: str = "" audio_file: Optional[str] = None def get_milvus_connection(): """Get or create Milvus connection""" global _milvus_connection if _milvus_connection is None: try: _milvus_connection = connections.connect(host=config.MILVUS_HOST, port=config.MILVUS_PORT) logger.info("šŸ“” Connected to Milvus") except Exception as e: logger.error(f"Failed to connect to Milvus: {e}") raise return _milvus_connection def get_collection(): """Get or create collection""" global _collection if _collection is None: get_milvus_connection() _collection = Collection(config.COLLECTION_NAME) _collection.load() return _collection def transcribe_audio_file(audio_file: str) -> str: """Transcribe audio file using AssemblyAI""" transcriber = aai.Transcriber() transcript = transcriber.transcribe(audio_file) return transcript.text def search_vector_database(query: str, limit: int = 5) -> List[Dict[str, Any]]: """Search vector database for relevant information""" try: collection = get_collection() # Generate query embedding response = openai_client.embeddings.create(model=config.EMBEDDING_MODEL, input=[query]) query_embedding = response.data[0].embedding # Search search_params = {"metric_type": "L2", "params": {"nprobe": 10}} results = collection.search( data=[query_embedding], anns_field="embedding", param=search_params, limit=limit, output_fields=["text", "source", "content_type"] ) # Format results search_results = [] for hits in results: for hit in hits: search_results.append({ "text": hit.entity.get("text"), "source": hit.entity.get("source"), "content_type": hit.entity.get("content_type"), "score": hit.score }) return search_results except Exception as e: logger.error(f"Error searching vector database: {e}") return [] def format_search_results(search_results: List[Dict[str, Any]]) -> str: """Format search results into readable string""" if not search_results: return "No relevant documents found." formatted_results = [] for result in search_results: relevance = (1 - result['score']) * 100 formatted_results.append( f"Source: {result['source']} ({result['content_type']})\n" f"Relevance: {relevance:.1f}%\n" f"Content: {result['text'][:200]}...\n" f"---" ) return "\n".join(formatted_results) class SearchKnowledgeBaseTool(BaseTool): """Tool for searching the knowledge base""" name: str = "search_knowledge_base" description: str = "Search the multimodal knowledge base for relevant information" def _run(self, query: str) -> str: """Search the knowledge base and return formatted results""" logger.info(f"Searching knowledge base for: {query}") search_results = search_vector_database(query) return format_search_results(search_results) class DataIngestionFlow(Flow): """CrewAI Flow for data ingestion and vector database setup""" def __init__(self): super().__init__() @start() def discover_files(self) -> DataIngestionState: """Discover all files in the data directory""" logger.info("šŸ” Discovering files in data directory...") data_dir = Path(config.DATA_DIR) if not data_dir.exists(): raise FileNotFoundError(f"Data directory not found: {data_dir}") discovered_files = [] for pattern in FILE_PATTERNS: files = glob.glob(str(data_dir / pattern)) discovered_files.extend(files) discovered_files = sorted(list(set(discovered_files))) logger.info(f"šŸ“ Discovered {len(discovered_files)} files") return DataIngestionState(discovered_files=discovered_files) @listen(lambda state: state.discovered_files is not None) def setup_vector_database(self, state: DataIngestionState) -> DataIngestionState: """Initialize Milvus connection and collection""" logger.info("šŸ”§ Setting up vector database...") get_milvus_connection() if not utility.has_collection(config.COLLECTION_NAME): fields = [ FieldSchema("id", DataType.INT64, is_primary=True, auto_id=True), FieldSchema("text", DataType.VARCHAR, max_length=65535), FieldSchema("source", DataType.VARCHAR, max_length=255), FieldSchema("content_type", DataType.VARCHAR, max_length=50), FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=config.EMBEDDING_DIM) ] schema = CollectionSchema(fields, "Multimodal RAG collection") collection = Collection(config.COLLECTION_NAME, schema) collection.create_index("embedding", {"index_type": "IVF_FLAT", "metric_type": "L2", "params": {"nlist": 1024}}) logger.info("āœ… Collection created and indexed") else: logger.info("āœ… Collection already exists") collection = Collection(config.COLLECTION_NAME) collection.load() logger.info("āœ… Vector database setup completed") return DataIngestionState(discovered_files=state.discovered_files, collection=collection) @listen(lambda state: state.collection is not None) def process_multimodal_data(self, state: DataIngestionState) -> DataIngestionState: """Process discovered files from the data directory""" logger.info(f"šŸ“„ Processing {len(state.discovered_files)} discovered files...") chunks = [] processed_files = [] for file_path in state.discovered_files: file_path = Path(file_path) filename = file_path.name logger.info(f"šŸ”„ Processing: {filename}") try: # Process different file types if file_path.suffix.lower() == '.pdf': with open(file_path, 'rb') as f: reader = PdfReader(f) text = "\n".join(page.extract_text() for page in reader.pages) content_type = "pdf" elif file_path.suffix.lower() in AUDIO_EXTENSIONS: text = transcribe_audio_file(str(file_path)) content_type = "audio" elif file_path.suffix.lower() in TEXT_EXTENSIONS: with open(file_path, 'r', encoding='utf-8') as f: text = f.read() content_type = "text" else: logger.warning(f"āš ļø Skipping unsupported file: {filename}") continue # Create chunks chunk_size = 1000 for i in range(0, len(text), chunk_size): chunk = text[i:i + chunk_size] if chunk.strip(): chunks.append({ "text": chunk, "source": filename, "content_type": content_type }) processed_files.append(filename) except Exception as e: logger.error(f"āŒ Error processing {filename}: {e}") continue if not chunks: logger.warning("No content extracted from files") return DataIngestionState( discovered_files=state.discovered_files, collection=state.collection, chunks=[], processed_files=[] ) logger.info(f"āœ… Processed {len(chunks)} chunks from {len(processed_files)} files") return DataIngestionState( discovered_files=state.discovered_files, collection=state.collection, chunks=chunks, processed_files=processed_files ) @listen(lambda state: len(state.chunks) > 0) def generate_embeddings_flow(self, state: DataIngestionState) -> DataIngestionState: """Generate embeddings for processed chunks""" logger.info(f"🧠 Generating embeddings for {len(state.chunks)} chunks...") texts = [chunk["text"] for chunk in state.chunks] batch_size = 100 all_embeddings = [] for i in range(0, len(texts), batch_size): batch_texts = texts[i:i+batch_size] response = openai_client.embeddings.create(model=config.EMBEDDING_MODEL, input=batch_texts) batch_embeddings = [data.embedding for data in response.data] all_embeddings.extend(batch_embeddings) # Assign embeddings to chunks updated_chunks = [] for chunk, embedding in zip(state.chunks, all_embeddings): chunk_copy = chunk.copy() chunk_copy["embedding"] = embedding updated_chunks.append(chunk_copy) logger.info("āœ… Embeddings generation completed") return DataIngestionState( discovered_files=state.discovered_files, collection=state.collection, chunks=updated_chunks, processed_files=state.processed_files ) @listen(lambda state: all(chunk.get("embedding") is not None for chunk in state.chunks)) def store_in_vector_database(self, state: DataIngestionState) -> DataIngestionState: """Insert processed chunks into Milvus""" logger.info(f"šŸ’¾ Inserting {len(state.chunks)} chunks into vector database...") data = [ [chunk["text"] for chunk in state.chunks], [chunk["source"] for chunk in state.chunks], [chunk["content_type"] for chunk in state.chunks], [chunk["embedding"] for chunk in state.chunks] ] state.collection.insert(data) state.collection.flush() logger.info("āœ… Data insertion completed") return state class MultimodalRAGFlow(Flow): """CrewAI Flow for query processing and response generation""" def __init__(self): super().__init__() @start() def transcribe_audio_if_needed(self, query: str, audio_file: Optional[str] = None) -> QueryState: """Transcribe audio if query is audio-based""" if audio_file: logger.info("šŸŽ¤ Transcribing audio file...") transcribed_query = transcribe_audio_file(audio_file) logger.info(f"āœ… Transcribed: {transcribed_query}") else: transcribed_query = query logger.info("šŸ“ Using text query directly") return QueryState(query=query, audio_file=audio_file, transcribed_query=transcribed_query) @listen(lambda state: state.transcribed_query is not None) def search_knowledge_base(self, state: QueryState) -> QueryState: """Search the vector database for relevant information""" logger.info("šŸ” Searching knowledge base...") search_results = search_vector_database(state.transcribed_query) formatted_results = format_search_results(search_results) logger.info("āœ… Search completed") return QueryState( query=state.query, audio_file=state.audio_file, transcribed_query=state.transcribed_query, search_results=formatted_results ) @listen(lambda state: state.search_results is not None) def generate_response(self, state: QueryState) -> QueryState: """Generate final response using CrewAI agents""" logger.info("šŸ¤– Generating response...") try: # Create agents research_agent = Agent( role="Information Retrieval Specialist", goal="Find the most relevant information from the knowledge base to answer user queries", backstory="You are an expert at analyzing queries and searching through multimodal knowledge bases to find the most relevant information.", tools=[SearchKnowledgeBaseTool()], verbose=True, allow_delegation=False ) response_agent = Agent( role="Response Generator", goal="Generate comprehensive, accurate, and helpful responses based on retrieved information", backstory="You are an expert at synthesizing information from multiple sources and creating clear, informative responses.", verbose=True, allow_delegation=False ) # Create tasks research_task = Task( description=f"Search for information relevant to: '{state.transcribed_query}'. Use the search_knowledge_base tool to find the most relevant context.", agent=research_agent, expected_output="Detailed information from the knowledge base with proper citations" ) response_task = Task( description=f"Based on the research findings, generate a comprehensive response to: '{state.transcribed_query}'.", agent=response_agent, expected_output="A well-structured, comprehensive response with proper citations" ) # Create crew and execute crew = Crew( agents=[research_agent, response_agent], tasks=[research_task, response_task], verbose=True, memory=False ) result = crew.kickoff() logger.info("āœ… Response generated") return QueryState( query=state.query, audio_file=state.audio_file, transcribed_query=state.transcribed_query, search_results=state.search_results, final_response=result ) except Exception as e: logger.error(f"Error generating response: {e}") # Fallback response fallback_response = f"Based on the search results:\n{state.search_results}\n\nQuery: {state.transcribed_query}" return QueryState( query=state.query, audio_file=state.audio_file, transcribed_query=state.transcribed_query, search_results=state.search_results, final_response=fallback_response ) def record_audio(duration: int = 10, sample_rate: int = 16000) -> str: """Record audio from microphone and save to temporary file""" print(f"šŸŽ¤ Recording for {duration} seconds... Speak now!") print("Press Ctrl+C to stop early") try: audio_data = sd.rec(int(duration * sample_rate), samplerate=sample_rate, channels=1, dtype='float64') sd.wait() temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav') sf.write(temp_file.name, audio_data, sample_rate) print("āœ… Recording completed!") return temp_file.name except KeyboardInterrupt: print("\nā¹ļø Recording stopped by user") temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav') sf.write(temp_file.name, audio_data, sample_rate) return temp_file.name except Exception as e: print(f"āŒ Recording failed: {e}") raise def process_query(query: str, audio_file: Optional[str] = None) -> str: """Process a query through the RAG flow""" try: rag_flow = MultimodalRAGFlow() # Start the flow and execute all steps initial_state = rag_flow.transcribe_audio_if_needed(query, audio_file) # Manually execute the remaining steps since Flow isn't auto-executing search_state = rag_flow.search_knowledge_base(initial_state) final_state = rag_flow.generate_response(search_state) return final_state.final_response except Exception as e: logger.error(f"Error processing query: {e}") return f"Sorry, I encountered an error while processing your query: {str(e)}" def check_system_status(): """Check if the system is ready""" try: # Check Milvus connection get_milvus_connection() # Check if collection exists and has data if utility.has_collection(config.COLLECTION_NAME): collection = Collection(config.COLLECTION_NAME) collection.load() # Check if collection has data stats = collection.get_statistics() if stats.get("row_count", 0) > 0: logger.info("āœ… System ready with data") return True else: logger.info("āš ļø Collection exists but is empty") return False else: logger.info("āš ļø Collection does not exist") return False except Exception as e: logger.error(f"āŒ System check failed: {e}") return False def main(): """Main application entry point""" print("\nšŸ¤– Welcome to Multimodal Agentic RAG System!") print("=" * 50) # Validate API keys if not config.ASSEMBLYAI_API_KEY or not config.OPENAI_API_KEY: print("\nāŒ Missing API keys!") print("Please create a .env file with:") print("ASSEMBLYAI_API_KEY=your_key_here") print("OPENAI_API_KEY=your_key_here") return # Check system status and setup if needed print("šŸ” Checking system status...") try: if check_system_status(): print("āœ… System ready!") else: print("\nāš ļø System not set up yet. Let's set it up first!") print("šŸ“” Connecting to Milvus...") # Use the DataIngestionFlow to set up the system ingestion_flow = DataIngestionFlow() initial_state = ingestion_flow.discover_files() # Manually execute the flow steps setup_state = ingestion_flow.setup_vector_database(initial_state) process_state = ingestion_flow.process_multimodal_data(setup_state) if len(process_state.chunks) > 0: embed_state = ingestion_flow.generate_embeddings_flow(process_state) final_state = ingestion_flow.store_in_vector_database(embed_state) print("āœ… Setup completed!") else: print("āš ļø No data to process") except Exception as e: print(f"\nāš ļø Error checking system status: {e}") print("Make sure Milvus is running: docker-compose up -d") return # Main interaction loop while True: print("\nWhat would you like to do?") print("1. šŸ’¬ Ask a question (text)") print("2. šŸŽ¤ Record and ask a question") print("3. 🚪 Exit") choice = input("\nEnter your choice (1-3): ").strip() if choice == "1": query = input("\nšŸ’¬ Enter your question: ").strip() if query: print("\nšŸ¤” Processing...") response = process_query(query) print(f"\nšŸ¤– Response:\n{response}") elif choice == "2": duration = input("\nšŸŽ¤ Recording duration in seconds (default 10): ").strip() duration = int(duration) if duration.isdigit() else 10 audio_file = record_audio(duration) if audio_file: print("\nšŸ¤” Processing...") response = process_query("", audio_file) print(f"\nšŸ¤– Response:\n{response}") try: os.unlink(audio_file) except: pass elif choice == "3": print("\nšŸ‘‹ Goodbye!") break else: print("āŒ Invalid choice. Please enter 1-3.") if __name__ == "__main__": try: main() except Exception as e: print(f"āŒ Fatal error: {e}") import traceback traceback.print_exc()