CoolFace
Apppublic

Sharada25/dhammaai

sourceHugging Facemitupdated 10mo agoView on Hugging Face
1likes
lightrag_wrapper.py627 linesDownload Raw Back to root
1"""lightrag_wrapper.py2 3Provides a resilient RAG interface for the Vipassana agent.4Now includes:5- Cross-Encoder Reranking for improved precision and accuracy6- BM25 keyword-based search for hybrid retrieval (BM25 + FAISS)7"""8import os9import json10import pickle11import faiss12import numpy as np13import openai14import re15import unicodedata16from typing import List, Dict, Optional, Tuple, Any17 18# Conditional imports for RAG components19try:20    from sentence_transformers import SentenceTransformer, CrossEncoder21    from sklearn.preprocessing import normalize22except ImportError:23    SentenceTransformer = None24    CrossEncoder = None25    normalize = None26 27try:28    from openai import OpenAI29except ImportError:30    OpenAI = None31 32try:33    from huggingface_hub import InferenceClient34except ImportError:35    InferenceClient = None36 37try:38    from rank_bm25 import BM25Okapi39except ImportError:40    BM25Okapi = None41 42try:43    from multilingual_prompts import (44        detect_language, get_system_prompt, get_user_prompt,45        get_english_query_hints, add_translation_context,46        VIPASSANA_TERMS_MULTILINGUAL47    )48except ImportError:49    print("Warning: multilingual_prompts module not found. Fallback to basic prompts.")50    detect_language = None51    get_system_prompt = None52    get_user_prompt = None53    get_english_query_hints = None54    add_translation_context = None55    VIPASSANA_TERMS_MULTILINGUAL = None56 57# ============================================================================58# PLUG-AND-PLAY LLM CONFIGURATION59# ============================================================================60# Switch between OpenAI and HuggingFace easily:61# - Set LLM_PROVIDER to "openai" to use OpenAI GPT models (requires OPENAI_API_KEY)62#   * Cost: ~$0.002-0.01 per chat (very cheap)63#   * Quality: Excellent (GPT-3.5-turbo)64#   * Speed: Fast65# - Set LLM_PROVIDER to "huggingface" to use HF Inference API (requires HF_API_TOKEN)66#   * Cost: FREE67#   * Quality: Good (Qwen2.5-7B-Instruct)68#   * Speed: Moderate69#70# For HuggingFace Spaces with OpenAI: Set LLM_PROVIDER=openai + OPENAI_API_KEY71# For HuggingFace Spaces without OpenAI: Set LLM_PROVIDER=huggingface + HF_API_TOKEN72# For Local Development: Set to "openai" if you have OpenAI API key73# ============================================================================74 75LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai")  # "openai" or "huggingface" - default to openai for better quality76 77# OpenAI Configuration78OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-3.5-turbo")79 80# HuggingFace Configuration81# Recommended models for best multilingual support (Hindi, Marathi, English):82# - "Qwen/Qwen2.5-7B-Instruct" (BEST multilingual quality - 18T tokens trained)83# - "mistralai/Mistral-7B-Instruct-v0.2" (good English, weak Hindi/Marathi)84# - "meta-llama/Meta-Llama-3.1-8B-Instruct" (decent multilingual)85# - "google/gemma-2-9b-it" (good multilingual, larger model)86HF_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-7B-Instruct")87 88# --- Other Configuration ---89EMBED_MODEL = os.getenv("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")90# Using a powerful, free, public Cross-Encoder for better relevance scoring91RERANK_MODEL = os.getenv("RERANK_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2")92INDEX_PATH = os.getenv("VIPASSANA_INDEX_PATH", "data/vector_store/index.faiss")93META_PATH = os.getenv("VIPASSANA_META_PATH", "data/vector_store/meta.json")94BM25_PATH = os.getenv("VIPASSANA_BM25_PATH", "data/vector_store/bm25.pkl")95 96 97class VipassanaRAGAgent:98    """Unified RAG wrapper with Multi-Query Search, BM25, and Cross-Encoder Reranking."""99 100    def __init__(self, openai_api_key: Optional[str] = None, hf_api_token: Optional[str] = None):101        # --- CRITICAL FIX: Initialize status attributes ---102        self.is_ready = False103        self.index_loaded = False104        # --------------------------------------------------105 106        self.model = None # Sentence Transformer for embedding107        self.reranker = None # Cross Encoder for re-scoring108        self.bm25 = None # BM25 for keyword search109        self.index = None110        self.metadatas = []111 112        # LLM clients (only one will be used based on LLM_PROVIDER)113        self.openai_client = None114        self.hf_client = None115        self.llm_provider = LLM_PROVIDER116 117        # 1. Check for required packages118        if SentenceTransformer is None or CrossEncoder is None or faiss is None or np is None:119            print("ERROR: Missing required packages (sentence-transformers, faiss, numpy). Cannot initialize RAG components.")120            return121 122        # 2. Setup LLM Client based on LLM_PROVIDER123        print(f"[LLM] Initializing LLM provider: {self.llm_provider}")124 125        if self.llm_provider == "openai":126            # ========== OPENAI SETUP ==========127            if OpenAI is None:128                print("ERROR: openai package not installed. Run: pip install openai")129                return130 131            try:132                # Use provided key or environment key133                api_key = openai_api_key or os.getenv("OPENAI_API_KEY")134                if not api_key:135                    print("ERROR: OPENAI_API_KEY not provided. Cannot initialize OpenAI client.")136                    print("HINT: Set OPENAI_API_KEY in your .env file or HF Spaces secrets")137                    return138 139                # Initialize with longer timeout for HF Spaces network140                self.openai_client = OpenAI(141                    api_key=api_key,142                    timeout=60.0,  # 60 second timeout143                    max_retries=3  # Retry up to 3 times on connection errors144                )145                print(f"[LLM] OpenAI client initialized successfully (model: {OPENAI_MODEL})")146 147            except Exception as e:148                print(f"ERROR: Failed to initialize OpenAI client: {e}")149                return150 151        elif self.llm_provider == "huggingface":152            # ========== HUGGINGFACE SETUP ==========153            if InferenceClient is None:154                print("ERROR: huggingface_hub package not installed. Run: pip install huggingface_hub")155                return156 157            try:158                # Use provided token or environment token (optional for public models)159                token = hf_api_token or os.getenv("HF_API_TOKEN") or os.getenv("HF_TOKEN")160 161                # Initialize HF Inference Client162                self.hf_client = InferenceClient(163                    model=HF_MODEL,164                    token=token,165                    timeout=60.0166                )167                print(f"[LLM] HuggingFace Inference client initialized successfully")168                print(f"[LLM] Using model: {HF_MODEL}")169                if not token:170                    print("[LLM] No HF_API_TOKEN provided - using public inference (may have rate limits)")171 172            except Exception as e:173                print(f"ERROR: Failed to initialize HuggingFace client: {e}")174                return175        else:176            print(f"ERROR: Invalid LLM_PROVIDER: {self.llm_provider}. Must be 'openai' or 'huggingface'")177            return 178 179        # 3. Load Models and Index180        try:181            print(f"Initializing embedding model: {EMBED_MODEL}")182            self.model = SentenceTransformer(EMBED_MODEL)183            184            print(f"Initializing reranking model: {RERANK_MODEL}")185            # Reranker model is a CrossEncoder, which expects (query, document) pairs186            self.reranker = CrossEncoder(RERANK_MODEL)187 188            self._load_index()189 190        except Exception as e:191            print(f"Error loading RAG components (models or index): {e}")192            return193 194        # 4. Set final readiness status195        if self.index_loaded:196            self.is_ready = True197            print("VipassanaRAGAgent initialized successfully with Reranking enabled.")198 199 200    def _load_index(self):201        """Loads the FAISS index, BM25 index, and metadata files."""202        print(f"Attempting to load index from {INDEX_PATH}...")203        try:204            self.index = faiss.read_index(INDEX_PATH)205            with open(META_PATH, "r", encoding="utf-8") as f:206                self.metadatas = json.load(f)207            208            # Load BM25 index if available209            if os.path.exists(BM25_PATH) and BM25Okapi is not None:210                with open(BM25_PATH, "rb") as f:211                    self.bm25 = pickle.load(f)212                print(f"BM25 index loaded successfully from {BM25_PATH}")213            else:214                print(f"Warning: BM25 index not found at {BM25_PATH}. Falling back to FAISS only.")215                self.bm25 = None216            217            self.index_loaded = True218            print(f"Index loaded successfully with {len(self.metadatas)} chunks.")219        except Exception as e:220            print(f"Warning: Could not load index from {INDEX_PATH}. Error: {e}")221            self.index_loaded = False222            self.index = None223            self.metadatas = []224            self.bm25 = None225 226    def _expand_query(self, query: str, detected_language: str = 'english') -> List[str]:227        """228        Expand query into a list of query variations for better initial recall.229        ENHANCED: Now supports multilingual query expansion for Hindi/Marathi.230        """231        query_lower = query.lower()232        query_list = [query_lower]233        234        # Get English query hints for non-English queries (for better retrieval)235        if detected_language != 'english' and get_english_query_hints:236            english_hints = get_english_query_hints(query, detected_language)237            query_list.extend(english_hints)238        239        # Use multilingual terms map if available240        if VIPASSANA_TERMS_MULTILINGUAL:241            vipassana_terms_map = {k: v.get('en', []) for k, v in VIPASSANA_TERMS_MULTILINGUAL.items()}242        else:243            vipassana_terms_map = {244                'meditation': ['practice', 'technique', 'sadhana'],245                'vipassana': ['insight meditation', 'mindfulness', 'awareness'],246                'anapana': ['breathing', 'breath', 'respiration'],247                'goenka': ['s.n. goenka', 'teacher', 'acharya'],248                'dhamma': ['dharma', 'teaching', 'truth'],249                'suffering': ['dukkha', 'pain', 'misery', 'stress'],250                'anicca': ['impermanence', 'change'],251            }252        253        # 1. Keyword Substitution Queries254        for key, terms in vipassana_terms_map.items():255            if key in query_lower:256                for term in terms[:2]:  # Limit to 2 synonyms per term257                    query_list.append(query_lower.replace(key, term))258 259        # 2. Simple Rephrase Heuristic for English260        if "?" in query or query_lower.startswith(("what is", "how to", "why is")):261            simple_rephrase = re.sub(r"what is|how to|why is", "", query_lower, 1).strip().replace("?", "")262            if simple_rephrase:263                query_list.append(simple_rephrase)264        265        # 3. Hindi/Marathi question word removal for better matching266        hindi_question_words = ['क्या', 'कैसे', 'कहाँ', 'क्यों', 'कौन', 'कब']267        marathi_question_words = ['काय', 'कसे', 'कुठे', 'का', 'कोण', 'केव्हा']268        269        for word in hindi_question_words + marathi_question_words:270            if word in query:271                cleaned = query.replace(word, '').strip()272                if cleaned and len(cleaned) > 3:273                    query_list.append(cleaned)274        275        # Clean and de-duplicate276        unique_queries = []277        seen_queries = set()278        for q in query_list:279            cleaned_q = re.sub(r"\s+", " ", q).strip()280            if cleaned_q and cleaned_q.lower() not in seen_queries and len(cleaned_q) > 2:281                unique_queries.append(cleaned_q)282                seen_queries.add(cleaned_q.lower())283 284        # Ensure a reasonable limit285        return unique_queries[:8]286 287    def _rerank_retrieved_items(self, query: str, retrieved_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:288        """289        Uses a Cross-Encoder model to re-score the relevance of each chunk to the query.290        This greatly improves precision.291        """292        if not self.reranker:293            return retrieved_items # Fallback to original score if reranker isn't loaded294 295        if not retrieved_items:296            return []297 298        # Prepare input pairs: [[query, chunk_text], [query, chunk_text], ...]299        sentences_to_rank = [[query, item["chunk"]] for item in retrieved_items]300        301        # Calculate new relevance scores (logits)302        new_scores = self.reranker.predict(sentences_to_rank)303 304        # Update the score and sort305        for i, item in enumerate(retrieved_items):306            # Cross-Encoder scores are high for high relevance307            item["rerank_score"] = float(new_scores[i]) 308 309        # Sort by the new, more accurate rerank_score310        retrieved_items.sort(key=lambda x: x["rerank_score"], reverse=True)311        312        return retrieved_items313 314 315    def _bm25_search(self, query: str, top_k: int = 15) -> List[Dict[str, Any]]:316        """317        Performs BM25 keyword-based search.318        Returns a list of candidate chunks with BM25 scores.319        """320        if self.bm25 is None:321            return []322        323        # Tokenize query324        tokenized_query = query.lower().split()325        326        # Get BM25 scores for all documents327        bm25_scores = self.bm25.get_scores(tokenized_query)328        329        # Get top-k indices330        top_indices = np.argsort(bm25_scores)[::-1][:top_k]331        332        results = []333        for idx in top_indices:334            if idx < len(self.metadatas) and bm25_scores[idx] > 0:335                metadata = self.metadatas[idx]336                results.append({337                    "score": float(bm25_scores[idx]),338                    "chunk": metadata.get("chunk", ""),339                    "source": metadata.get("source"),340                    "metadata": metadata341                })342        343        return results344 345    def retrieve(self, query: str, top_k: int = 5, top_k_initial: int = 30, detected_language: str = 'english') -> List[Dict[str, Any]]:346        """347        Retrieves context using hybrid search (BM25 + FAISS) followed by cross-encoder reranking.348 349        top_k: The final number of chunks passed to the LLM.350        top_k_initial: The number of chunks retrieved from FAISS before reranking.351        detected_language: Language of the query for better expansion.352        """353        if not self.index_loaded:354            return []355        356        # 1. Multi-Query Generation with language awareness357        query_list = self._expand_query(query, detected_language)358        359        # 2. Search FAISS index with all queries (High Recall)360        all_retrieved_items: Dict[str, Dict[str, Any]] = {} 361        362        # Distribute the initial search load across all queries (e.g., 30 chunks total)363        search_k_per_query = max(5, top_k_initial // len(query_list)) if len(query_list) > 0 else top_k_initial364 365        for single_query in query_list:366            if not single_query.strip():367                continue368                369            query_embedding = self.model.encode(single_query, convert_to_numpy=True)370            query_embedding = normalize(query_embedding.reshape(1, -1))371 372            # Search FAISS index for a large set of candidates373            distances, indices = self.index.search(query_embedding.astype("float32"), search_k_per_query)374            375            for i, score in zip(indices[0], distances[0]):376                if i >= 0:377                    metadata = self.metadatas[i]378                    chunk_text = metadata.get("chunk", "")379                    380                    if chunk_text:381                        # De-duplicate chunks, keeping the best original FAISS score382                        if chunk_text not in all_retrieved_items or score > all_retrieved_items[chunk_text]["score"]:383                            all_retrieved_items[chunk_text] = {384                                "score": float(score), # Original FAISS score (used for initial selection)385                                "chunk": chunk_text,386                                "source": metadata.get("source"),387                                "metadata": metadata388                            }389 390        # 2.5 BM25 Keyword Search (Hybrid Retrieval)391        bm25_results = self._bm25_search(query, top_k=15)392        for item in bm25_results:393            chunk_text = item["chunk"]394            if chunk_text and chunk_text not in all_retrieved_items:395                all_retrieved_items[chunk_text] = item396 397        initial_candidates = list(all_retrieved_items.values())398        399        # 3. Reranking (High Precision)400        if not initial_candidates:401            return []402            403        # Rerank all candidates to get the true relevance score404        reranked_items = self._rerank_retrieved_items(query, initial_candidates)405 406        # 4. Final Truncation: Return the very best chunks after reranking407        return reranked_items[:top_k]408 409 410    def _get_llm_response(self, query: str, context: str, mode: str = "long", detected_language: str = None) -> str:411            """412            Unified method to get LLM response from either OpenAI or HuggingFace.413            Automatically routes to the correct provider based on LLM_PROVIDER setting.414            """415            if not self.openai_client and not self.hf_client:416                return "Internal error: LLM client is not initialized."417 418            # Detect language and get appropriate prompts419            if detect_language and get_system_prompt and get_user_prompt:420                if detected_language is None:421                    detected_language = detect_language(query)422                system_prompt = get_system_prompt(detected_language)423                424                # Add translation context for non-English queries425                enhanced_context = context426                if add_translation_context and detected_language != 'english':427                    enhanced_context = add_translation_context(context, detected_language)428                429                prompt = get_user_prompt(query, enhanced_context, detected_language)430                print(f"[LLM] Detected language: {detected_language}")431            else:432                # Fallback to basic English prompts if multilingual module not available433                detected_language = "english"434                system_prompt = (435                    "You are the Vipassana Guide AI, a compassionate meditation teacher. "436                    "Use ONLY the provided CONTEXT. Do not add external knowledge.\n\n"437                    "Format responses with ## headings, **bold**, and *italic* for Pali/Sanskrit terms.\n"438                    "Be direct and practical. Include [Source: filename] references."439                )440                prompt = f"""CONTEXT (Vipassana Knowledge Base):441{context}442 443USER'S QUESTION:444{query}445 446Generate the response based strictly on the provided CONTEXT."""447 448            # ========== ROUTE TO CORRECT LLM PROVIDER ==========449 450            if self.llm_provider == "openai":451                # ========== OPENAI IMPLEMENTATION ==========452                try:453                    response = self.openai_client.chat.completions.create(454                        model=OPENAI_MODEL,455                        messages=[456                            {"role": "system", "content": system_prompt},457                            {"role": "user", "content": prompt}458                        ],459                        temperature=0.05,  # Low temperature ensures the model stays faithful to the context460                        max_tokens=1200,461                        top_p=0.9,462                        timeout=60  # 60 second timeout for this specific request463                    )464                    return response.choices[0].message.content465 466                except openai.APIConnectionError as e:467                    # Network connectivity issues468                    error_msg = f"Network connection error to OpenAI API. This may be temporary. Please try again in a moment."469                    print(f"[OpenAI API] Connection Error: {e}")470                    return error_msg471                except openai.APITimeoutError as e:472                    # Request timed out473                    error_msg = f"Request to OpenAI timed out. The service may be slow. Please try again."474                    print(f"[OpenAI API] Timeout Error: {e}")475                    return error_msg476                except openai.AuthenticationError as e:477                    # API key issue478                    error_msg = f"OpenAI API authentication failed. Please check your API key configuration."479                    print(f"[OpenAI API] Authentication Error: {e}")480                    return error_msg481                except openai.RateLimitError as e:482                    # Rate limit exceeded483                    error_msg = f"OpenAI API rate limit exceeded. Please wait a moment and try again."484                    print(f"[OpenAI API] Rate Limit Error: {e}")485                    return error_msg486                except Exception as e:487                    # Generic error handling488                    error_msg = f"Error during OpenAI generation: {type(e).__name__} - {str(e)}"489                    print(f"[OpenAI API] Generic Error: {e}")490                    return error_msg491 492            elif self.llm_provider == "huggingface":493                # ========== HUGGINGFACE IMPLEMENTATION ==========494                try:495                    # Use chat completion API for instruction-tuned models like Mistral496                    # Format messages similar to OpenAI's chat format497                    messages = [498                        {"role": "system", "content": system_prompt},499                        {"role": "user", "content": prompt}500                    ]501 502                    # Call HuggingFace Chat Completion API503                    response = self.hf_client.chat_completion(504                        messages=messages,505                        max_tokens=1200,506                        temperature=0.05,  # Very low temperature for consistency (especially important for multilingual)507                        top_p=0.85,  # Reduced top_p for more focused output508                    )509 510                    # Extract the assistant's response511                    answer = response.choices[0].message.content512                    return answer.strip()513 514                except Exception as e:515                    # HuggingFace error handling516                    error_msg = f"Error during HuggingFace generation: {type(e).__name__} - {str(e)}"517                    print(f"[HF API] Error: {e}")518                    print(f"[HF API] Full error details: {e}")519                    return error_msg520 521            else:522                return f"Error: Unknown LLM provider: {self.llm_provider}"523 524    def answer(self, query: str, top_k: int = 6, mode: str = "long") -> Tuple[str, List[str]]:525        """Main method to retrieve context and generate an answer with optimized performance."""526        if not self.is_ready:527            return "RAG Agent failed to initialize. Please check the console for errors.", []528 529        # Detect language early for use throughout the pipeline530        detected_language = 'english'531        if detect_language:532            detected_language = detect_language(query)533            print(f"[RAG] Query language detected: {detected_language}")534 535        # 0. Check if this is a course-related query and return direct information536        try:537            from course_extractor import get_course_extractor538            extractor = get_course_extractor()539 540            query_lower = query.lower()541 542            # Detect course-related keywords543            if any(keyword in query_lower for keyword in ['course', 'december', 'november', 'january', 'schedule', 'date', 'centre', 'center', 'when', 'booking', 'register']):544                # Extract month if present545                months = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']546                month_found = None547                centre_found = None548 549                for month in months:550                    if month in query_lower:551                        month_found = month552                        break553 554                # Check for centre names555                centres = extractor.get_all_centres()556                for centre in centres:557                    if centre.lower() in query_lower:558                        centre_found = centre559                        break560 561                # Try to get course information with language support562                if centre_found and month_found:563                    course_info = extractor.get_course_info(centre=centre_found, month=month_found, language=detected_language)564                    if course_info:565                        return course_info, ["data/knowledge_base/dhamma_org_content.md"]566                elif centre_found:567                    course_info = extractor.get_course_info(centre=centre_found, language=detected_language)568                    if course_info:569                        return course_info, ["data/knowledge_base/dhamma_org_content.md"]570                elif month_found:571                    course_info = extractor.get_course_info(month=month_found, language=detected_language)572                    if course_info:573                        return course_info, ["data/knowledge_base/dhamma_org_content.md"]574        except Exception as e:575            print(f"Course extractor error (non-critical): {e}")576            pass  # Fall back to RAG if course extraction fails577 578        # 1. Optimized retrieval with reranking for better performance579        retrieved_items = self.retrieve(query, top_k=top_k, top_k_initial=25, detected_language=detected_language)580 581        if not retrieved_items:582            return "Could not retrieve relevant documents from the knowledge base.", []583 584        # 2. Format context with better organization585        context_parts = []586        sources = []587 588        for i, item in enumerate(retrieved_items):589            chunk = item.get("chunk", "")590            source = item.get("source", "")591 592            if chunk:593                # Add source information to each chunk594                context_parts.append(f"[Source: {source}]\n{chunk}")595 596                if source and source not in sources:597                    sources.append(source)598 599        context = "\n\n---\n\n".join(context_parts)600 601        # 3. Generate answer using enhanced context (works with both OpenAI and HuggingFace)602        answer = self._get_llm_response(query, context, mode, detected_language=detected_language)603 604        return answer, sources605 606    def _clean_text(self, text: str) -> str:607        """Basic cleaning for text extracted from PDFs."""608        if not isinstance(text, str):609            return ""610        611        # Standard cleaning logic remains (normalized, cleanup hyphens/spaces, etc.)612        cleaned = unicodedata.normalize('NFKC', text)613        cleaned = re.sub(r"(/c\d+)+", " ", cleaned)614        cleaned = re.sub(r"\[\s*\d+\s*\]", " ", cleaned)615        cleaned = re.sub(r"\(\s*\d+\s*\)", " ", cleaned)616        cleaned = re.sub(r"-\s*\n\s*", "", cleaned)617        cleaned = re.sub(r"\n+", " ", cleaned)618        cleaned = re.sub(r"[\x00-\x1F\x7F-\x9F]", " ", cleaned)619        cleaned = re.sub(r"\s+", " ", cleaned).strip()620        621        return cleaned622 623# NOTE: The initialization logic in app.py will automatically pick up the new 624# RAG agent, but you must ensure that all required dependencies 625# (sentence-transformers[for CrossEncoder], numpy, faiss, openai) are installed 626# in the environment where this code runs.627