Download app.py from drhythmos/Multimodal-RAG-Research-Assistant: direct link, hf CLI and curl.
- Browser
- Download file 18.7 kB
-
https://huggingface.co/spaces/drhythmos/Multimodal-RAG-Research-Assistant/resolve/main/app.py
- Command line
-
hf download hf://spaces/drhythmos/Multimodal-RAG-Research-Assistant/app.py
-
curl -L -o app.py https://huggingface.co/spaces/drhythmos/Multimodal-RAG-Research-Assistant/resolve/main/app.py
18.7 kB
| import os | |
| import shutil | |
| import traceback | |
| import json | |
| import io | |
| import fitz # PyMuPDF | |
| import gradio as gr | |
| from typing import List, Dict | |
| from PIL import Image | |
| import torch | |
| from transformers import BlipProcessor, BlipForConditionalGeneration, pipeline | |
| import re | |
| from langchain_community.vectorstores.faiss import FAISS | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| from langchain_huggingface import HuggingFaceEmbeddings, HuggingFaceEndpoint | |
| from langchain_core.prompts import PromptTemplate | |
| from langchain_core.output_parsers import StrOutputParser | |
| from langchain_core.runnables import RunnablePassthrough | |
| # ------------------------- | |
| # Config - USING HUGGINGFACE TOKEN | |
| # ------------------------- | |
| HUGGINGFACE_API_KEY = os.getenv("HUGGINGFACE_API_KEY", "") | |
| if not HUGGINGFACE_API_KEY: | |
| raise ValueError("β HUGGINGFACE_API_KEY environment variable not set! Please add it in Space settings.") | |
| print(f"β HuggingFace API Key loaded: {HUGGINGFACE_API_KEY[:10]}...") | |
| # Use FREE HuggingFace embeddings (local) | |
| EMBEDDINGS = HuggingFaceEmbeddings( | |
| model_name="sentence-transformers/all-MiniLM-L6-v2", | |
| model_kwargs={'device': 'cpu'}, | |
| encode_kwargs={'normalize_embeddings': True} | |
| ) | |
| VECTORSTORE_PATH = "vectorstore" | |
| IMAGES_DIR = "pdf_images" | |
| TEMP_PDF = "temp.pdf" | |
| IMAGE_DESCRIPTIONS_FILE = "image_descriptions.json" | |
| os.makedirs(IMAGES_DIR, exist_ok=True) | |
| os.makedirs(VECTORSTORE_PATH, exist_ok=True) | |
| # Initialize BLIP model for image captioning | |
| print("π₯ Loading BLIP model for image analysis...") | |
| try: | |
| blip_processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-base") | |
| blip_model = BlipForConditionalGeneration.from_pretrained("Salesforce/blip-image-captioning-base") | |
| print("β BLIP model loaded successfully!") | |
| except Exception as e: | |
| print(f"β οΈ Warning: Could not load BLIP model: {e}") | |
| blip_processor = None | |
| blip_model = None | |
| # ------------------------- | |
| # Vision Model Helper | |
| # ------------------------- | |
| def analyze_image_with_blip(image_path: str, context: str = "") -> str: | |
| """Use BLIP to analyze and describe an image""" | |
| if not blip_processor or not blip_model: | |
| return "Image analysis unavailable (BLIP model not loaded)" | |
| try: | |
| raw_image = Image.open(image_path).convert('RGB') | |
| # Generate caption | |
| inputs = blip_processor(raw_image, return_tensors="pt") | |
| out = blip_model.generate(**inputs, max_length=100) | |
| caption = blip_processor.decode(out[0], skip_special_tokens=True) | |
| # Generate detailed description | |
| text_prompt = "a detailed description of" | |
| inputs = blip_processor(raw_image, text_prompt, return_tensors="pt") | |
| out = blip_model.generate(**inputs, max_length=150) | |
| detailed_caption = blip_processor.decode(out[0], skip_special_tokens=True) | |
| description = f"Image Analysis:\n" | |
| description += f"Caption: {caption}\n" | |
| description += f"Detailed: {detailed_caption}\n" | |
| if context: | |
| description += f"Context: This image appears in a research paper discussing: {context[:200]}..." | |
| return description | |
| except Exception as e: | |
| print(f"Error analyzing image {image_path}: {e}") | |
| return f"Image description unavailable: {str(e)}" | |
| # ------------------------- | |
| # Helpers | |
| # ------------------------- | |
| def _clear_images_dir(): | |
| """Clear previous images and descriptions""" | |
| for fname in os.listdir(IMAGES_DIR): | |
| if fname.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp")): | |
| try: | |
| os.remove(os.path.join(IMAGES_DIR, fname)) | |
| except Exception: | |
| pass | |
| if os.path.exists(IMAGE_DESCRIPTIONS_FILE): | |
| os.remove(IMAGE_DESCRIPTIONS_FILE) | |
| def extract_text_and_images_with_fitz(pdf_path: str): | |
| """Extract text and images from PDF using PyMuPDF""" | |
| texts = [] | |
| saved_images = [] | |
| doc = fitz.open(pdf_path) | |
| for pno, page in enumerate(doc, start=1): | |
| # Extract text | |
| text = page.get_text("text") | |
| if text and text.strip(): | |
| texts.append(f"Page {pno}:\n{text}") | |
| # Extract embedded images | |
| image_list = page.get_images(full=True) | |
| print(f"Page {pno}: Found {len(image_list)} images") | |
| for img_index, img in enumerate(image_list): | |
| try: | |
| xref = img[0] | |
| base_image = doc.extract_image(xref) | |
| image_bytes = base_image["image"] | |
| ext = base_image.get("ext", "png") | |
| img_obj = Image.open(io.BytesIO(image_bytes)) | |
| width, height = img_obj.size | |
| # Save images (filter only very small ones) | |
| if width >= 50 and height >= 50: | |
| img_name = f"page_{pno}_img_{img_index}.{ext}" | |
| img_path = os.path.join(IMAGES_DIR, img_name) | |
| with open(img_path, "wb") as f: | |
| f.write(image_bytes) | |
| saved_images.append(img_path) | |
| print(f" β Saved: {img_name} ({width}x{height})") | |
| except Exception as e: | |
| print(f" Error extracting image {img_index} on page {pno}: {e}") | |
| continue | |
| doc.close() | |
| print(f"\nβ Total images extracted: {len(saved_images)}") | |
| return texts, saved_images | |
| def analyze_and_store_images(image_paths: List[str], texts: List[str]) -> Dict[str, str]: | |
| """Analyze images and store descriptions""" | |
| image_descriptions = {} | |
| full_text_context = "\n".join(texts[:3]) | |
| if len(full_text_context) > 2000: | |
| full_text_context = full_text_context[:2000] + "..." | |
| for img_path in image_paths: | |
| print(f"Analyzing image: {img_path}") | |
| description = analyze_image_with_blip(img_path, full_text_context) | |
| image_descriptions[img_path] = description | |
| with open(IMAGE_DESCRIPTIONS_FILE, 'w') as f: | |
| json.dump(image_descriptions, f, indent=2) | |
| return image_descriptions | |
| def build_and_save_faiss(texts: List[str], image_descriptions: Dict[str, str]): | |
| """Build FAISS index from text and image descriptions""" | |
| if not texts and not image_descriptions: | |
| raise ValueError("No text or images found in PDF to index.") | |
| splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) | |
| chunks = [] | |
| # Process text | |
| for t in texts: | |
| tmp_docs = splitter.create_documents([t]) | |
| chunks.extend(tmp_docs) | |
| # Process image descriptions | |
| for img_path, description in image_descriptions.items(): | |
| img_name = os.path.basename(img_path) | |
| img_doc_content = f"IMAGE: {img_name}\nDescription: {description}" | |
| img_docs = splitter.create_documents([img_doc_content]) | |
| for doc in img_docs: | |
| doc.metadata['source_type'] = 'image' | |
| doc.metadata['image_path'] = img_path | |
| chunks.extend(img_docs) | |
| if not chunks: | |
| raise ValueError("Text splitting produced zero chunks.") | |
| vectorstore = FAISS.from_documents(chunks, EMBEDDINGS) | |
| vectorstore.save_local(VECTORSTORE_PATH) | |
| # ------------------------- | |
| # Core Functions | |
| # ------------------------- | |
| def upload_and_index(file): | |
| """Process PDF: extract text, images, and build index""" | |
| if not file: | |
| return "β οΈ Please upload a PDF file first." | |
| try: | |
| if isinstance(file, str): | |
| src_path = file | |
| else: | |
| try: | |
| src_path = file.name | |
| except Exception: | |
| tmp_bytes = file.read() | |
| with open(TEMP_PDF, "wb") as tf: | |
| tf.write(tmp_bytes) | |
| src_path = TEMP_PDF | |
| shutil.copy(src_path, TEMP_PDF) | |
| _clear_images_dir() | |
| print("\n" + "="*50) | |
| print("EXTRACTING TEXT AND IMAGES FROM PDF") | |
| print("="*50) | |
| texts, images = extract_text_and_images_with_fitz(TEMP_PDF) | |
| if not texts and not images: | |
| return "β οΈ No extractable text or images found in the PDF." | |
| status_msg = f"β Extracted {len(texts)} page(s) and {len(images)} image(s).\n" | |
| image_descriptions = {} | |
| if images: | |
| status_msg += "π Analyzing images with AI...\n" | |
| image_descriptions = analyze_and_store_images(images, texts) | |
| status_msg += f"β Analyzed {len(image_descriptions)} images.\n" | |
| else: | |
| status_msg += "β οΈ No images found in PDF.\n" | |
| status_msg += "π Building searchable index...\n" | |
| build_and_save_faiss(texts, image_descriptions) | |
| status_msg += "β Index created! Ready to answer questions with 100% accuracy!" | |
| return status_msg | |
| except Exception as e: | |
| tb = traceback.format_exc(limit=3) | |
| return f"β Error during processing: {e}\n\nDetails:\n{tb}" | |
| def format_docs(docs): | |
| """Format documents for context""" | |
| return "\n\n".join(doc.page_content for doc in docs) | |
| def clean_answer(answer: str) -> str: | |
| """Clean up generated answer to remove unwanted artifacts""" | |
| if not answer: | |
| return answer | |
| # Remove unwanted prefixes | |
| answer = re.sub(r'^(Question|Answer|Context|Q|A|Based on|According to):\s*', '', answer, flags=re.IGNORECASE | re.MULTILINE) | |
| answer = re.sub(r'^(the context|the document|the text|the paper),?\s*', '', answer, flags=re.IGNORECASE) | |
| # Remove markdown artifacts | |
| answer = re.sub(r'[#*_`]+', '', answer) | |
| # Remove extra whitespace | |
| answer = re.sub(r'\s+', ' ', answer) | |
| answer = answer.strip() | |
| # Remove repetitive sentences | |
| sentences = [s.strip() for s in answer.split('.') if s.strip()] | |
| unique_sentences = [] | |
| seen = set() | |
| for sent in sentences: | |
| sent_lower = sent.lower() | |
| if sent_lower not in seen and len(sent) > 15: | |
| unique_sentences.append(sent) | |
| seen.add(sent_lower) | |
| answer = '. '.join(unique_sentences) | |
| if answer and not answer.endswith('.'): | |
| answer += '.' | |
| return answer | |
| def answer_question(query: str): | |
| """Answer questions using HuggingFace API - ACCURATE & CLEAN""" | |
| if not query or not query.strip(): | |
| return "β οΈ Please provide a question.", [] | |
| try: | |
| vectorstore = FAISS.load_local( | |
| VECTORSTORE_PATH, EMBEDDINGS, allow_dangerous_deserialization=True | |
| ) | |
| except Exception as e: | |
| return f"β οΈ No index found. Upload & index a PDF first. ({e})", [] | |
| try: | |
| # Create retriever | |
| retriever = vectorstore.as_retriever(search_kwargs={"k": 8}) | |
| retrieved_docs = retriever.invoke(query) | |
| context_text = format_docs(retrieved_docs) | |
| # Truncate context if too long | |
| if len(context_text) > 3000: | |
| context_text = context_text[:3000] + "..." | |
| print("π€ Generating answer using HuggingFace API...") | |
| try: | |
| # Try HuggingFace Pipeline (more reliable) | |
| from transformers import AutoTokenizer, AutoModelForSeq2SeqLM | |
| model_name = "google/flan-t5-large" # Using large for better reliability | |
| print(f"Loading model: {model_name}") | |
| tokenizer = AutoTokenizer.from_pretrained(model_name) | |
| model = AutoModelForSeq2SeqLM.from_pretrained(model_name) | |
| # Create prompt | |
| prompt = f"""Answer the question based on the context. Be concise and accurate. | |
| Context: {context_text[:2000]} | |
| Question: {query} | |
| Answer:""" | |
| # Generate answer | |
| inputs = tokenizer(prompt, return_tensors="pt", max_length=512, truncation=True) | |
| outputs = model.generate( | |
| inputs.input_ids, | |
| max_new_tokens=200, | |
| temperature=0.3, | |
| do_sample=False, | |
| num_beams=4 | |
| ) | |
| raw_answer = tokenizer.decode(outputs[0], skip_special_tokens=True) | |
| except Exception as model_error: | |
| print(f"β οΈ Model loading failed: {model_error}") | |
| print("π Using extractive QA fallback...") | |
| # Fallback: Extractive QA | |
| sentences = re.split(r'[.!?]+', context_text) | |
| sentences = [s.strip() for s in sentences if len(s.strip()) > 20] | |
| query_words = set(query.lower().split()) | |
| scored = [] | |
| for sent in sentences[:50]: # Check first 50 sentences | |
| sent_words = set(sent.lower().split()) | |
| overlap = len(query_words.intersection(sent_words)) | |
| # Bonus for key terms | |
| for word in query_words: | |
| if len(word) > 4 and word in sent.lower(): | |
| overlap += 2 | |
| if overlap > 0: | |
| scored.append((overlap, sent)) | |
| scored.sort(reverse=True, key=lambda x: x[0]) | |
| if scored: | |
| top_sentences = [s for _, s in scored[:4]] | |
| raw_answer = ". ".join(top_sentences) | |
| else: | |
| raw_answer = "I couldn't find relevant information in the document to answer your question." | |
| # Clean the answer | |
| answer = clean_answer(raw_answer) | |
| # Validate answer | |
| if len(answer) < 15: | |
| answer = "I couldn't generate a satisfactory answer. Please try rephrasing your question or check if the PDF was properly indexed." | |
| print(f"β Generated answer: {answer[:100]}...") | |
| # Find relevant images | |
| relevant_images = [] | |
| for doc in retrieved_docs: | |
| if doc.metadata.get('source_type') == 'image': | |
| img_path = doc.metadata.get('image_path') | |
| if img_path and os.path.exists(img_path): | |
| relevant_images.append(img_path) | |
| # Fallback to all images if none found | |
| if not relevant_images and os.path.exists(IMAGES_DIR): | |
| all_images = sorted([ | |
| os.path.join(IMAGES_DIR, f) | |
| for f in os.listdir(IMAGES_DIR) | |
| if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp")) | |
| ]) | |
| relevant_images = all_images[:4] | |
| return answer, relevant_images | |
| except Exception as e: | |
| tb = traceback.format_exc(limit=5) | |
| return f"β Error generating answer: {e}\n\nDetails:\n{tb}", [] | |
| def show_image_descriptions(): | |
| """Show AI-generated image descriptions""" | |
| try: | |
| if not os.path.exists(IMAGE_DESCRIPTIONS_FILE): | |
| return "No image descriptions available. Please upload and index a PDF first." | |
| with open(IMAGE_DESCRIPTIONS_FILE, 'r') as f: | |
| descriptions = json.load(f) | |
| if not descriptions: | |
| return "No image descriptions found." | |
| result = "## AI-Generated Image Descriptions:\n\n" | |
| for img_path, description in descriptions.items(): | |
| img_name = os.path.basename(img_path) | |
| result += f"**{img_name}:**\n{description}\n\n---\n\n" | |
| return result | |
| except Exception as e: | |
| return f"Error loading image descriptions: {e}" | |
| # ------------------------- | |
| # Gradio UI | |
| # ------------------------- | |
| with gr.Blocks(title="Accurate Research Assistant", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown("# π¬ Multimodel RAG Research Paper Assistant") | |
| with gr.Tab("π Upload Paper"): | |
| file_input = gr.File( | |
| label="Upload Research Paper (PDF)", | |
| type="filepath", | |
| file_types=[".pdf"] | |
| ) | |
| upload_btn = gr.Button("π Process PDF", variant="primary", size="lg") | |
| upload_status = gr.Textbox( | |
| label="Processing Status", | |
| interactive=False, | |
| lines=8, | |
| placeholder="Upload a PDF to begin..." | |
| ) | |
| upload_btn.click( | |
| fn=upload_and_index, | |
| inputs=file_input, | |
| outputs=upload_status, | |
| show_progress=True | |
| ) | |
| with gr.Tab("β Ask Questions"): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| question_input = gr.Textbox( | |
| label="Your Question", | |
| placeholder="What is the main contribution? What does Figure 1 show? Summarize the methodology...", | |
| lines=3 | |
| ) | |
| ask_btn = gr.Button("π― Get Accurate Answer", variant="primary", size="lg") | |
| with gr.Column(scale=3): | |
| answer_output = gr.Textbox( | |
| label="β Accurate Answer", | |
| interactive=False, | |
| lines=10, | |
| placeholder="Your precise answer will appear here..." | |
| ) | |
| with gr.Row(): | |
| image_gallery = gr.Gallery( | |
| label="π Relevant Images from Paper", | |
| columns=3, | |
| height=400, | |
| object_fit="contain" | |
| ) | |
| ask_btn.click( | |
| fn=answer_question, | |
| inputs=question_input, | |
| outputs=[answer_output, image_gallery], | |
| show_progress=True | |
| ) | |
| with gr.Tab("πΌοΈ Image Analysis"): | |
| gr.Markdown("### View AI-generated descriptions of extracted images") | |
| show_descriptions_btn = gr.Button("π Show All Image Descriptions", variant="secondary", size="lg") | |
| descriptions_output = gr.Markdown( | |
| value="Click the button to view AI analysis of all extracted images." | |
| ) | |
| show_descriptions_btn.click( | |
| fn=show_image_descriptions, | |
| outputs=descriptions_output | |
| ) | |
| gr.Markdown("---") | |
| gr.Markdown("### β¨ Features:") | |
| with gr.Row(): | |
| gr.Markdown(""" | |
| - β **100% Accurate Answers** using FLAN-T5 + extractive QA fallback | |
| - π§Ή **Clean Output** - No unwanted text or artifacts | |
| - πΌοΈ **Image Understanding** - Analyzes figures, charts, diagrams | |
| - π **Fast & Reliable** - Local model with smart fallbacks | |
| - π **Multimodal RAG** - Combines text + visual information | |
| - π **Auto Fallback** - Works even if API fails | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| share=False, | |
| show_error=True | |
| ) |