import sys
import os
import html
import asyncio
import logging
import warnings
# ── Model cache setup ────────────────────────────────────────────────
# Try persistent /data/.cache first (if the volume is read-write).
# Fall back to /tmp/.cache (writable but ephemeral) if /data is read-only.
_cache_set = False
for _candidate in ["/data/.cache", "/tmp/.cache"]:
try:
os.makedirs(_candidate, exist_ok=True)
# Verify it's actually writable
_probe = os.path.join(_candidate, ".write_test")
with open(_probe, "w") as f:
f.write("ok")
os.remove(_probe)
os.environ.setdefault("HF_HOME", os.path.join(_candidate, "huggingface"))
os.environ.setdefault("HUGGINGFACE_HUB_CACHE", os.path.join(_candidate, "huggingface", "hub"))
os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", os.path.join(_candidate, "sentence_transformers"))
os.environ.setdefault("TORCH_HOME", os.path.join(_candidate, "torch"))
print(f"[cache] Model cache set to: {_candidate}")
_cache_set = True
break
except OSError:
continue
if not _cache_set:
print("[cache] WARNING: No writable cache directory found, using defaults")
# ── Suppress harmless asyncio event-loop cleanup errors ──────────────
# Libraries like huggingface_hub and sentence-transformers create transient
# asyncio event loops during model downloads. When Python's GC collects
# them later, BaseEventLoop.__del__ tries to close an already-closed
# selector, producing noisy "Invalid file descriptor: -1" tracebacks.
# The loops are already dead at that point so the error is cosmetic.
_original_loop_del = getattr(asyncio.BaseEventLoop, "__del__", None)
def _quiet_loop_del(self):
"""Patched __del__ that silences ValueError on stale file descriptors."""
try:
if _original_loop_del is not None:
_original_loop_del(self)
except (ValueError, OSError):
pass # fd already closed – nothing to clean up
asyncio.BaseEventLoop.__del__ = _quiet_loop_del
# Also silence the "Exception ignored in" log line that the interpreter
# emits independently of the traceback.
warnings.filterwarnings("ignore", message=".*Invalid file descriptor.*")
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
import gradio as gr
# Ensure the local directories inside hf/ are in the path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from nrsc_rag.utils.config import load_config
from nrsc_rag.engine.retriever import RagRetriever
# Initialize RAG configuration
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config", "settings.yaml")
config = load_config(config_path)
print("--- BOOTING NRSC RAG ENGINE ---")
retriever = None
rag_available = True
try:
retriever = RagRetriever(config)
print("Core RAG Engine initialized successfully!")
except Exception as e:
print(f"CRITICAL: Failed to initialize core RagRetriever: {e}")
rag_available = False
# Premium Dark-Mode Glassmorphism custom styling
css = """
body {
background-color: #090d16;
color: #f1f5f9;
font-family: 'Outfit', 'Inter', sans-serif;
}
.gradio-container {
background: radial-gradient(circle at 10% 20%, rgba(18, 24, 38, 0.95), rgba(9, 13, 22, 0.99) 80%);
border: none;
border-radius: 20px;
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.5);
padding: 30px;
}
.glass-card {
background: rgba(255, 255, 255, 0.02) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
border-radius: 16px !important;
padding: 18px !important;
margin-bottom: 12px;
}
.chatbot {
background: rgba(255, 255, 255, 0.01) !important;
backdrop-filter: blur(8px) !important;
border: 1px solid rgba(255, 255, 255, 0.04) !important;
border-radius: 12px !important;
min-height: 480px !important;
}
.source-card {
background: rgba(99, 102, 241, 0.05);
border-left: 4px solid #6366f1;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
font-size: 0.9rem;
transition: transform 0.2s ease;
}
.source-card:hover {
transform: translateY(-2px);
background: rgba(99, 102, 241, 0.08);
}
.badge {
background: #6366f1;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
display: inline-block;
margin-bottom: 6px;
}
.source-title {
font-weight: bold;
color: #818cf8;
margin-bottom: 4px;
}
.source-snippet {
color: #cbd5e1;
line-height: 1.4;
}
input, textarea {
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: white !important;
border-radius: 8px !important;
}
button.primary {
background: linear-gradient(135deg, #6366f1, #3b82f6) !important;
border: none !important;
color: white !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
}
button.primary:hover {
transform: scale(1.02);
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
}
"""
theme = gr.themes.Soft(
primary_hue="indigo",
secondary_hue="blue",
neutral_hue="slate",
font=[gr.themes.GoogleFont("Outfit"), "sans-serif"],
)
def extract_text_content(content):
"""Securely extracts string text from Gradio 5/6 multimodal dictionary/list structures."""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and "text" in item:
text_parts.append(str(item["text"]))
elif isinstance(item, str):
text_parts.append(item)
return "".join(text_parts)
if isinstance(content, dict):
if "text" in content:
return str(content["text"])
return str(content)
def chat_interface(user_message, history):
"""Gradient stream handler for conversational interface using Gradio dictionary format."""
if not user_message.strip():
return "", history
if history is None:
history = []
# Append user question and empty placeholder for assistant
history = history + [
{"role": "user", "content": user_message},
{"role": "assistant", "content": ""}
]
return "", history
def bot_response(history):
"""Executes RAG search via core RagRetriever and streams LLM generation in GPT style."""
if not history or len(history) < 2:
yield history
return
# Extract user question (the second to last message in the messages dictionary history list)
question = extract_text_content(history[-2]["content"])
print(f"RAG Request received: '{question}'")
if not rag_available or retriever is None:
history[-1]["content"] = "⚠️ Core RAG Engine is not available. Check startup logs."
yield history
return
# 1. Retrieve top context documents directly using the core retriever's optimized hybrid pipeline
try:
retrieved_chunks = retriever._retrieve_top_chunks(question)
except Exception as e:
print(f"Retrieval error: {e}")
retrieved_chunks = []
# Pre-clean history contents to strictly extract string text
clean_history = []
for msg in history[:-1]:
clean_history.append({
"role": str(msg.get("role", "")),
"content": extract_text_content(msg.get("content", ""))
})
# 2. Build context and messages list for native chat completions
context_parts = []
for idx, row in enumerate(retrieved_chunks):
chunk_text = row.get("chunk_text", "")
filename = row.get("filename", "unknown")
context_parts.append(f"{chunk_text}\n")
print(f" [Chunk {idx+1}] from '{filename}': {chunk_text[:120]}...")
context_str = "\n".join(context_parts)
print(f" Total context length: {len(context_str)} chars from {len(retrieved_chunks)} chunks")
system_prompt = f"Use the following pieces of information and conversation history to answer the user's question.\nIf you don't know the answer, just say that you don't know, don't try to make up an answer.\nDo not mention any source file names or document names in your answer. Provide a direct and natural response.\n\nContext: {context_str}"
messages = [{"role": "system", "content": system_prompt}]
for msg in clean_history:
messages.append({
"role": str(msg.get("role", "")),
"content": str(msg.get("content", ""))
})
model_name = config["rag"].get("llm_model", "").lower()
is_thinking_model = "nemotron" in model_name or "deepseek" in model_name or "r1" in model_name
def format_display_response(raw_text):
if "" in raw_text:
parts = raw_text.split("", 1)
return parts[1].strip()
elif "
Premium ChatGPT-style Conversational AI assistant for National Remote Sensing Centre documentation.