Spaces:
Running
Running
Commit ·
2a2c039
0
Parent(s):
Deploy Bio-RAG
Browse files- .env.example +7 -0
- .gitignore +29 -0
- .vscode/settings.json +3 -0
- Dockerfile +17 -0
- MOVE_PROJECT_INSTRUCTIONS.md +68 -0
- README.md +12 -0
- app.py +170 -0
- assets/logo.png +0 -0
- assets/style.css +220 -0
- config.py +24 -0
- main.py +35 -0
- prompts.py +62 -0
- requirements.txt +14 -0
- src/bio_rag/__init__.py +10 -0
- src/bio_rag/claim_decomposer.py +75 -0
- src/bio_rag/config.py +44 -0
- src/bio_rag/data_loader.py +168 -0
- src/bio_rag/generator.py +62 -0
- src/bio_rag/knowledge_base.py +63 -0
- src/bio_rag/nli_evaluator.py +105 -0
- src/bio_rag/pipeline.py +170 -0
- src/bio_rag/query_processor.py +108 -0
- src/bio_rag/retriever.py +83 -0
- src/bio_rag/risk_scorer.py +98 -0
- static/css/style.css +1345 -0
- static/index.html +144 -0
- static/js/app.js +838 -0
- utils/helpers.py +21 -0
- web_app.py +142 -0
.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Optional overrides
|
| 2 |
+
BIO_RAG_EMBEDDING_MODEL=dmis-lab/biobert-v1.1
|
| 3 |
+
BIO_RAG_GENERATOR_MODEL=BioMistral/BioMistral-7B
|
| 4 |
+
BIO_RAG_INDEX_PATH=.cache/bio_rag_faiss
|
| 5 |
+
BIO_RAG_MAX_SAMPLES=2000
|
| 6 |
+
BIO_RAG_TOP_K=5
|
| 7 |
+
BIO_RAG_CLAIM_SIM_THRESHOLD=0.62
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.cache/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.faiss
|
| 6 |
+
*.pkl
|
| 7 |
+
venv/
|
| 8 |
+
.venv/
|
| 9 |
+
nul
|
| 10 |
+
test_*.py
|
| 11 |
+
final_test*.py
|
| 12 |
+
debug_*.txt
|
| 13 |
+
test_out*.txt
|
| 14 |
+
*.log
|
| 15 |
+
.kiro/
|
| 16 |
+
.streamlit/
|
| 17 |
+
vector_db/
|
| 18 |
+
data/raw_pdfs/
|
| 19 |
+
add_metformin_renal_docs.py
|
| 20 |
+
build_and_test.py
|
| 21 |
+
check_long_answer.py
|
| 22 |
+
fast_find_no.py
|
| 23 |
+
find_no_questions.py
|
| 24 |
+
fix_pipeline.py
|
| 25 |
+
hallucination_test.py
|
| 26 |
+
rebuild_and_test_index.py
|
| 27 |
+
rebuild_index.py
|
| 28 |
+
verify.py
|
| 29 |
+
data/
|
.vscode/settings.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"kiroAgent.configureMCP": "Disabled"
|
| 3 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
RUN useradd -m -u 1000 user
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends build-essential git && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
|
| 6 |
+
USER user
|
| 7 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 8 |
+
ENV HF_HOME="/home/user/.cache/huggingface"
|
| 9 |
+
ENV HF_HUB_DISABLE_SYMLINKS_WARNING=1
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
+
COPY --chown=user . /app
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
CMD ["python", "web_app.py"]
|
MOVE_PROJECT_INSTRUCTIONS.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# تعليمات نقل المشروع - CRITICAL FIX
|
| 2 |
+
|
| 3 |
+
## المشكلة الجذرية:
|
| 4 |
+
المسار الحالي يحتوي على أحرف عربية:
|
| 5 |
+
```
|
| 6 |
+
D:\s2\mata kulih\s2\حقي\BAHASA ALAMI\pak abadi\code\BioRAG_Project
|
| 7 |
+
```
|
| 8 |
+
|
| 9 |
+
هذا يسبب مشاكل مع:
|
| 10 |
+
- FAISS (لا يدعم Unicode paths)
|
| 11 |
+
- ChromaDB (مشاكل في HNSW index)
|
| 12 |
+
- العديد من المكتبات الأخرى
|
| 13 |
+
|
| 14 |
+
## الحل النهائي:
|
| 15 |
+
|
| 16 |
+
### الخطوة 1: انسخ المشروع لمسار إنجليزي
|
| 17 |
+
```cmd
|
| 18 |
+
xcopy "D:\s2\mata kulih\s2\حقي\BAHASA ALAMI\pak abadi\code\BioRAG_Project" "C:\Projects\BioRAG" /E /I /H
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
أو يدوياً:
|
| 22 |
+
1. افتح File Explorer
|
| 23 |
+
2. انسخ مجلد BioRAG_Project
|
| 24 |
+
3. الصقه في مسار إنجليزي مثل: `C:\Projects\BioRAG`
|
| 25 |
+
|
| 26 |
+
### الخطوة 2: افتح المشروع الجديد
|
| 27 |
+
```cmd
|
| 28 |
+
cd C:\Projects\BioRAG
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
### الخطوة 3: أعد تفعيل البيئة الافتراضية
|
| 32 |
+
```cmd
|
| 33 |
+
python -m venv venv
|
| 34 |
+
venv\Scripts\activate
|
| 35 |
+
pip install -r requirements.txt
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### الخطوة 4: شغّل سكريبت البناء
|
| 39 |
+
```cmd
|
| 40 |
+
python fix_with_faiss.py
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### الخطوة 5: شغّل التطبيق
|
| 44 |
+
```cmd
|
| 45 |
+
python -m streamlit run app.py
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## ملاحظة مهمة:
|
| 49 |
+
بعد النقل، ستحتاج لإعادة تحميل بيانات PubMed:
|
| 50 |
+
```cmd
|
| 51 |
+
python download_data.py
|
| 52 |
+
python fix_with_faiss.py
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## البديل السريع (إذا لم تستطع نقل المشروع):
|
| 58 |
+
|
| 59 |
+
سأقوم بتعديل الكود ليحفظ قاعدة البيانات في مسار مؤقت بدون أحرف عربية:
|
| 60 |
+
|
| 61 |
+
```python
|
| 62 |
+
import tempfile
|
| 63 |
+
FAISS_INDEX_PATH = os.path.join(tempfile.gettempdir(), "biorag_faiss")
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
هل تريد:
|
| 67 |
+
1. نقل المشروع لمسار إنجليزي (الحل الأفضل) ✅
|
| 68 |
+
2. استخدام المسار المؤقت (حل سريع) ⚡
|
README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: BioRAG
|
| 3 |
+
emoji: 🧬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Bio-RAG: Medical Hallucination Detector
|
| 11 |
+
|
| 12 |
+
Automated fact-verification for diabetes-related medical QA using RAG + NLI.
|
app.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
from src.bio_rag.pipeline import BioRAGPipeline
|
| 4 |
+
|
| 5 |
+
# --- Page Configuration ---
|
| 6 |
+
st.set_page_config(page_title="BioRAG Medical Assistant", page_icon="🏥", layout="wide")
|
| 7 |
+
|
| 8 |
+
# --- Load Custom CSS ---
|
| 9 |
+
css_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "style.css")
|
| 10 |
+
if os.path.exists(css_path):
|
| 11 |
+
with open(css_path) as f:
|
| 12 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 13 |
+
|
| 14 |
+
# --- Cached Pipeline Initialization ---
|
| 15 |
+
@st.cache_resource(show_spinner=False)
|
| 16 |
+
def load_pipeline():
|
| 17 |
+
"""Load the full RAG pipeline (this will also load vector stores and models)"""
|
| 18 |
+
return BioRAGPipeline()
|
| 19 |
+
|
| 20 |
+
# Initialize the pipeline silently behind the scenes
|
| 21 |
+
pipeline = load_pipeline()
|
| 22 |
+
|
| 23 |
+
# --- Sidebar ---
|
| 24 |
+
with st.sidebar:
|
| 25 |
+
st.markdown("""
|
| 26 |
+
<div style="text-align:center; padding: 1rem 0 0.5rem;">
|
| 27 |
+
<div style="font-size: 2.5rem;">🏥</div>
|
| 28 |
+
<div style="font-size: 1.3rem; font-weight: 700; color: #1e293b; margin-top: 0.3rem;">BioRAG</div>
|
| 29 |
+
<div style="font-size: 0.8rem; color: #64748b;">Medical Hallucination Detector</div>
|
| 30 |
+
</div>
|
| 31 |
+
""", unsafe_allow_html=True)
|
| 32 |
+
|
| 33 |
+
st.markdown("---")
|
| 34 |
+
|
| 35 |
+
st.markdown("""
|
| 36 |
+
<div style="padding: 0.6rem 0;">
|
| 37 |
+
<div style="font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.5rem;">Two-Phase Pipeline</div>
|
| 38 |
+
<div style="color: #334155; font-size: 0.85rem; line-height: 2;">
|
| 39 |
+
<span style="color: #2563eb;">①</span> <b>Phase 1:</b> Retrieval & Generation<br>
|
| 40 |
+
<span style="color: #0d9488;">②</span> <b>Phase 2:</b> Decompose into Claims<br>
|
| 41 |
+
<span style="color: #d97706;">③</span> <b>Phase 2:</b> NLI Verification<br>
|
| 42 |
+
<span style="color: #dc2626;">④</span> <b>Phase 2:</b> Clinical Risk Scoring
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
""", unsafe_allow_html=True)
|
| 46 |
+
|
| 47 |
+
st.markdown("---")
|
| 48 |
+
|
| 49 |
+
st.markdown("""
|
| 50 |
+
<div style="padding: 0.4rem 0;">
|
| 51 |
+
<div style="font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.5rem;">Tech Stack</div>
|
| 52 |
+
<div style="color: #475569; font-size: 0.78rem; line-height: 1.9;">
|
| 53 |
+
☁️ <span style="color: #7c3aed;">llama-3.1-8b-instant (Groq)</span><br>
|
| 54 |
+
🛡️ <span style="color: #059669;">nli-deberta-v3-base</span><br>
|
| 55 |
+
🔢 <span style="color: #2563eb;">FAISS Hybrid Retrieval</span>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
""", unsafe_allow_html=True)
|
| 59 |
+
|
| 60 |
+
st.markdown("---")
|
| 61 |
+
if st.button("🗑️ Clear Chat History"):
|
| 62 |
+
st.session_state.messages = []
|
| 63 |
+
st.rerun()
|
| 64 |
+
|
| 65 |
+
# --- Main App Header ---
|
| 66 |
+
st.markdown("""
|
| 67 |
+
<div style="padding: 0.5rem 0 0.3rem;">
|
| 68 |
+
<h1 style="color: #1e293b; font-size: 1.6rem; margin-bottom: 0.2rem;">🏥 Bio-RAG: Clinical Fact-Checking</h1>
|
| 69 |
+
<p style="color: #64748b; font-size: 0.88rem; margin: 0;">Generates an answer and scores its risk of hallucination using NLI and Clinical Severity heuristics.</p>
|
| 70 |
+
</div>
|
| 71 |
+
""", unsafe_allow_html=True)
|
| 72 |
+
st.markdown("---")
|
| 73 |
+
|
| 74 |
+
# --- Chat State Management ---
|
| 75 |
+
if "messages" not in st.session_state:
|
| 76 |
+
st.session_state.messages = []
|
| 77 |
+
|
| 78 |
+
# --- Render Chat History ---
|
| 79 |
+
for msg in st.session_state.messages:
|
| 80 |
+
if msg["role"] == "user":
|
| 81 |
+
with st.chat_message("user"):
|
| 82 |
+
st.markdown(msg["content"])
|
| 83 |
+
elif msg["role"] == "assistant":
|
| 84 |
+
with st.chat_message("assistant"):
|
| 85 |
+
st.markdown(msg["content"])
|
| 86 |
+
|
| 87 |
+
# Display Risk Badge if it's an assistant message and successfully scored
|
| 88 |
+
if "result_data" in msg:
|
| 89 |
+
res = msg["result_data"]
|
| 90 |
+
|
| 91 |
+
if res.get("rejection_message"):
|
| 92 |
+
pass # Handled in the markdown output already implicitly, but can add badge:
|
| 93 |
+
else:
|
| 94 |
+
max_risk = res.get("max_risk_score", 0.0)
|
| 95 |
+
is_safe = res.get("safe", False)
|
| 96 |
+
|
| 97 |
+
if is_safe:
|
| 98 |
+
st.markdown(f"✅ **Safe (Low Risk)**: Maximum Clinical Risk Score is **{max_risk:.4f}**")
|
| 99 |
+
else:
|
| 100 |
+
st.markdown(f"⚠️ **FLAGGED (High Risk)**: Maximum Clinical Risk Score is **{max_risk:.4f}**. Answer has been redacted.")
|
| 101 |
+
|
| 102 |
+
# Add an expander for the detailed claim breakdown
|
| 103 |
+
with st.expander("🔍 View Verification Details"):
|
| 104 |
+
st.markdown("### Atomic Claims & Risk Scores")
|
| 105 |
+
for claim_check in res.get("claim_checks", []):
|
| 106 |
+
risk_val = claim_check.get("risk_score", 0.0)
|
| 107 |
+
|
| 108 |
+
st.markdown(f"""
|
| 109 |
+
**Claim:** {claim_check.get('claim')}
|
| 110 |
+
- **NLI Contradiction Prob:** {claim_check.get('nli_prob')}
|
| 111 |
+
- **Risk Score: {risk_val:.4f}**
|
| 112 |
+
---
|
| 113 |
+
""")
|
| 114 |
+
|
| 115 |
+
if res.get("evidence"):
|
| 116 |
+
st.markdown("### Retrieved Context (Top Passages)")
|
| 117 |
+
for idx, ev in enumerate(res.get("evidence", [])[:3]):
|
| 118 |
+
text = ev.get('text', str(ev)) if isinstance(ev, dict) else (ev.text if hasattr(ev, 'text') else str(ev))
|
| 119 |
+
st.info(f"**Document {idx+1}:** {text}")
|
| 120 |
+
|
| 121 |
+
# --- Handle User Input ---
|
| 122 |
+
if prompt := st.chat_input("Ask a medical question about diabetes (e.g., 'Is high insulin dose safe for mild sugar elevation?')..."):
|
| 123 |
+
st.session_state.messages.append({"role": "user", "content": prompt})
|
| 124 |
+
with st.chat_message("user"):
|
| 125 |
+
st.markdown(prompt)
|
| 126 |
+
|
| 127 |
+
with st.chat_message("assistant"):
|
| 128 |
+
with st.spinner("🤖 Phase 1: Retrieving context & Generating answer via Groq..."):
|
| 129 |
+
# The spinner text updates are implicit, we just run the pipeline.
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
with st.spinner("🛡️ Phase 2: Evaluating Claims & Calculating Clinical Risk (DeBERTa NLI)..."):
|
| 133 |
+
# Call the Pipeline
|
| 134 |
+
result = pipeline.ask(prompt)
|
| 135 |
+
answer_text = result.final_answer
|
| 136 |
+
|
| 137 |
+
st.markdown(answer_text)
|
| 138 |
+
|
| 139 |
+
if not result.rejection_message:
|
| 140 |
+
if result.safe:
|
| 141 |
+
st.success(f"✅ **Safe (Low Risk)**: Maximum Clinical Risk Score is **{result.max_risk_score:.4f}**")
|
| 142 |
+
else:
|
| 143 |
+
st.error(f"⚠️ **FLAGGED (High Risk)**: Maximum Clinical Risk Score is **{result.max_risk_score:.4f}**. Answer has been redacted.")
|
| 144 |
+
|
| 145 |
+
with st.expander("🔍 View Verification Details"):
|
| 146 |
+
st.markdown("### Atomic Claims & Risk Scores")
|
| 147 |
+
for claim_check in result.claim_checks:
|
| 148 |
+
risk_val = claim_check.get('risk_score', 0.0)
|
| 149 |
+
|
| 150 |
+
st.markdown(f"""
|
| 151 |
+
**Claim:** {claim_check.get('claim')}
|
| 152 |
+
- **NLI Contradiction Prob:** {claim_check.get('nli_prob')}
|
| 153 |
+
- **Risk Score: {risk_val:.4f}**
|
| 154 |
+
---
|
| 155 |
+
""")
|
| 156 |
+
|
| 157 |
+
if result.evidence:
|
| 158 |
+
st.markdown("### Retrieved Context (Top Passages)")
|
| 159 |
+
for idx, ev in enumerate(result.evidence[:3]):
|
| 160 |
+
text = ev.get('text', str(ev)) if isinstance(ev, dict) else (ev.text if hasattr(ev, 'text') else str(ev))
|
| 161 |
+
st.info(f"**Document {idx+1}:** {text}")
|
| 162 |
+
|
| 163 |
+
# Save assistant message to state with result data
|
| 164 |
+
# We need to make sure result.evidence is properly serialized or ignored to avoid st.session_state issues.
|
| 165 |
+
# result.to_dict() is safe as long as it handles RetrievedPassage correctly.
|
| 166 |
+
st.session_state.messages.append({
|
| 167 |
+
"role": "assistant",
|
| 168 |
+
"content": answer_text,
|
| 169 |
+
"result_data": result.to_dict()
|
| 170 |
+
})
|
assets/logo.png
ADDED
|
assets/style.css
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================
|
| 2 |
+
BioRAG Medical Assistant - Light Medical Theme
|
| 3 |
+
============================================ */
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg: #f0f5ff;
|
| 7 |
+
--bg-white: #ffffff;
|
| 8 |
+
--bg-sidebar: #f8faff;
|
| 9 |
+
--accent: #2563eb;
|
| 10 |
+
--accent-light: #dbeafe;
|
| 11 |
+
--accent-hover: #1d4ed8;
|
| 12 |
+
--teal: #0d9488;
|
| 13 |
+
--green: #059669;
|
| 14 |
+
--green-light: #d1fae5;
|
| 15 |
+
--amber: #d97706;
|
| 16 |
+
--amber-light: #fef3c7;
|
| 17 |
+
--red: #dc2626;
|
| 18 |
+
--red-light: #fee2e2;
|
| 19 |
+
--text: #1e293b;
|
| 20 |
+
--text-secondary: #475569;
|
| 21 |
+
--text-muted: #94a3b8;
|
| 22 |
+
--border: #e2e8f0;
|
| 23 |
+
--border-light: #f1f5f9;
|
| 24 |
+
--shadow: 0 1px 4px rgba(0,0,0,0.06);
|
| 25 |
+
--shadow-hover: 0 4px 12px rgba(37,99,235,0.1);
|
| 26 |
+
--radius: 12px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* === Animations === */
|
| 30 |
+
@keyframes fadeInUp {
|
| 31 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 32 |
+
to { opacity: 1; transform: translateY(0); }
|
| 33 |
+
}
|
| 34 |
+
@keyframes fadeIn {
|
| 35 |
+
from { opacity: 0; }
|
| 36 |
+
to { opacity: 1; }
|
| 37 |
+
}
|
| 38 |
+
@keyframes slideIn {
|
| 39 |
+
from { opacity: 0; transform: translateX(-8px); }
|
| 40 |
+
to { opacity: 1; transform: translateX(0); }
|
| 41 |
+
}
|
| 42 |
+
@keyframes pulse {
|
| 43 |
+
0%, 100% { opacity: 1; }
|
| 44 |
+
50% { opacity: 0.7; }
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* === Global === */
|
| 48 |
+
.stApp {
|
| 49 |
+
background: var(--bg) !important;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* === Scrollbar === */
|
| 53 |
+
::-webkit-scrollbar { width: 5px; }
|
| 54 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 55 |
+
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
| 56 |
+
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
|
| 57 |
+
|
| 58 |
+
/* === Text === */
|
| 59 |
+
.stApp h1, .stApp h2, .stApp h3 {
|
| 60 |
+
color: var(--text) !important;
|
| 61 |
+
font-weight: 700 !important;
|
| 62 |
+
}
|
| 63 |
+
.stApp p, .stApp span, .stApp label, .stApp li, .stApp div {
|
| 64 |
+
color: var(--text);
|
| 65 |
+
}
|
| 66 |
+
.stCaption, .stApp .stCaption p {
|
| 67 |
+
color: var(--text-muted) !important;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* === Sidebar === */
|
| 71 |
+
section[data-testid="stSidebar"] {
|
| 72 |
+
background: var(--bg-white) !important;
|
| 73 |
+
border-right: 1px solid var(--border) !important;
|
| 74 |
+
animation: fadeIn 0.5s ease;
|
| 75 |
+
}
|
| 76 |
+
section[data-testid="stSidebar"] * {
|
| 77 |
+
color: var(--text) !important;
|
| 78 |
+
}
|
| 79 |
+
section[data-testid="stSidebar"] .stCaption p,
|
| 80 |
+
section[data-testid="stSidebar"] .stCaption {
|
| 81 |
+
color: var(--text-muted) !important;
|
| 82 |
+
}
|
| 83 |
+
section[data-testid="stSidebar"] hr {
|
| 84 |
+
border-color: var(--border) !important;
|
| 85 |
+
}
|
| 86 |
+
section[data-testid="stSidebar"] .stButton button {
|
| 87 |
+
background: var(--accent) !important;
|
| 88 |
+
color: white !important;
|
| 89 |
+
border: none !important;
|
| 90 |
+
border-radius: var(--radius) !important;
|
| 91 |
+
padding: 0.5rem 1rem !important;
|
| 92 |
+
font-weight: 600 !important;
|
| 93 |
+
width: 100%;
|
| 94 |
+
transition: all 0.25s ease !important;
|
| 95 |
+
}
|
| 96 |
+
section[data-testid="stSidebar"] .stButton button:hover {
|
| 97 |
+
background: var(--accent-hover) !important;
|
| 98 |
+
box-shadow: var(--shadow-hover) !important;
|
| 99 |
+
transform: translateY(-1px) !important;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* === Chat Messages === */
|
| 103 |
+
.stChatMessage {
|
| 104 |
+
background: var(--bg-white) !important;
|
| 105 |
+
border: 1px solid var(--border) !important;
|
| 106 |
+
border-radius: var(--radius) !important;
|
| 107 |
+
padding: 1rem 1.2rem !important;
|
| 108 |
+
margin-bottom: 0.75rem !important;
|
| 109 |
+
box-shadow: var(--shadow) !important;
|
| 110 |
+
animation: fadeInUp 0.35s ease;
|
| 111 |
+
transition: box-shadow 0.2s ease !important;
|
| 112 |
+
}
|
| 113 |
+
.stChatMessage:hover {
|
| 114 |
+
box-shadow: var(--shadow-hover) !important;
|
| 115 |
+
}
|
| 116 |
+
.stChatMessage p, .stChatMessage li, .stChatMessage span {
|
| 117 |
+
color: var(--text) !important;
|
| 118 |
+
line-height: 1.7 !important;
|
| 119 |
+
font-size: 0.93rem !important;
|
| 120 |
+
}
|
| 121 |
+
.stChatMessage .stMarkdown { color: var(--text) !important; }
|
| 122 |
+
.stChatMessage .stMarkdown strong { color: var(--accent) !important; }
|
| 123 |
+
.stChatMessage .stCaption p { color: var(--text-muted) !important; }
|
| 124 |
+
|
| 125 |
+
.stChatMessage [data-testid="chatAvatarIcon-user"] {
|
| 126 |
+
background: var(--accent) !important;
|
| 127 |
+
}
|
| 128 |
+
.stChatMessage [data-testid="chatAvatarIcon-assistant"] {
|
| 129 |
+
background: var(--teal) !important;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* === Chat Input === */
|
| 133 |
+
[data-testid="stChatInput"],
|
| 134 |
+
[data-testid="stChatInput"] > div,
|
| 135 |
+
.stChatInput {
|
| 136 |
+
background: transparent !important;
|
| 137 |
+
border: none !important;
|
| 138 |
+
box-shadow: none !important;
|
| 139 |
+
padding: 0 !important;
|
| 140 |
+
outline: none !important;
|
| 141 |
+
}
|
| 142 |
+
[data-testid="stBottom"] > div {
|
| 143 |
+
background: transparent !important;
|
| 144 |
+
border: none !important;
|
| 145 |
+
box-shadow: none !important;
|
| 146 |
+
}
|
| 147 |
+
.stChatInput textarea, [data-testid="stChatInputTextArea"] {
|
| 148 |
+
background: var(--bg-white) !important;
|
| 149 |
+
border: 1px solid var(--border) !important;
|
| 150 |
+
border-radius: var(--radius) !important;
|
| 151 |
+
color: var(--text) !important;
|
| 152 |
+
font-size: 0.93rem !important;
|
| 153 |
+
padding: 0.8rem 1rem !important;
|
| 154 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
|
| 155 |
+
}
|
| 156 |
+
.stChatInput textarea:focus, [data-testid="stChatInputTextArea"]:focus {
|
| 157 |
+
border-color: var(--accent) !important;
|
| 158 |
+
box-shadow: 0 0 0 3px rgba(37,99,235,0.12) !important;
|
| 159 |
+
}
|
| 160 |
+
.stChatInput button, [data-testid="stChatInputSubmitButton"] {
|
| 161 |
+
display: none !important;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* === Expander === */
|
| 165 |
+
[data-testid="stExpander"] {
|
| 166 |
+
border: 1px solid var(--border) !important;
|
| 167 |
+
border-radius: var(--radius) !important;
|
| 168 |
+
background: var(--bg-white) !important;
|
| 169 |
+
animation: fadeIn 0.3s ease;
|
| 170 |
+
}
|
| 171 |
+
[data-testid="stExpander"] details { border: none !important; }
|
| 172 |
+
[data-testid="stExpander"] summary {
|
| 173 |
+
color: var(--text) !important;
|
| 174 |
+
font-weight: 500 !important;
|
| 175 |
+
padding: 0.7rem 1rem !important;
|
| 176 |
+
transition: color 0.2s ease !important;
|
| 177 |
+
}
|
| 178 |
+
[data-testid="stExpander"] summary:hover { color: var(--accent) !important; }
|
| 179 |
+
|
| 180 |
+
/* === Text Area (sources) === */
|
| 181 |
+
.stTextArea textarea {
|
| 182 |
+
background: var(--bg) !important;
|
| 183 |
+
border: 1px solid var(--border) !important;
|
| 184 |
+
border-radius: 8px !important;
|
| 185 |
+
color: var(--text-secondary) !important;
|
| 186 |
+
font-size: 0.82rem !important;
|
| 187 |
+
line-height: 1.6 !important;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/* === Divider === */
|
| 191 |
+
hr {
|
| 192 |
+
border-color: var(--border) !important;
|
| 193 |
+
opacity: 0.7;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/* === Spinner === */
|
| 197 |
+
.stSpinner > div > span {
|
| 198 |
+
color: var(--text-secondary) !important;
|
| 199 |
+
animation: pulse 1.5s ease infinite;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/* === Alert boxes === */
|
| 203 |
+
.stAlert {
|
| 204 |
+
border-radius: var(--radius) !important;
|
| 205 |
+
padding: 0.8rem 1rem !important;
|
| 206 |
+
animation: slideIn 0.35s ease;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* === Footer === */
|
| 210 |
+
.stApp > footer { display: none !important; }
|
| 211 |
+
|
| 212 |
+
/* === Layout === */
|
| 213 |
+
.main .block-container {
|
| 214 |
+
max-width: 850px !important;
|
| 215 |
+
padding: 1.5rem 1rem !important;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
[data-testid="stBottom"] {
|
| 219 |
+
background: linear-gradient(180deg, transparent 0%, var(--bg) 40%) !important;
|
| 220 |
+
}
|
config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
# Models (all local - no API key needed)
|
| 4 |
+
EMBEDDING_MODEL_NAME = "BAAI/bge-small-en-v1.5" # 33MB - text to vectors
|
| 5 |
+
NLI_MODEL_NAME = "cross-encoder/nli-deberta-v3-base" # 184MB - hallucination verification
|
| 6 |
+
GENERATOR_MODEL_NAME = "google/flan-t5-base" # 990MB - answer generation
|
| 7 |
+
|
| 8 |
+
# Paths
|
| 9 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 10 |
+
FAISS_INDEX_PATH = os.path.join(BASE_DIR, "faiss_db")
|
| 11 |
+
CHROMA_DB_DIR = os.path.join(BASE_DIR, "vector_db", "chroma_store")
|
| 12 |
+
DATA_DIR = os.path.join(BASE_DIR, "data")
|
| 13 |
+
PDF_DIR = os.path.join(DATA_DIR, "raw_pdfs")
|
| 14 |
+
|
| 15 |
+
# Processing
|
| 16 |
+
TOP_K_RETRIEVE = 5
|
| 17 |
+
TOP_K_CANDIDATES = 15 # Broad retrieval before reranking
|
| 18 |
+
MIN_RELEVANCE_THRESHOLD = 0.50 # Minimum reranking similarity to accept results
|
| 19 |
+
FAITHFULNESS_THRESHOLD = 0.7
|
| 20 |
+
SOURCE_REJECTION_THRESHOLD = 0.15 # Below this faithfulness, hide sources entirely
|
| 21 |
+
|
| 22 |
+
os.makedirs(PDF_DIR, exist_ok=True)
|
| 23 |
+
os.makedirs(FAISS_INDEX_PATH, exist_ok=True)
|
| 24 |
+
os.makedirs(CHROMA_DB_DIR, exist_ok=True)
|
main.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
from src.bio_rag.pipeline import BioRAGPipeline
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 12 |
+
parser = argparse.ArgumentParser(
|
| 13 |
+
description="Bio-RAG: diabetes-focused evidence-based QA with hallucination scoring"
|
| 14 |
+
)
|
| 15 |
+
parser.add_argument(
|
| 16 |
+
"--question",
|
| 17 |
+
type=str,
|
| 18 |
+
default="Can vitamin D help reduce complications in diabetes?",
|
| 19 |
+
help="Medical question to answer.",
|
| 20 |
+
)
|
| 21 |
+
return parser
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
load_dotenv()
|
| 26 |
+
args = build_parser().parse_args()
|
| 27 |
+
|
| 28 |
+
pipe = BioRAGPipeline()
|
| 29 |
+
result = pipe.ask(args.question)
|
| 30 |
+
|
| 31 |
+
print(json.dumps(result.to_dict(), indent=2, default=str))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
main()
|
prompts.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==========================================
|
| 2 |
+
# 1. Free Generation Prompt (No sources - model knowledge only)
|
| 3 |
+
# ==========================================
|
| 4 |
+
# This prompt makes the model answer from its internal knowledge without any external context
|
| 5 |
+
FREE_GENERATION_PROMPT = """You are an expert Medical AI Assistant specializing in diabetes and metabolic diseases.
|
| 6 |
+
Answer the following medical question using your medical knowledge.
|
| 7 |
+
|
| 8 |
+
IMPORTANT INSTRUCTIONS:
|
| 9 |
+
1. Provide a DETAILED answer with at least 3-5 sentences.
|
| 10 |
+
2. Include specific medical facts, mechanisms, and clinical details.
|
| 11 |
+
3. Mention relevant biological processes, risk factors, or treatments.
|
| 12 |
+
4. Use professional medical terminology.
|
| 13 |
+
5. Structure your answer clearly.
|
| 14 |
+
|
| 15 |
+
Question:
|
| 16 |
+
{question}
|
| 17 |
+
|
| 18 |
+
Detailed Medical Answer:"""
|
| 19 |
+
|
| 20 |
+
# ==========================================
|
| 21 |
+
# 2. RAG Prompt (Source-augmented generation) - used as reference only
|
| 22 |
+
# ==========================================
|
| 23 |
+
RAG_SYSTEM_PROMPT = """You are a medical expert specializing in diabetes. Answer the following question
|
| 24 |
+
using ONLY the provided research abstracts. Your answer must be:
|
| 25 |
+
- Exactly 5 to 7 sentences long
|
| 26 |
+
- Factually grounded in the provided evidence
|
| 27 |
+
- Clinically precise and safe for medical use
|
| 28 |
+
- Written in clear professional language
|
| 29 |
+
|
| 30 |
+
Do NOT add information beyond what is in the abstracts.
|
| 31 |
+
|
| 32 |
+
Question:
|
| 33 |
+
{question}
|
| 34 |
+
|
| 35 |
+
Answer:"""
|
| 36 |
+
|
| 37 |
+
# ==========================================
|
| 38 |
+
# 3. Claim Decomposition Prompt
|
| 39 |
+
# ==========================================
|
| 40 |
+
# Used to break down a long answer into small individual claims for verification
|
| 41 |
+
DECOMPOSITION_PROMPT = """You are an expert medical analyzer. Break down the following medical answer into a list of atomic, verifiable facts (claims).
|
| 42 |
+
You must inject context from the original question into every claim so it is completely self-sufficient.
|
| 43 |
+
|
| 44 |
+
RULES:
|
| 45 |
+
1. Each claim must be an atomic, standalone factual statement.
|
| 46 |
+
2. Each claim must explicitly embed the medical subject, the condition context (e.g., diabetes), and any patient constraints mentioned in the question.
|
| 47 |
+
3. Preserve negation: e.g., 'Metformin is NOT recommended' must remain negated.
|
| 48 |
+
4. Preserve uncertainty: e.g., 'Metformin may cause...' must keep 'may'.
|
| 49 |
+
5. Preserve conditionality: e.g., 'When kidney function is below 30...' must stay conditional.
|
| 50 |
+
6. Format the output as a valid JSON object with the key 'claims' containing an array of strings ONLY. Do not include markdown or explanations. NEVER output just an array directly.
|
| 51 |
+
7. Do NOT include any reference codes like [E1], [E2], [E3] in claims.
|
| 52 |
+
8. Do NOT mention study names or abstract numbers. Extract only the medical fact itself.
|
| 53 |
+
9. Do NOT add unnecessary filler phrases like "For a patient with no specified condition".
|
| 54 |
+
|
| 55 |
+
Original Question:
|
| 56 |
+
{question}
|
| 57 |
+
|
| 58 |
+
Answer to Decompose:
|
| 59 |
+
{answer}
|
| 60 |
+
|
| 61 |
+
JSON Output:"""
|
| 62 |
+
HALLUCINATION_TEST_PROMPT = "Generate a plausible-sounding but medically incorrect fact about insulin."
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
flask
|
| 3 |
+
langchain
|
| 4 |
+
langchain-community
|
| 5 |
+
langchain-huggingface
|
| 6 |
+
langchain-chroma
|
| 7 |
+
langchain-text-splitters
|
| 8 |
+
sentence-transformers
|
| 9 |
+
transformers
|
| 10 |
+
torch
|
| 11 |
+
datasets
|
| 12 |
+
pandas
|
| 13 |
+
PyMuPDF
|
| 14 |
+
rank_bm25
|
src/bio_rag/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bio-RAG package for diabetes-focused hallucination quantification."""
|
| 2 |
+
|
| 3 |
+
__all__ = ["BioRAGPipeline"]
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def __getattr__(name: str):
|
| 7 |
+
if name == "BioRAGPipeline":
|
| 8 |
+
from .pipeline import BioRAGPipeline
|
| 9 |
+
return BioRAGPipeline
|
| 10 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
src/bio_rag/claim_decomposer.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# Add root folder to sys.path to be able to import prompts
|
| 10 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 11 |
+
try:
|
| 12 |
+
from prompts import DECOMPOSITION_PROMPT
|
| 13 |
+
except ImportError:
|
| 14 |
+
# Fallback if import fails
|
| 15 |
+
DECOMPOSITION_PROMPT = """You are an expert medical analyzer. Break down the following medical answer into a list of atomic, verifiable facts (claims).
|
| 16 |
+
You must inject context from the original question into every claim so it is completely self-sufficient.
|
| 17 |
+
|
| 18 |
+
RULES:
|
| 19 |
+
1. Each claim must be an atomic, standalone factual statement.
|
| 20 |
+
2. Each claim must explicitly embed the medical subject, the condition context (e.g., diabetes), and any patient constraints mentioned in the question.
|
| 21 |
+
3. Preserve negation: e.g., 'Metformin is NOT recommended' must remain negated.
|
| 22 |
+
4. Preserve uncertainty: e.g., 'Metformin may cause...' must keep 'may'.
|
| 23 |
+
5. Preserve conditionality: e.g., 'When kidney function is below 30...' must stay conditional.
|
| 24 |
+
6. Format the output as a valid JSON object with the key 'claims' containing an array of strings ONLY. Do not include markdown or explanations. NEVER output just an array directly.
|
| 25 |
+
7. Do NOT include any reference codes like [E1], [E2], [E3] in claims.
|
| 26 |
+
8. Do NOT mention study names or abstract numbers. Extract only the medical fact itself.
|
| 27 |
+
9. Do NOT add unnecessary filler phrases like "For a patient with no specified condition".
|
| 28 |
+
|
| 29 |
+
Original Question:
|
| 30 |
+
{question}
|
| 31 |
+
|
| 32 |
+
Answer to Decompose:
|
| 33 |
+
{answer}
|
| 34 |
+
|
| 35 |
+
JSON Output:"""
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
class ClaimDecomposer:
|
| 40 |
+
"""Decomposes an answer into atomic, context-injected claims using an LLM."""
|
| 41 |
+
|
| 42 |
+
def __init__(self, generator) -> None:
|
| 43 |
+
self.generator = generator
|
| 44 |
+
|
| 45 |
+
def decompose(self, question: str, answer: str) -> list[str]:
|
| 46 |
+
# Ensure we use our updated prompt even if local prompts.py exists by overriding for this test
|
| 47 |
+
prompt = DECOMPOSITION_PROMPT.format(question=question, answer=answer)
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
output = self._generate_with_model(prompt, is_json=True)
|
| 51 |
+
|
| 52 |
+
import re
|
| 53 |
+
cleaned_json = re.sub(r'^```[jJ]son\s*', '', output)
|
| 54 |
+
cleaned_json = re.sub(r'```$', '', cleaned_json).strip()
|
| 55 |
+
|
| 56 |
+
obj = json.loads(cleaned_json)
|
| 57 |
+
claims = obj.get("claims", [])
|
| 58 |
+
|
| 59 |
+
if isinstance(claims, list) and all(isinstance(c, str) for c in claims):
|
| 60 |
+
return claims
|
| 61 |
+
|
| 62 |
+
logger.warning("Failed to parse JSON for claim decomposition. Attempting simple split fallback.")
|
| 63 |
+
return self._fallback_decompose(answer)
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.warning(f"Error during claim decomposition: {e}")
|
| 66 |
+
return self._fallback_decompose(answer)
|
| 67 |
+
|
| 68 |
+
def _fallback_decompose(self, answer: str) -> list[str]:
|
| 69 |
+
"""Fallback just in case the LLM or JSON parsing fails severely."""
|
| 70 |
+
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
| 71 |
+
return [s.strip(" -\n\t") for s in _SENTENCE_SPLIT.split(answer.strip()) if len(s.strip()) > 10]
|
| 72 |
+
|
| 73 |
+
def _generate_with_model(self, text: str, is_json: bool = False) -> str:
|
| 74 |
+
# Calls the centralized Groq API generation method
|
| 75 |
+
return self.generator.generate_direct(text, max_tokens=500, is_json=is_json)
|
src/bio_rag/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
DIABETES_KEYWORDS = [
|
| 9 |
+
"diabetes",
|
| 10 |
+
"diabetic",
|
| 11 |
+
"blood glucose",
|
| 12 |
+
"insulin",
|
| 13 |
+
"metformin",
|
| 14 |
+
"type 1",
|
| 15 |
+
"type 2",
|
| 16 |
+
"glycemic",
|
| 17 |
+
"hyperglycemia",
|
| 18 |
+
"hypoglycemia",
|
| 19 |
+
"biguanide",
|
| 20 |
+
"antidiabetic",
|
| 21 |
+
"glucophage",
|
| 22 |
+
"renal",
|
| 23 |
+
"nephropathy",
|
| 24 |
+
"kidney",
|
| 25 |
+
"lactic acidosis",
|
| 26 |
+
"egfr",
|
| 27 |
+
"creatinine"
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class BioRAGConfig:
|
| 33 |
+
embedding_model: str = os.getenv(
|
| 34 |
+
"BIO_RAG_EMBEDDING_MODEL", "NeuML/pubmedbert-base-embeddings"
|
| 35 |
+
)
|
| 36 |
+
generator_model: str = os.getenv("BIO_RAG_GENERATOR_MODEL", "llama-3.1-8b-instant")
|
| 37 |
+
nli_model: str = os.getenv("BIO_RAG_NLI_MODEL", "pritamdeka/PubMedBERT-MNLI-MedNLI")
|
| 38 |
+
index_path: Path = Path(os.getenv("BIO_RAG_INDEX_PATH", ".cache/bio_rag_faiss"))
|
| 39 |
+
max_samples: int = int(os.getenv("BIO_RAG_MAX_SAMPLES", "20000"))
|
| 40 |
+
top_k: int = int(os.getenv("BIO_RAG_TOP_K", "10"))
|
| 41 |
+
claim_similarity_threshold: float = float(
|
| 42 |
+
os.getenv("BIO_RAG_CLAIM_SIM_THRESHOLD", "0.62")
|
| 43 |
+
)
|
| 44 |
+
dataset_name: str = "qiaojin/PubMedQA"
|
src/bio_rag/data_loader.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import urllib.request
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any, Iterable
|
| 8 |
+
|
| 9 |
+
from datasets import Dataset, DatasetDict, load_dataset
|
| 10 |
+
|
| 11 |
+
from .config import DIABETES_KEYWORDS
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class PubMedQASample:
|
| 18 |
+
qid: str
|
| 19 |
+
question: str
|
| 20 |
+
context: str
|
| 21 |
+
answer: str
|
| 22 |
+
authors: str = ""
|
| 23 |
+
year: str = ""
|
| 24 |
+
journal: str = ""
|
| 25 |
+
title: str = ""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _normalize_text(text: str) -> str:
|
| 29 |
+
return " ".join(str(text).split())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _extract_context_text(record: dict[str, Any]) -> str:
|
| 33 |
+
context = record.get("context", "")
|
| 34 |
+
|
| 35 |
+
if isinstance(context, dict):
|
| 36 |
+
blocks = []
|
| 37 |
+
for key in ("contexts", "sentences", "text", "abstract"):
|
| 38 |
+
val = context.get(key)
|
| 39 |
+
if isinstance(val, list):
|
| 40 |
+
blocks.extend(str(v) for v in val)
|
| 41 |
+
elif isinstance(val, str):
|
| 42 |
+
blocks.append(val)
|
| 43 |
+
if blocks:
|
| 44 |
+
return _normalize_text(" ".join(blocks))
|
| 45 |
+
|
| 46 |
+
if isinstance(context, list):
|
| 47 |
+
return _normalize_text(" ".join(str(v) for v in context))
|
| 48 |
+
|
| 49 |
+
if isinstance(context, str):
|
| 50 |
+
return _normalize_text(context)
|
| 51 |
+
|
| 52 |
+
long_answer = record.get("long_answer") or record.get("final_decision") or ""
|
| 53 |
+
return _normalize_text(str(long_answer))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _extract_answer_text(record: dict[str, Any]) -> str:
|
| 57 |
+
for key in ("long_answer", "final_decision", "answer"):
|
| 58 |
+
val = record.get(key)
|
| 59 |
+
if isinstance(val, str) and val.strip():
|
| 60 |
+
return _normalize_text(val)
|
| 61 |
+
return ""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _is_diabetes_related(question: str, context: str, keywords: Iterable[str]) -> bool:
|
| 65 |
+
corpus = f"{question} {context}".lower()
|
| 66 |
+
return any(keyword.lower() in corpus for keyword in keywords)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_diabetes_pubmedqa(
|
| 70 |
+
dataset_name: str,
|
| 71 |
+
max_samples: int = 2000,
|
| 72 |
+
keywords: Iterable[str] = DIABETES_KEYWORDS,
|
| 73 |
+
) -> list[PubMedQASample]:
|
| 74 |
+
import warnings
|
| 75 |
+
import os
|
| 76 |
+
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
|
| 77 |
+
|
| 78 |
+
with warnings.catch_warnings():
|
| 79 |
+
warnings.simplefilter("ignore")
|
| 80 |
+
# PubMedQA requires a config name; prefer artificial/unlabeled for scale
|
| 81 |
+
for config_name in ("pqa_artificial", "pqa_unlabeled", "pqa_labeled"):
|
| 82 |
+
try:
|
| 83 |
+
raw = load_dataset(dataset_name, config_name)
|
| 84 |
+
break
|
| 85 |
+
except Exception:
|
| 86 |
+
continue
|
| 87 |
+
else:
|
| 88 |
+
raw = load_dataset(dataset_name)
|
| 89 |
+
split = _pick_split(raw)
|
| 90 |
+
|
| 91 |
+
filtered: list[PubMedQASample] = []
|
| 92 |
+
for idx, record in enumerate(split):
|
| 93 |
+
question = _normalize_text(str(record.get("question", "")))
|
| 94 |
+
context = _extract_context_text(record)
|
| 95 |
+
|
| 96 |
+
if not question or not context:
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
if not _is_diabetes_related(question, context, keywords):
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
filtered.append(
|
| 103 |
+
PubMedQASample(
|
| 104 |
+
qid=str(record.get("pubid", idx)),
|
| 105 |
+
question=question,
|
| 106 |
+
context=context,
|
| 107 |
+
answer=_extract_answer_text(record),
|
| 108 |
+
)
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
if len(filtered) >= max_samples:
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
# Fetch PubMed metadata (authors, year, journal) in batch
|
| 115 |
+
# _enrich_with_pubmed_metadata(filtered) # Disabled to prevent API timeout and speed up indexing
|
| 116 |
+
|
| 117 |
+
return filtered
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _enrich_with_pubmed_metadata(samples: list[PubMedQASample]) -> None:
|
| 121 |
+
"""Fetch author/year/journal from PubMed API for all samples."""
|
| 122 |
+
if not samples:
|
| 123 |
+
return
|
| 124 |
+
pubids = [s.qid for s in samples if s.qid.isdigit()]
|
| 125 |
+
if not pubids:
|
| 126 |
+
return
|
| 127 |
+
metadata: dict[str, dict] = {}
|
| 128 |
+
for i in range(0, len(pubids), 200):
|
| 129 |
+
batch = pubids[i:i+200]
|
| 130 |
+
ids_str = ",".join(batch)
|
| 131 |
+
url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id={ids_str}&retmode=json"
|
| 132 |
+
try:
|
| 133 |
+
req = urllib.request.Request(url, headers={"User-Agent": "BioRAG/1.0"})
|
| 134 |
+
resp = urllib.request.urlopen(req, timeout=15)
|
| 135 |
+
data = json.loads(resp.read())
|
| 136 |
+
result = data.get("result", {})
|
| 137 |
+
for pid in batch:
|
| 138 |
+
if pid in result and isinstance(result[pid], dict):
|
| 139 |
+
metadata[pid] = result[pid]
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.warning("PubMed metadata fetch failed: %s", e)
|
| 142 |
+
for s in samples:
|
| 143 |
+
info = metadata.get(s.qid)
|
| 144 |
+
if not info:
|
| 145 |
+
continue
|
| 146 |
+
authors_list = info.get("authors", [])
|
| 147 |
+
if authors_list:
|
| 148 |
+
names = [a.get("name", "") for a in authors_list[:3]]
|
| 149 |
+
s.authors = ", ".join(names)
|
| 150 |
+
if len(authors_list) > 3:
|
| 151 |
+
s.authors += " et al."
|
| 152 |
+
pubdate = info.get("pubdate", "")
|
| 153 |
+
if pubdate:
|
| 154 |
+
s.year = pubdate.split()[0] if pubdate.split() else pubdate[:4]
|
| 155 |
+
s.journal = info.get("source", "")
|
| 156 |
+
s.title = info.get("title", "")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _pick_split(raw: DatasetDict | Dataset) -> Dataset:
|
| 160 |
+
if isinstance(raw, Dataset):
|
| 161 |
+
return raw
|
| 162 |
+
|
| 163 |
+
for candidate in ("train", "pqa_labeled", "validation", "test"):
|
| 164 |
+
if candidate in raw:
|
| 165 |
+
return raw[candidate]
|
| 166 |
+
|
| 167 |
+
first_key = next(iter(raw.keys()))
|
| 168 |
+
return raw[first_key]
|
src/bio_rag/generator.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Iterable
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from groq import Groq
|
| 8 |
+
|
| 9 |
+
from .retriever import RetrievedPassage
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Switch to use Groq API instead of local Models
|
| 14 |
+
class BiomedicalAnswerGenerator:
|
| 15 |
+
"""Generates answers using a biomedical LLM via Groq API."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, model_name: str = "llama-3.1-8b-instant") -> None:
|
| 18 |
+
self.model_name = model_name
|
| 19 |
+
self._is_seq2seq = False
|
| 20 |
+
self.client = Groq(api_key=os.getenv("GROQ_API_KEY"))
|
| 21 |
+
logger.info("Loaded Groq API Generator with model: %s", self.model_name)
|
| 22 |
+
|
| 23 |
+
def generate(self, question: str, passages: Iterable[RetrievedPassage]) -> str:
|
| 24 |
+
passage_list = list(passages)
|
| 25 |
+
prompt = _format_prompt(question, passage_list, seq2seq=self._is_seq2seq)
|
| 26 |
+
return self.generate_direct(prompt, max_tokens=300)
|
| 27 |
+
|
| 28 |
+
def generate_direct(self, prompt: str, max_tokens: int = 512, is_json: bool = False) -> str:
|
| 29 |
+
"""Helper method for QueryProcessor and ClaimDecomposer to generate text."""
|
| 30 |
+
try:
|
| 31 |
+
kwargs = {
|
| 32 |
+
"messages": [
|
| 33 |
+
{
|
| 34 |
+
"role": "user",
|
| 35 |
+
"content": prompt,
|
| 36 |
+
}
|
| 37 |
+
],
|
| 38 |
+
"model": self.model_name,
|
| 39 |
+
"temperature": 0.0,
|
| 40 |
+
"max_tokens": max_tokens,
|
| 41 |
+
}
|
| 42 |
+
if is_json:
|
| 43 |
+
kwargs["response_format"] = {"type": "json_object"}
|
| 44 |
+
|
| 45 |
+
response = self.client.chat.completions.create(**kwargs)
|
| 46 |
+
return response.choices[0].message.content.strip()
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error("Error generating with Groq API: %s", e)
|
| 49 |
+
return "No answer generated."
|
| 50 |
+
|
| 51 |
+
def _format_prompt(question: str, passages: list[RetrievedPassage], *, seq2seq: bool = False) -> str:
|
| 52 |
+
evidence_block = "\n\n".join(
|
| 53 |
+
[f"[E{p.rank}] {p.text}" for p in passages]
|
| 54 |
+
)
|
| 55 |
+
return (
|
| 56 |
+
"You are a highly confident and authoritative medical consultant specializing in diabetes. You MUST answer the user's question directly and assertively.\n"
|
| 57 |
+
"NEVER refuse to answer. NEVER use standard AI disclaimers like 'I cannot provide medical advice', 'consult a doctor', or 'I am an AI'.\n"
|
| 58 |
+
"Your task is to provide exactly 5 to 7 sentences answering the medical question, incorporating concepts from the provided abstracts if possible, but you MUST provide a direct answer to the user's query no matter what.\n\n"
|
| 59 |
+
f"Context:\n{evidence_block}\n\n"
|
| 60 |
+
f"Question:\n{question}\n\n"
|
| 61 |
+
"Answer:\n"
|
| 62 |
+
)
|
src/bio_rag/knowledge_base.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import os
|
| 5 |
+
import warnings
|
| 6 |
+
|
| 7 |
+
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
|
| 8 |
+
|
| 9 |
+
from langchain_core.documents import Document
|
| 10 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 11 |
+
from langchain_community.vectorstores import FAISS
|
| 12 |
+
|
| 13 |
+
from .config import BioRAGConfig
|
| 14 |
+
from .data_loader import PubMedQASample
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class KnowledgeBaseBuilder:
|
| 18 |
+
def __init__(self, config: BioRAGConfig) -> None:
|
| 19 |
+
self.config = config
|
| 20 |
+
self.embeddings = HuggingFaceEmbeddings(
|
| 21 |
+
model_name=config.embedding_model,
|
| 22 |
+
show_progress=True,
|
| 23 |
+
encode_kwargs={"batch_size": 32}
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
def build(self, samples: list[PubMedQASample]) -> FAISS:
|
| 27 |
+
documents = [
|
| 28 |
+
Document(
|
| 29 |
+
page_content=sample.context,
|
| 30 |
+
metadata={
|
| 31 |
+
"qid": sample.qid,
|
| 32 |
+
"question": sample.question,
|
| 33 |
+
"answer": sample.answer,
|
| 34 |
+
"authors": sample.authors,
|
| 35 |
+
"year": sample.year,
|
| 36 |
+
"journal": sample.journal,
|
| 37 |
+
"title": sample.title,
|
| 38 |
+
},
|
| 39 |
+
)
|
| 40 |
+
for sample in samples
|
| 41 |
+
]
|
| 42 |
+
return FAISS.from_documents(documents, self.embeddings)
|
| 43 |
+
|
| 44 |
+
def save(self, vectorstore: FAISS) -> None:
|
| 45 |
+
self.config.index_path.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
vectorstore.save_local(str(self.config.index_path))
|
| 47 |
+
|
| 48 |
+
def load_or_build(self, samples: list[PubMedQASample]) -> FAISS:
|
| 49 |
+
path = self.config.index_path
|
| 50 |
+
if _looks_like_faiss_index(path):
|
| 51 |
+
return FAISS.load_local(
|
| 52 |
+
str(path),
|
| 53 |
+
self.embeddings,
|
| 54 |
+
allow_dangerous_deserialization=True,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
vectorstore = self.build(samples)
|
| 58 |
+
self.save(vectorstore)
|
| 59 |
+
return vectorstore
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _looks_like_faiss_index(path: Path) -> bool:
|
| 63 |
+
return path.exists() and (path / "index.faiss").exists() and (path / "index.pkl").exists()
|
src/bio_rag/nli_evaluator.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
import torch
|
| 5 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class NLIEvaluator:
|
| 10 |
+
def __init__(self, model_name="pritamdeka/BioBERT-mnli-snli-scinli-scitail-mednli-sst2"):
|
| 11 |
+
import os
|
| 12 |
+
import warnings
|
| 13 |
+
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
|
| 14 |
+
|
| 15 |
+
logger.info(f"Loading NLI model: {model_name}")
|
| 16 |
+
# Removed batch_size to prevent PyTorch DataLoader deadlock on Windows CPU
|
| 17 |
+
with warnings.catch_warnings():
|
| 18 |
+
warnings.simplefilter("ignore")
|
| 19 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 20 |
+
self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
|
| 21 |
+
print(f"LOADED MODEL: {self.model.config._name_or_path}")
|
| 22 |
+
print(f"LABELS: {self.model.config.id2label}")
|
| 23 |
+
|
| 24 |
+
def _chunk_evidence(self, text: str, window_size: int = 3, stride: int = 1) -> list[str]:
|
| 25 |
+
# Split text into sentences
|
| 26 |
+
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if len(s.strip()) > 15]
|
| 27 |
+
if not sentences:
|
| 28 |
+
return [text]
|
| 29 |
+
|
| 30 |
+
chunks = []
|
| 31 |
+
for i in range(0, len(sentences), stride):
|
| 32 |
+
chunk = " ".join(sentences[i:i+window_size])
|
| 33 |
+
chunks.append(chunk)
|
| 34 |
+
if i + window_size >= len(sentences):
|
| 35 |
+
break
|
| 36 |
+
return chunks
|
| 37 |
+
|
| 38 |
+
def evaluate(self, claim: str, evidence_texts: list[str]) -> float:
|
| 39 |
+
if not evidence_texts:
|
| 40 |
+
return 1.0
|
| 41 |
+
|
| 42 |
+
all_scores = []
|
| 43 |
+
|
| 44 |
+
chunked_evidences = []
|
| 45 |
+
for text in evidence_texts:
|
| 46 |
+
chunked_evidences.extend(self._chunk_evidence(text, window_size=3, stride=3))
|
| 47 |
+
|
| 48 |
+
for evidence in chunked_evidences:
|
| 49 |
+
try:
|
| 50 |
+
inputs = self.tokenizer(
|
| 51 |
+
evidence,
|
| 52 |
+
claim,
|
| 53 |
+
return_tensors="pt",
|
| 54 |
+
truncation=True,
|
| 55 |
+
max_length=512
|
| 56 |
+
)
|
| 57 |
+
with torch.no_grad():
|
| 58 |
+
outputs = self.model(**inputs)
|
| 59 |
+
|
| 60 |
+
probs = torch.softmax(outputs.logits, dim=-1)
|
| 61 |
+
|
| 62 |
+
entail_prob = 0.0
|
| 63 |
+
contradict_prob = 0.0
|
| 64 |
+
neutral_prob = 0.0
|
| 65 |
+
for i, label in self.model.config.id2label.items():
|
| 66 |
+
prob = probs[0][i].item()
|
| 67 |
+
label_lower = label.lower()
|
| 68 |
+
if 'entail' in label_lower:
|
| 69 |
+
entail_prob = prob
|
| 70 |
+
elif 'contradict' in label_lower:
|
| 71 |
+
contradict_prob = prob
|
| 72 |
+
elif 'neutral' in label_lower:
|
| 73 |
+
neutral_prob = prob
|
| 74 |
+
|
| 75 |
+
nli_prob = (0.5 * neutral_prob) + contradict_prob
|
| 76 |
+
all_scores.append(nli_prob)
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.warning(f"NLI Evaluation failed for a pair: {e}")
|
| 80 |
+
|
| 81 |
+
if not all_scores:
|
| 82 |
+
return 1.0
|
| 83 |
+
|
| 84 |
+
all_scores.sort()
|
| 85 |
+
min_score = all_scores[0]
|
| 86 |
+
max_score = max(all_scores)
|
| 87 |
+
|
| 88 |
+
# If strong support exists (< 0.05) AND no strong contradiction (> 0.7),
|
| 89 |
+
# trust the supporting evidence
|
| 90 |
+
if min_score < 0.05 and max_score < 0.7:
|
| 91 |
+
result = min_score
|
| 92 |
+
elif min_score < 0.05 and max_score >= 0.7:
|
| 93 |
+
# Mixed evidence: some support, some contradict — use percentile 25
|
| 94 |
+
idx = max(0, len(all_scores) // 4)
|
| 95 |
+
result = all_scores[idx]
|
| 96 |
+
else:
|
| 97 |
+
# No strong support — use percentile 25
|
| 98 |
+
idx = max(0, len(all_scores) // 4)
|
| 99 |
+
result = all_scores[idx]
|
| 100 |
+
|
| 101 |
+
# Unverified claim handler
|
| 102 |
+
if result > 0.45 and result < 0.55:
|
| 103 |
+
result = 0.8501
|
| 104 |
+
|
| 105 |
+
return result
|
src/bio_rag/pipeline.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/bio_rag/pipeline.py
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
from .claim_decomposer import ClaimDecomposer
|
| 9 |
+
from .config import BioRAGConfig
|
| 10 |
+
from .data_loader import load_diabetes_pubmedqa
|
| 11 |
+
from .generator import BiomedicalAnswerGenerator
|
| 12 |
+
from .knowledge_base import KnowledgeBaseBuilder
|
| 13 |
+
from .retriever import BioRetriever, RetrievedPassage
|
| 14 |
+
from .query_processor import QueryProcessor
|
| 15 |
+
from .nli_evaluator import NLIEvaluator
|
| 16 |
+
from .risk_scorer import RiskScorer
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class BioRAGResult:
|
| 20 |
+
question: str
|
| 21 |
+
original_answer: str
|
| 22 |
+
final_answer: str
|
| 23 |
+
evidence: list[RetrievedPassage]
|
| 24 |
+
claims: list[str]
|
| 25 |
+
claim_checks: list[dict]
|
| 26 |
+
max_risk_score: float = 0.0
|
| 27 |
+
safe: bool = True
|
| 28 |
+
rejection_message: str = ""
|
| 29 |
+
|
| 30 |
+
def to_dict(self) -> dict:
|
| 31 |
+
return asdict(self)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class BioRAGPipeline:
|
| 35 |
+
def __init__(self, config: BioRAGConfig | None = None) -> None:
|
| 36 |
+
self.config = config or BioRAGConfig()
|
| 37 |
+
|
| 38 |
+
# Phase 1 initialization
|
| 39 |
+
self.samples = load_diabetes_pubmedqa(
|
| 40 |
+
dataset_name=self.config.dataset_name,
|
| 41 |
+
max_samples=self.config.max_samples,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
kb_builder = KnowledgeBaseBuilder(self.config)
|
| 45 |
+
self.vectorstore = kb_builder.load_or_build(self.samples)
|
| 46 |
+
|
| 47 |
+
self.retriever = BioRetriever(self.vectorstore, top_k=self.config.top_k)
|
| 48 |
+
self.generator = BiomedicalAnswerGenerator(self.config.generator_model)
|
| 49 |
+
self.query_processor = QueryProcessor(self.generator)
|
| 50 |
+
|
| 51 |
+
# Phase 2 initialization
|
| 52 |
+
self.claim_decomposer = ClaimDecomposer(self.generator)
|
| 53 |
+
self.nli_evaluator = NLIEvaluator(self.config.nli_model)
|
| 54 |
+
self.risk_scorer = RiskScorer()
|
| 55 |
+
|
| 56 |
+
def ask(self, question: str) -> BioRAGResult:
|
| 57 |
+
"""
|
| 58 |
+
Executes the full Phase 1 (Domain Scoping, Expansion, Retrieval, Generation)
|
| 59 |
+
and Phase 2 (Decomposition, NLI, Risk Scoring, Flagging) pipeline.
|
| 60 |
+
"""
|
| 61 |
+
# --- PHASE 1 ---
|
| 62 |
+
# 1.1: Domain Scoping
|
| 63 |
+
is_valid, msg = self.query_processor.validate_domain(question)
|
| 64 |
+
if not is_valid:
|
| 65 |
+
return BioRAGResult(
|
| 66 |
+
question=question,
|
| 67 |
+
original_answer="",
|
| 68 |
+
final_answer=msg,
|
| 69 |
+
evidence=[],
|
| 70 |
+
claims=[],
|
| 71 |
+
claim_checks=[],
|
| 72 |
+
rejection_message=msg,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# 1.2: Query Refinement & Expansion (Groq JSON)
|
| 76 |
+
queries_to_run = self.query_processor.expand_queries(question)
|
| 77 |
+
|
| 78 |
+
# 1.3: Hybrid Retrieval + RRF
|
| 79 |
+
passages = self.retriever.retrieve(queries_to_run)
|
| 80 |
+
|
| 81 |
+
# Phase 1 relevance check: if retriever returns too few results,
|
| 82 |
+
# the corpus likely doesn't cover this topic — refuse to generate
|
| 83 |
+
if len(passages) < 3:
|
| 84 |
+
no_evidence_msg = (
|
| 85 |
+
"Insufficient medical evidence found in the database to answer "
|
| 86 |
+
"your question reliably. Please consult a healthcare professional "
|
| 87 |
+
"or rephrase your question."
|
| 88 |
+
)
|
| 89 |
+
return BioRAGResult(
|
| 90 |
+
question=question,
|
| 91 |
+
original_answer="",
|
| 92 |
+
final_answer=no_evidence_msg,
|
| 93 |
+
evidence=[],
|
| 94 |
+
claims=[],
|
| 95 |
+
claim_checks=[],
|
| 96 |
+
rejection_message=no_evidence_msg,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# 1.4: LLM Answer Generation (Groq)
|
| 100 |
+
original_answer = self.generator.generate(question, passages)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# --- PHASE 2 ---
|
| 104 |
+
# 2.1: Semantic Decomposition (Groq JSON)
|
| 105 |
+
try:
|
| 106 |
+
claims_out = self.claim_decomposer.decompose(question, original_answer)
|
| 107 |
+
|
| 108 |
+
# Decomposer now directly returns a list of strings
|
| 109 |
+
if isinstance(claims_out, list) and len(claims_out) > 0:
|
| 110 |
+
claims = claims_out
|
| 111 |
+
else:
|
| 112 |
+
claims = [original_answer]
|
| 113 |
+
except Exception:
|
| 114 |
+
claims = [original_answer]
|
| 115 |
+
|
| 116 |
+
# 2.2 - 2.5: Per-Claim Retrieval, NLI Evaluation, Risk Calculation
|
| 117 |
+
claim_checks = []
|
| 118 |
+
max_risk = 0.0
|
| 119 |
+
|
| 120 |
+
for claim in claims:
|
| 121 |
+
# 2.2: Context Injection Retrieval
|
| 122 |
+
enriched_query = f"{question} {claim}"
|
| 123 |
+
# زيادة top_k لضمان جلب أدلة تناقض الجرعات المتعلقة بالكلى
|
| 124 |
+
claim_passages = self.retriever.retrieve([enriched_query])[:10]
|
| 125 |
+
|
| 126 |
+
combined_evidence = " ".join([p.text for p in claim_passages])
|
| 127 |
+
combined_evidence = combined_evidence[:1500]
|
| 128 |
+
|
| 129 |
+
# 2.3: DeBERTa V3 NLI probability
|
| 130 |
+
# Evaluate finds the minimum contradiction (best support) across all retrieved passages
|
| 131 |
+
best_nli_prob = self.nli_evaluator.evaluate(claim, [combined_evidence])
|
| 132 |
+
|
| 133 |
+
# 2.4: Clinical Impact & Risk Weighting
|
| 134 |
+
profile = self.risk_scorer.calculate_profile(claim)
|
| 135 |
+
severity, type_score, omitted = profile.severity, profile.type_score, profile.omission
|
| 136 |
+
|
| 137 |
+
# Risk-Weighted Score = NLI_Probability x (Severity x Type x Omission)
|
| 138 |
+
risk_score = self.risk_scorer.compute_weighted_risk(best_nli_prob, profile)
|
| 139 |
+
|
| 140 |
+
# 2.5: Max Risk Aggregation
|
| 141 |
+
max_risk = max(max_risk, risk_score)
|
| 142 |
+
|
| 143 |
+
claim_checks.append({
|
| 144 |
+
"claim": claim,
|
| 145 |
+
"nli_prob": round(best_nli_prob, 4),
|
| 146 |
+
"severity_score": severity,
|
| 147 |
+
"type_score": type_score,
|
| 148 |
+
"omission_score": omitted,
|
| 149 |
+
"risk_score": round(risk_score, 4)
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
# 2.6: Final Decision: Safe or Dangerous
|
| 153 |
+
is_safe = max_risk < 0.7
|
| 154 |
+
|
| 155 |
+
if not is_safe:
|
| 156 |
+
decision_msg = "WARNING: This answer contains potentially unverified medical information. Confidence threshold not met. Do not use for clinical decisions."
|
| 157 |
+
final_answer = f"{decision_msg}\n\n{original_answer}"
|
| 158 |
+
else:
|
| 159 |
+
final_answer = original_answer
|
| 160 |
+
|
| 161 |
+
return BioRAGResult(
|
| 162 |
+
question=question,
|
| 163 |
+
original_answer=original_answer,
|
| 164 |
+
final_answer=final_answer,
|
| 165 |
+
evidence=passages,
|
| 166 |
+
claims=claims,
|
| 167 |
+
claim_checks=claim_checks,
|
| 168 |
+
max_risk_score=round(max_risk, 4),
|
| 169 |
+
safe=is_safe
|
| 170 |
+
)
|
src/bio_rag/query_processor.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
from typing import List, Tuple
|
| 7 |
+
|
| 8 |
+
from .config import DIABETES_KEYWORDS
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Negation patterns that indicate the question is about non-diabetic patients
|
| 13 |
+
NON_DIABETES_PATTERNS = [
|
| 14 |
+
"non-diabetic", "non diabetic", "nondiabetic",
|
| 15 |
+
"without diabetes", "no diabetes", "not diabetic",
|
| 16 |
+
"healthy individuals", "healthy subjects", "healthy patients",
|
| 17 |
+
"non-diabetic patients", "non-diabetic individuals",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
# Common misspellings of diabetes-related terms
|
| 21 |
+
DIABETES_MISSPELLINGS = [
|
| 22 |
+
"diabeties", "diabtes", "dibeties", "diabetis", "diabeets",
|
| 23 |
+
"diebetes", "diabeetus", "diebeties",
|
| 24 |
+
"metformn", "metformine", "metformin",
|
| 25 |
+
"insuln", "insuline",
|
| 26 |
+
"glucos", "glocose",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
class QueryProcessor:
|
| 30 |
+
"""Handles domain validation and query expansion using LLM."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, generator) -> None:
|
| 33 |
+
self.generator = generator
|
| 34 |
+
|
| 35 |
+
def validate_domain(self, question: str) -> Tuple[bool, str]:
|
| 36 |
+
q_lower = question.lower()
|
| 37 |
+
|
| 38 |
+
# Check if question is explicitly about non-diabetic patients
|
| 39 |
+
if any(pattern in q_lower for pattern in NON_DIABETES_PATTERNS):
|
| 40 |
+
# Still allow if question compares diabetic vs non-diabetic
|
| 41 |
+
if not any(k in q_lower for k in ["compared to", "versus", "vs", "comparison"]):
|
| 42 |
+
return False, (
|
| 43 |
+
"This system is designed for diabetes patients only. "
|
| 44 |
+
"Your question appears to be about non-diabetic patients."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Check standard keywords
|
| 48 |
+
if any(keyword in q_lower for keyword in DIABETES_KEYWORDS):
|
| 49 |
+
return True, ""
|
| 50 |
+
|
| 51 |
+
# Check common misspellings
|
| 52 |
+
if any(misspelling in q_lower for misspelling in DIABETES_MISSPELLINGS):
|
| 53 |
+
return True, ""
|
| 54 |
+
|
| 55 |
+
return False, (
|
| 56 |
+
"This system is strict to Diabetes. "
|
| 57 |
+
"Your question appears to be outside this domain."
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def expand_queries(self, question: str) -> List[str]:
|
| 61 |
+
prompt = (
|
| 62 |
+
"You are a medical query engineer. Given a user question about diabetes, produce 4 search query variants:\n"
|
| 63 |
+
"1 BM25-optimized with MeSH terms\n"
|
| 64 |
+
"1 Dense-optimized\n"
|
| 65 |
+
"2 semantic variants\n\n"
|
| 66 |
+
"Return as JSON array of query strings. Do NOT include Markdown formatting like ``json.\n\n"
|
| 67 |
+
f"Question: '{question}'\n\n"
|
| 68 |
+
"JSON Output:"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
output = self._generate_with_model(prompt, is_json=True)
|
| 73 |
+
import re
|
| 74 |
+
cleaned_json = re.sub(r'^```[jJ]son\s*', '', output)
|
| 75 |
+
cleaned_json = re.sub(r'```$', '', cleaned_json).strip()
|
| 76 |
+
# Handle standard Groq response format for json
|
| 77 |
+
try:
|
| 78 |
+
queries = json.loads(cleaned_json)
|
| 79 |
+
if isinstance(queries, dict):
|
| 80 |
+
# Trying to find the array in the dict
|
| 81 |
+
for key in queries:
|
| 82 |
+
if isinstance(queries[key], list):
|
| 83 |
+
queries = queries[key]
|
| 84 |
+
break
|
| 85 |
+
|
| 86 |
+
# Extract string queries if it returned a list of dicts instead of list of strings
|
| 87 |
+
if isinstance(queries, list) and len(queries) > 0 and isinstance(queries[0], dict):
|
| 88 |
+
queries = [q.get("query", str(q)) for q in queries if "query" in q]
|
| 89 |
+
|
| 90 |
+
except json.JSONDecodeError:
|
| 91 |
+
# Fallback pattern if JSON parse fails
|
| 92 |
+
queries = []
|
| 93 |
+
|
| 94 |
+
if isinstance(queries, list) and all(isinstance(q, str) for q in queries):
|
| 95 |
+
if question not in queries:
|
| 96 |
+
queries.insert(0, question)
|
| 97 |
+
print("Generated Queries:", queries)
|
| 98 |
+
return queries
|
| 99 |
+
|
| 100 |
+
logger.warning(f"Failed to parse JSON for query expansion. Returning original query. Output was: {output}")
|
| 101 |
+
return [question]
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.warning(f"Error during query expansion: {e}")
|
| 104 |
+
return [question]
|
| 105 |
+
|
| 106 |
+
def _generate_with_model(self, text: str, is_json: bool = False) -> str:
|
| 107 |
+
# Calls the centralized Groq API generation method
|
| 108 |
+
return self.generator.generate_direct(text, max_tokens=300, is_json=is_json)
|
src/bio_rag/retriever.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List, Dict
|
| 5 |
+
|
| 6 |
+
from langchain_community.vectorstores import FAISS
|
| 7 |
+
from langchain_core.documents import Document
|
| 8 |
+
from rank_bm25 import BM25Okapi
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class RetrievedPassage:
|
| 13 |
+
rank: int
|
| 14 |
+
score: float
|
| 15 |
+
qid: str
|
| 16 |
+
text: str
|
| 17 |
+
source_question: str
|
| 18 |
+
source_answer: str
|
| 19 |
+
authors: str = ""
|
| 20 |
+
year: str = ""
|
| 21 |
+
journal: str = ""
|
| 22 |
+
title: str = ""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class BioRetriever:
|
| 26 |
+
def __init__(self, vectorstore: FAISS, top_k: int = 10) -> None:
|
| 27 |
+
self.vectorstore = vectorstore
|
| 28 |
+
self.top_k = top_k
|
| 29 |
+
|
| 30 |
+
# Build BM25 index on initialization and store the mapping of documents
|
| 31 |
+
self._docs = list(self.vectorstore.docstore._dict.values())
|
| 32 |
+
corpus = [doc.page_content.lower().split() for doc in self._docs]
|
| 33 |
+
self.bm25 = BM25Okapi(corpus)
|
| 34 |
+
|
| 35 |
+
def retrieve(self, query_or_queries: str | List[str]) -> list[RetrievedPassage]:
|
| 36 |
+
# Handle both single query string or multiple expanded variants
|
| 37 |
+
queries = [query_or_queries] if isinstance(query_or_queries, str) else query_or_queries
|
| 38 |
+
|
| 39 |
+
# Store ranks for RRF. Key: doc_id (using index in self._docs or text as fallback)
|
| 40 |
+
rrf_scores: Dict[str, float] = {}
|
| 41 |
+
doc_store: Dict[str, Document] = {}
|
| 42 |
+
|
| 43 |
+
for query in queries:
|
| 44 |
+
# 1. Sparse Retrieval (BM25)
|
| 45 |
+
tokenized_query = query.lower().split()
|
| 46 |
+
bm25_scores = self.bm25.get_scores(tokenized_query)
|
| 47 |
+
# Get top_k from BM25
|
| 48 |
+
bm25_top_indices = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:self.top_k]
|
| 49 |
+
|
| 50 |
+
for rank, idx in enumerate(bm25_top_indices, start=1):
|
| 51 |
+
doc = self._docs[idx]
|
| 52 |
+
# Combine qid and part of text to create unique id
|
| 53 |
+
doc_id = doc.metadata.get("qid", "") + "_" + doc.page_content[:50]
|
| 54 |
+
doc_store[doc_id] = doc
|
| 55 |
+
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (rank + 60))
|
| 56 |
+
|
| 57 |
+
# 2. Dense Retrieval (FAISS)
|
| 58 |
+
dense_docs_scores = self.vectorstore.similarity_search_with_score(query, k=self.top_k)
|
| 59 |
+
# Filter out irrelevant results — L2 distance > 250.0 means too dissimilar
|
| 60 |
+
dense_docs_scores = [(doc, score) for doc, score in dense_docs_scores if score < 250.0]
|
| 61 |
+
for rank, (doc, _score) in enumerate(dense_docs_scores, start=1):
|
| 62 |
+
doc_id = doc.metadata.get("qid", "") + "_" + doc.page_content[:50]
|
| 63 |
+
doc_store[doc_id] = doc
|
| 64 |
+
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (rank + 60))
|
| 65 |
+
|
| 66 |
+
# Sort documents by their RRF score
|
| 67 |
+
ranked_docs = sorted(rrf_scores.items(), key=lambda item: item[1], reverse=True)
|
| 68 |
+
|
| 69 |
+
passages: list[RetrievedPassage] = []
|
| 70 |
+
for i, (doc_id, score) in enumerate(ranked_docs[:self.top_k], start=1):
|
| 71 |
+
doc = doc_store[doc_id]
|
| 72 |
+
passages.append(
|
| 73 |
+
RetrievedPassage(
|
| 74 |
+
rank=i,
|
| 75 |
+
score=float(score),
|
| 76 |
+
qid=str(doc.metadata.get("qid", "")),
|
| 77 |
+
text=doc.page_content,
|
| 78 |
+
source_question=str(doc.metadata.get("question", "")),
|
| 79 |
+
source_answer=str(doc.metadata.get("answer", "")),
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
return passages
|
src/bio_rag/risk_scorer.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import logging
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class RiskProfile:
|
| 9 |
+
severity: float
|
| 10 |
+
type_score: float
|
| 11 |
+
omission: float
|
| 12 |
+
|
| 13 |
+
class RiskScorer:
|
| 14 |
+
"""Implement rule-based severity classifiers for medical statements."""
|
| 15 |
+
|
| 16 |
+
HIGH_SEVERITY_KEYWORDS = {
|
| 17 |
+
"dosage", "dose", "mg", "units/kg", "contraindicated",
|
| 18 |
+
"hypoglycemia", "ketoacidosis", "renal failure", "dialysis",
|
| 19 |
+
"surgery", "emergency", "fatal", "toxic", "lactic acidosis",
|
| 20 |
+
"gfr", "egfr", "creatinine", "nephropathy",
|
| 21 |
+
"insulin dose", "insulin dosage", "insulin regimen",
|
| 22 |
+
"insulin therapy", "insulin administration",
|
| 23 |
+
"drug interaction", "overdose",
|
| 24 |
+
"discontinue insulin", "stop insulin", "stopping insulin",
|
| 25 |
+
"discontinue therapy", "stop taking insulin",
|
| 26 |
+
"severe renal",
|
| 27 |
+
"glipizide", "glimepiride", "sulfonylurea", "pioglitazone",
|
| 28 |
+
"sitagliptin", "dapagliflozin", "empagliflozin", "liraglutide", "semaglutide",
|
| 29 |
+
"primary treatment", "first-line", "first line", "drug of choice",
|
| 30 |
+
"treatment of choice", "recommended treatment"
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# These terms sound medical but are general concepts, not dangerous claims
|
| 34 |
+
SEVERITY_EXCEPTIONS = {
|
| 35 |
+
"insulin sensitivity", "insulin resistance", "insulin secretion",
|
| 36 |
+
"insulin signaling", "insulin receptor", "insulin levels",
|
| 37 |
+
"kidney function", "renal function",
|
| 38 |
+
"dose adjustment", "dose adjusted", "adjust the dose",
|
| 39 |
+
"careful monitoring", "close monitoring", "closely monitored",
|
| 40 |
+
"dose-dependent", "dosage adjustment",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
MED_SEVERITY_KEYWORDS = {
|
| 44 |
+
"diet", "hba1c", "monitoring", "lifestyle", "exercise",
|
| 45 |
+
"target", "frequency", "guidance"
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
def calculate_profile(self, claim: str) -> RiskProfile:
|
| 49 |
+
claim_lower = claim.lower()
|
| 50 |
+
|
| 51 |
+
# 1. Severity
|
| 52 |
+
severity = 0.3 # Base Low
|
| 53 |
+
# Check exceptions first
|
| 54 |
+
has_exception = any(w in claim_lower for w in self.SEVERITY_EXCEPTIONS)
|
| 55 |
+
if not has_exception and any(w in claim_lower for w in self.HIGH_SEVERITY_KEYWORDS):
|
| 56 |
+
severity = 1.0
|
| 57 |
+
elif any(w in claim_lower for w in self.MED_SEVERITY_KEYWORDS):
|
| 58 |
+
severity = 0.7
|
| 59 |
+
elif has_exception:
|
| 60 |
+
severity = 0.5
|
| 61 |
+
else:
|
| 62 |
+
if "cure" in claim_lower:
|
| 63 |
+
severity = 0.8
|
| 64 |
+
|
| 65 |
+
# 2. Type
|
| 66 |
+
fabrication_signals = ["causes", "proven", "always", "never", "cures", "eliminates", "guarantees", "completely safe", "no risk", "fully effective", "definitely"]
|
| 67 |
+
type_score = 1.0 if any(w in claim_lower for w in fabrication_signals) else 0.5
|
| 68 |
+
|
| 69 |
+
# 3. Omission
|
| 70 |
+
omission_signals = ["not recommended", "avoid", "contraindicated", "warning", "caution", "do not", "should not"]
|
| 71 |
+
omission = 1.0 if any(w in claim_lower for w in omission_signals) else 0.5
|
| 72 |
+
|
| 73 |
+
return RiskProfile(
|
| 74 |
+
severity=severity,
|
| 75 |
+
type_score=type_score,
|
| 76 |
+
omission=omission
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
def compute_weighted_risk(self, nli_prob: float, profile: RiskProfile) -> float:
|
| 80 |
+
"""Risk-Weighted Score = NLI_Probability x (Severity x Type x Omission)"""
|
| 81 |
+
nli_adjusted = min(1.0, nli_prob * 2.0)
|
| 82 |
+
|
| 83 |
+
is_unverified = abs(nli_prob - 0.8501) < 0.0001
|
| 84 |
+
is_genuine_contradiction = nli_prob >= 0.7 and not is_unverified
|
| 85 |
+
|
| 86 |
+
if is_genuine_contradiction:
|
| 87 |
+
# Evidence actively contradicts this claim — assume worst case
|
| 88 |
+
effective_type = 1.0
|
| 89 |
+
effective_omission = 1.0
|
| 90 |
+
elif is_unverified and profile.severity >= 1.0:
|
| 91 |
+
# No evidence found + HIGH severity = assume worst case
|
| 92 |
+
effective_type = 1.0
|
| 93 |
+
effective_omission = 1.0
|
| 94 |
+
else:
|
| 95 |
+
effective_type = profile.type_score
|
| 96 |
+
effective_omission = profile.omission
|
| 97 |
+
|
| 98 |
+
return min(1.0, nli_adjusted * (profile.severity * effective_type * effective_omission))
|
static/css/style.css
ADDED
|
@@ -0,0 +1,1345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================
|
| 2 |
+
Bio-RAG — Dark Theme Medical UI
|
| 3 |
+
============================================ */
|
| 4 |
+
|
| 5 |
+
/* --- CSS Variables --- */
|
| 6 |
+
:root {
|
| 7 |
+
--bg-primary: #0a0a0f;
|
| 8 |
+
--bg-secondary: #12121a;
|
| 9 |
+
--bg-sidebar: #0e0e16;
|
| 10 |
+
--bg-header: rgba(10, 10, 15, 0.8);
|
| 11 |
+
--bg-input: #16161f;
|
| 12 |
+
--bg-user-msg: #1a2a42;
|
| 13 |
+
--bg-hover: rgba(255, 255, 255, 0.04);
|
| 14 |
+
--bg-card: rgba(255, 255, 255, 0.03);
|
| 15 |
+
--bg-card-hover: rgba(255, 255, 255, 0.07);
|
| 16 |
+
--bg-safe: rgba(46, 204, 113, 0.06);
|
| 17 |
+
--bg-flagged: rgba(231, 76, 60, 0.06);
|
| 18 |
+
|
| 19 |
+
--text-primary: #e8e8ed;
|
| 20 |
+
--text-secondary: #8b8b9e;
|
| 21 |
+
--text-muted: #55556a;
|
| 22 |
+
--text-user: #ffffff;
|
| 23 |
+
|
| 24 |
+
--accent: #4a9eff;
|
| 25 |
+
--accent-hover: #6bb3ff;
|
| 26 |
+
--safe: #2ecc71;
|
| 27 |
+
--flagged: #e74c3c;
|
| 28 |
+
--warning: #f39c12;
|
| 29 |
+
|
| 30 |
+
--border: rgba(255, 255, 255, 0.06);
|
| 31 |
+
--border-light: rgba(255, 255, 255, 0.1);
|
| 32 |
+
--border-input: rgba(255, 255, 255, 0.12);
|
| 33 |
+
|
| 34 |
+
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 35 |
+
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 36 |
+
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
| 37 |
+
|
| 38 |
+
--sidebar-width: 260px;
|
| 39 |
+
--header-height: 56px;
|
| 40 |
+
--chat-max-width: 800px;
|
| 41 |
+
--input-max-width: 800px;
|
| 42 |
+
|
| 43 |
+
--font-body: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 44 |
+
--font-mono: 'IBM Plex Mono', 'Consolas', monospace;
|
| 45 |
+
|
| 46 |
+
--radius-sm: 8px;
|
| 47 |
+
--radius-md: 12px;
|
| 48 |
+
--radius-lg: 18px;
|
| 49 |
+
--radius-pill: 24px;
|
| 50 |
+
--radius-round: 50%;
|
| 51 |
+
|
| 52 |
+
--transition-fast: 150ms ease;
|
| 53 |
+
--transition-normal: 250ms ease;
|
| 54 |
+
--transition-slow: 400ms ease;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* --- Reset & Base --- */
|
| 58 |
+
*, *::before, *::after {
|
| 59 |
+
margin: 0;
|
| 60 |
+
padding: 0;
|
| 61 |
+
box-sizing: border-box;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
html {
|
| 65 |
+
font-size: 15px;
|
| 66 |
+
-webkit-font-smoothing: antialiased;
|
| 67 |
+
-moz-osx-font-smoothing: grayscale;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
body {
|
| 71 |
+
font-family: var(--font-body);
|
| 72 |
+
background: var(--bg-primary);
|
| 73 |
+
color: var(--text-primary);
|
| 74 |
+
line-height: 1.65;
|
| 75 |
+
display: flex;
|
| 76 |
+
height: 100vh;
|
| 77 |
+
overflow: hidden;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
button {
|
| 81 |
+
font-family: inherit;
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
border: none;
|
| 84 |
+
background: none;
|
| 85 |
+
color: inherit;
|
| 86 |
+
font-size: inherit;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
textarea {
|
| 90 |
+
font-family: inherit;
|
| 91 |
+
font-size: inherit;
|
| 92 |
+
color: inherit;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* --- Scrollbar --- */
|
| 96 |
+
::-webkit-scrollbar {
|
| 97 |
+
width: 5px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
::-webkit-scrollbar-track {
|
| 101 |
+
background: transparent;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
::-webkit-scrollbar-thumb {
|
| 105 |
+
background: rgba(255, 255, 255, 0.1);
|
| 106 |
+
border-radius: 10px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
::-webkit-scrollbar-thumb:hover {
|
| 110 |
+
background: rgba(255, 255, 255, 0.18);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* ============================================
|
| 114 |
+
SIDEBAR
|
| 115 |
+
============================================ */
|
| 116 |
+
.sidebar {
|
| 117 |
+
width: var(--sidebar-width);
|
| 118 |
+
height: 100vh;
|
| 119 |
+
background: var(--bg-sidebar);
|
| 120 |
+
border-right: 1px solid var(--border);
|
| 121 |
+
display: flex;
|
| 122 |
+
flex-direction: column;
|
| 123 |
+
flex-shrink: 0;
|
| 124 |
+
transition: transform var(--transition-normal), width var(--transition-normal);
|
| 125 |
+
z-index: 100;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.sidebar.collapsed {
|
| 129 |
+
transform: translateX(-100%);
|
| 130 |
+
width: 0;
|
| 131 |
+
border: none;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.sidebar.collapsed .sidebar-header {
|
| 135 |
+
display: none;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.sidebar-header {
|
| 139 |
+
padding: 12px;
|
| 140 |
+
display: flex;
|
| 141 |
+
align-items: center;
|
| 142 |
+
gap: 8px;
|
| 143 |
+
border-bottom: 1px solid var(--border);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.sidebar-toggle {
|
| 147 |
+
width: 36px;
|
| 148 |
+
height: 36px;
|
| 149 |
+
display: flex;
|
| 150 |
+
align-items: center;
|
| 151 |
+
justify-content: center;
|
| 152 |
+
border-radius: var(--radius-sm);
|
| 153 |
+
color: var(--text-secondary);
|
| 154 |
+
transition: background var(--transition-fast), color var(--transition-fast);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.sidebar-toggle:hover {
|
| 158 |
+
background: var(--bg-hover);
|
| 159 |
+
color: var(--text-primary);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.new-chat-btn {
|
| 163 |
+
flex: 1;
|
| 164 |
+
display: flex;
|
| 165 |
+
align-items: center;
|
| 166 |
+
justify-content: center;
|
| 167 |
+
gap: 6px;
|
| 168 |
+
padding: 8px 14px;
|
| 169 |
+
border-radius: var(--radius-sm);
|
| 170 |
+
border: 1px solid var(--border-light);
|
| 171 |
+
color: var(--text-secondary);
|
| 172 |
+
font-size: 0.85rem;
|
| 173 |
+
font-weight: 500;
|
| 174 |
+
transition: all var(--transition-fast);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.new-chat-btn:hover {
|
| 178 |
+
background: var(--bg-hover);
|
| 179 |
+
color: var(--text-primary);
|
| 180 |
+
border-color: var(--border-light);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.sidebar-history {
|
| 184 |
+
flex: 1;
|
| 185 |
+
overflow-y: auto;
|
| 186 |
+
padding: 12px 8px;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.history-section-title {
|
| 190 |
+
font-size: 0.7rem;
|
| 191 |
+
font-weight: 600;
|
| 192 |
+
color: var(--text-muted);
|
| 193 |
+
text-transform: uppercase;
|
| 194 |
+
letter-spacing: 1.2px;
|
| 195 |
+
padding: 8px 12px 4px;
|
| 196 |
+
margin-top: 8px;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.history-item {
|
| 200 |
+
display: block;
|
| 201 |
+
width: 100%;
|
| 202 |
+
text-align: left;
|
| 203 |
+
padding: 10px 12px;
|
| 204 |
+
border-radius: var(--radius-sm);
|
| 205 |
+
color: var(--text-secondary);
|
| 206 |
+
font-size: 0.85rem;
|
| 207 |
+
white-space: nowrap;
|
| 208 |
+
overflow: hidden;
|
| 209 |
+
text-overflow: ellipsis;
|
| 210 |
+
transition: background var(--transition-fast), color var(--transition-fast);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.history-item:hover {
|
| 214 |
+
background: var(--bg-hover);
|
| 215 |
+
color: var(--text-primary);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.history-item.active {
|
| 219 |
+
background: var(--bg-card-hover);
|
| 220 |
+
color: var(--text-primary);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.sidebar-footer {
|
| 224 |
+
padding: 12px;
|
| 225 |
+
border-top: 1px solid var(--border);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.sidebar-badge {
|
| 229 |
+
display: flex;
|
| 230 |
+
align-items: center;
|
| 231 |
+
gap: 8px;
|
| 232 |
+
font-size: 0.75rem;
|
| 233 |
+
color: var(--text-muted);
|
| 234 |
+
padding: 8px 12px;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.badge-dot {
|
| 238 |
+
width: 6px;
|
| 239 |
+
height: 6px;
|
| 240 |
+
background: var(--safe);
|
| 241 |
+
border-radius: var(--radius-round);
|
| 242 |
+
animation: pulse-dot 2s infinite;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
@keyframes pulse-dot {
|
| 246 |
+
0%, 100% { opacity: 1; }
|
| 247 |
+
50% { opacity: 0.4; }
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* ============================================
|
| 251 |
+
MAIN AREA
|
| 252 |
+
============================================ */
|
| 253 |
+
.main {
|
| 254 |
+
flex: 1;
|
| 255 |
+
display: flex;
|
| 256 |
+
flex-direction: column;
|
| 257 |
+
height: 100vh;
|
| 258 |
+
min-width: 0;
|
| 259 |
+
position: relative;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/* --- HEADER --- */
|
| 263 |
+
.header {
|
| 264 |
+
height: var(--header-height);
|
| 265 |
+
display: flex;
|
| 266 |
+
align-items: center;
|
| 267 |
+
justify-content: space-between;
|
| 268 |
+
padding: 0 16px;
|
| 269 |
+
background: var(--bg-header);
|
| 270 |
+
backdrop-filter: blur(12px);
|
| 271 |
+
-webkit-backdrop-filter: blur(12px);
|
| 272 |
+
border-bottom: 1px solid var(--border);
|
| 273 |
+
position: sticky;
|
| 274 |
+
top: 0;
|
| 275 |
+
z-index: 50;
|
| 276 |
+
flex-shrink: 0;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.header-left {
|
| 280 |
+
display: flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
gap: 12px;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.header-menu-btn {
|
| 286 |
+
display: none;
|
| 287 |
+
width: 36px;
|
| 288 |
+
height: 36px;
|
| 289 |
+
align-items: center;
|
| 290 |
+
justify-content: center;
|
| 291 |
+
border-radius: var(--radius-sm);
|
| 292 |
+
color: var(--text-secondary);
|
| 293 |
+
transition: all var(--transition-fast);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.header-menu-btn:hover {
|
| 297 |
+
background: var(--bg-hover);
|
| 298 |
+
color: var(--text-primary);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.sidebar.collapsed ~ .main .header-menu-btn {
|
| 302 |
+
display: flex;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.header-brand {
|
| 306 |
+
display: flex;
|
| 307 |
+
align-items: center;
|
| 308 |
+
gap: 8px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.brand-icon {
|
| 312 |
+
font-size: 1.4rem;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.brand-name {
|
| 316 |
+
font-weight: 700;
|
| 317 |
+
font-size: 1.05rem;
|
| 318 |
+
color: var(--text-primary);
|
| 319 |
+
letter-spacing: -0.3px;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.brand-tag {
|
| 323 |
+
font-size: 0.72rem;
|
| 324 |
+
color: var(--text-muted);
|
| 325 |
+
background: var(--bg-card);
|
| 326 |
+
padding: 2px 8px;
|
| 327 |
+
border-radius: var(--radius-sm);
|
| 328 |
+
border: 1px solid var(--border);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.header-right {
|
| 332 |
+
display: flex;
|
| 333 |
+
align-items: center;
|
| 334 |
+
gap: 4px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.header-btn {
|
| 338 |
+
width: 36px;
|
| 339 |
+
height: 36px;
|
| 340 |
+
display: flex;
|
| 341 |
+
align-items: center;
|
| 342 |
+
justify-content: center;
|
| 343 |
+
border-radius: var(--radius-sm);
|
| 344 |
+
color: var(--text-secondary);
|
| 345 |
+
transition: all var(--transition-fast);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.header-btn:hover {
|
| 349 |
+
background: var(--bg-hover);
|
| 350 |
+
color: var(--text-primary);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* ============================================
|
| 354 |
+
CHAT AREA
|
| 355 |
+
============================================ */
|
| 356 |
+
.chat-area {
|
| 357 |
+
flex: 1;
|
| 358 |
+
overflow-y: auto;
|
| 359 |
+
padding: 0 16px;
|
| 360 |
+
scroll-behavior: smooth;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/* --- WELCOME SCREEN --- */
|
| 364 |
+
.welcome {
|
| 365 |
+
display: flex;
|
| 366 |
+
flex-direction: column;
|
| 367 |
+
align-items: center;
|
| 368 |
+
justify-content: center;
|
| 369 |
+
min-height: calc(100vh - var(--header-height) - 120px);
|
| 370 |
+
text-align: center;
|
| 371 |
+
padding: 40px 20px;
|
| 372 |
+
animation: fadeIn 0.6s ease;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.welcome-icon {
|
| 376 |
+
font-size: 3.5rem;
|
| 377 |
+
margin-bottom: 12px;
|
| 378 |
+
filter: drop-shadow(0 0 20px rgba(74, 158, 255, 0.2));
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.welcome-title {
|
| 382 |
+
font-size: 2rem;
|
| 383 |
+
font-weight: 700;
|
| 384 |
+
color: var(--text-primary);
|
| 385 |
+
letter-spacing: -0.5px;
|
| 386 |
+
margin-bottom: 6px;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.welcome-subtitle {
|
| 390 |
+
font-size: 0.95rem;
|
| 391 |
+
color: var(--text-secondary);
|
| 392 |
+
margin-bottom: 8px;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.welcome-desc {
|
| 396 |
+
font-size: 0.82rem;
|
| 397 |
+
color: var(--text-muted);
|
| 398 |
+
max-width: 500px;
|
| 399 |
+
line-height: 1.7;
|
| 400 |
+
margin-bottom: 36px;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.suggestions {
|
| 404 |
+
display: grid;
|
| 405 |
+
grid-template-columns: 1fr 1fr;
|
| 406 |
+
gap: 10px;
|
| 407 |
+
max-width: 560px;
|
| 408 |
+
width: 100%;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.suggestion-card {
|
| 412 |
+
display: flex;
|
| 413 |
+
align-items: flex-start;
|
| 414 |
+
gap: 10px;
|
| 415 |
+
padding: 14px 16px;
|
| 416 |
+
background: var(--bg-card);
|
| 417 |
+
border: 1px solid var(--border);
|
| 418 |
+
border-radius: var(--radius-md);
|
| 419 |
+
text-align: left;
|
| 420 |
+
color: var(--text-secondary);
|
| 421 |
+
font-size: 0.82rem;
|
| 422 |
+
line-height: 1.5;
|
| 423 |
+
transition: all var(--transition-normal);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.suggestion-card:hover {
|
| 427 |
+
background: var(--bg-card-hover);
|
| 428 |
+
border-color: var(--border-light);
|
| 429 |
+
color: var(--text-primary);
|
| 430 |
+
transform: translateY(-1px);
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.suggestion-icon {
|
| 434 |
+
font-size: 1.1rem;
|
| 435 |
+
flex-shrink: 0;
|
| 436 |
+
margin-top: 1px;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.suggestion-text {
|
| 440 |
+
flex: 1;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/* --- MESSAGES --- */
|
| 444 |
+
.messages {
|
| 445 |
+
max-width: var(--chat-max-width);
|
| 446 |
+
margin: 0 auto;
|
| 447 |
+
padding: 24px 0 140px;
|
| 448 |
+
width: 100%;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.messages:empty {
|
| 452 |
+
display: none;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
/* --- USER MESSAGE --- */
|
| 456 |
+
.msg-user {
|
| 457 |
+
display: flex;
|
| 458 |
+
justify-content: flex-end;
|
| 459 |
+
margin-bottom: 24px;
|
| 460 |
+
animation: msgIn 0.25s ease-out;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.msg-user-bubble {
|
| 464 |
+
max-width: 75%;
|
| 465 |
+
background: var(--bg-user-msg);
|
| 466 |
+
color: var(--text-user);
|
| 467 |
+
padding: 12px 18px;
|
| 468 |
+
border-radius: var(--radius-lg) var(--radius-lg) 4px var(--radius-lg);
|
| 469 |
+
font-size: 0.92rem;
|
| 470 |
+
line-height: 1.6;
|
| 471 |
+
word-wrap: break-word;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/* --- BOT MESSAGE --- */
|
| 475 |
+
.msg-bot {
|
| 476 |
+
display: flex;
|
| 477 |
+
align-items: flex-start;
|
| 478 |
+
gap: 12px;
|
| 479 |
+
margin-bottom: 28px;
|
| 480 |
+
animation: msgIn 0.25s ease-out;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.msg-bot-avatar {
|
| 484 |
+
width: 32px;
|
| 485 |
+
height: 32px;
|
| 486 |
+
display: flex;
|
| 487 |
+
align-items: center;
|
| 488 |
+
justify-content: center;
|
| 489 |
+
font-size: 1.2rem;
|
| 490 |
+
flex-shrink: 0;
|
| 491 |
+
margin-top: 2px;
|
| 492 |
+
background: var(--bg-card);
|
| 493 |
+
border-radius: var(--radius-sm);
|
| 494 |
+
border: 1px solid var(--border);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.msg-bot-content {
|
| 498 |
+
flex: 1;
|
| 499 |
+
min-width: 0;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.msg-bot-text {
|
| 503 |
+
color: var(--text-primary);
|
| 504 |
+
font-size: 0.92rem;
|
| 505 |
+
line-height: 1.75;
|
| 506 |
+
word-wrap: break-word;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.msg-bot-text p {
|
| 510 |
+
margin-bottom: 10px;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.msg-bot-text p:last-child {
|
| 514 |
+
margin-bottom: 0;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
/* --- TYPING CURSOR --- */
|
| 518 |
+
.cursor {
|
| 519 |
+
display: inline-block;
|
| 520 |
+
width: 2px;
|
| 521 |
+
height: 1em;
|
| 522 |
+
background: var(--accent);
|
| 523 |
+
margin-left: 2px;
|
| 524 |
+
vertical-align: text-bottom;
|
| 525 |
+
animation: blink 0.8s step-end infinite;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
@keyframes blink {
|
| 529 |
+
50% { opacity: 0; }
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
/* ============================================
|
| 533 |
+
THINKING STATE
|
| 534 |
+
============================================ */
|
| 535 |
+
.thinking {
|
| 536 |
+
display: flex;
|
| 537 |
+
align-items: flex-start;
|
| 538 |
+
gap: 12px;
|
| 539 |
+
margin-bottom: 28px;
|
| 540 |
+
animation: msgIn 0.25s ease-out;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.thinking-content {
|
| 544 |
+
display: flex;
|
| 545 |
+
flex-direction: column;
|
| 546 |
+
gap: 8px;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.thinking-dots {
|
| 550 |
+
display: flex;
|
| 551 |
+
gap: 4px;
|
| 552 |
+
padding: 4px 0;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.thinking-dot {
|
| 556 |
+
width: 7px;
|
| 557 |
+
height: 7px;
|
| 558 |
+
background: var(--text-muted);
|
| 559 |
+
border-radius: var(--radius-round);
|
| 560 |
+
animation: pulseDot 1.2s ease-in-out infinite;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.thinking-dot:nth-child(2) { animation-delay: 0.15s; }
|
| 564 |
+
.thinking-dot:nth-child(3) { animation-delay: 0.3s; }
|
| 565 |
+
|
| 566 |
+
@keyframes pulseDot {
|
| 567 |
+
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
|
| 568 |
+
40% { transform: scale(1); opacity: 1; }
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.thinking-step {
|
| 572 |
+
display: flex;
|
| 573 |
+
align-items: center;
|
| 574 |
+
gap: 8px;
|
| 575 |
+
font-size: 0.8rem;
|
| 576 |
+
color: var(--text-muted);
|
| 577 |
+
animation: fadeIn 0.4s ease;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.thinking-step.done {
|
| 581 |
+
color: var(--safe);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.thinking-step.active {
|
| 585 |
+
color: var(--text-secondary);
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
/* ============================================
|
| 589 |
+
VERIFICATION PANEL
|
| 590 |
+
============================================ */
|
| 591 |
+
.verification-panel {
|
| 592 |
+
margin-top: 16px;
|
| 593 |
+
border-radius: var(--radius-md);
|
| 594 |
+
border: 1px solid var(--border);
|
| 595 |
+
overflow: hidden;
|
| 596 |
+
background: var(--bg-card);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.verification-panel.safe {
|
| 600 |
+
border-left: 3px solid var(--safe);
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.verification-panel.flagged {
|
| 604 |
+
border-left: 3px solid var(--flagged);
|
| 605 |
+
background: var(--bg-flagged);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
.verification-summary {
|
| 609 |
+
display: flex;
|
| 610 |
+
align-items: center;
|
| 611 |
+
justify-content: space-between;
|
| 612 |
+
padding: 12px 16px;
|
| 613 |
+
cursor: pointer;
|
| 614 |
+
transition: background var(--transition-fast);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.verification-summary:hover {
|
| 618 |
+
background: var(--bg-hover);
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.verification-info {
|
| 622 |
+
display: flex;
|
| 623 |
+
flex-direction: column;
|
| 624 |
+
gap: 2px;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.verification-status {
|
| 628 |
+
font-size: 0.88rem;
|
| 629 |
+
font-weight: 600;
|
| 630 |
+
display: flex;
|
| 631 |
+
align-items: center;
|
| 632 |
+
gap: 6px;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.verification-status.safe { color: var(--safe); }
|
| 636 |
+
.verification-status.flagged { color: var(--flagged); }
|
| 637 |
+
|
| 638 |
+
.verification-meta {
|
| 639 |
+
font-size: 0.75rem;
|
| 640 |
+
color: var(--text-muted);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.verification-toggle {
|
| 644 |
+
font-size: 0.78rem;
|
| 645 |
+
color: var(--text-muted);
|
| 646 |
+
display: flex;
|
| 647 |
+
align-items: center;
|
| 648 |
+
gap: 4px;
|
| 649 |
+
white-space: nowrap;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.verification-details {
|
| 653 |
+
max-height: 0;
|
| 654 |
+
overflow: hidden;
|
| 655 |
+
transition: max-height 0.4s ease;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.verification-details.open {
|
| 659 |
+
max-height: 2000px;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
.verification-details-inner {
|
| 663 |
+
padding: 0 16px 16px;
|
| 664 |
+
border-top: 1px solid var(--border);
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.claims-title {
|
| 668 |
+
font-size: 0.78rem;
|
| 669 |
+
font-weight: 600;
|
| 670 |
+
color: var(--text-muted);
|
| 671 |
+
text-transform: uppercase;
|
| 672 |
+
letter-spacing: 1px;
|
| 673 |
+
margin: 14px 0 10px;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
/* --- CLAIM ITEM --- */
|
| 677 |
+
.claim-item {
|
| 678 |
+
padding: 10px 0;
|
| 679 |
+
border-bottom: 1px solid var(--border);
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.claim-item:last-child {
|
| 683 |
+
border-bottom: none;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.claim-risk-bar-container {
|
| 687 |
+
display: flex;
|
| 688 |
+
align-items: center;
|
| 689 |
+
gap: 10px;
|
| 690 |
+
margin-bottom: 6px;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.claim-risk-bar {
|
| 694 |
+
flex: 1;
|
| 695 |
+
height: 4px;
|
| 696 |
+
background: rgba(255, 255, 255, 0.06);
|
| 697 |
+
border-radius: 2px;
|
| 698 |
+
overflow: hidden;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.claim-risk-bar-fill {
|
| 702 |
+
height: 100%;
|
| 703 |
+
border-radius: 2px;
|
| 704 |
+
transition: width 0.6s ease-out;
|
| 705 |
+
min-width: 1px;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.claim-risk-bar-fill.low { background: var(--safe); }
|
| 709 |
+
.claim-risk-bar-fill.medium { background: var(--warning); }
|
| 710 |
+
.claim-risk-bar-fill.high { background: var(--flagged); }
|
| 711 |
+
|
| 712 |
+
.claim-risk-value {
|
| 713 |
+
font-size: 0.72rem;
|
| 714 |
+
font-family: var(--font-mono);
|
| 715 |
+
color: var(--text-muted);
|
| 716 |
+
min-width: 42px;
|
| 717 |
+
text-align: right;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
.claim-text {
|
| 721 |
+
font-size: 0.82rem;
|
| 722 |
+
color: var(--text-secondary);
|
| 723 |
+
line-height: 1.55;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
/* --- EVIDENCE SECTION --- */
|
| 727 |
+
.evidence-title {
|
| 728 |
+
font-size: 0.78rem;
|
| 729 |
+
font-weight: 600;
|
| 730 |
+
color: var(--text-muted);
|
| 731 |
+
text-transform: uppercase;
|
| 732 |
+
letter-spacing: 1px;
|
| 733 |
+
margin: 18px 0 10px;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.evidence-item {
|
| 737 |
+
display: flex;
|
| 738 |
+
align-items: flex-start;
|
| 739 |
+
gap: 8px;
|
| 740 |
+
padding: 10px 12px;
|
| 741 |
+
background: rgba(255, 255, 255, 0.02);
|
| 742 |
+
border-radius: var(--radius-sm);
|
| 743 |
+
margin-bottom: 6px;
|
| 744 |
+
font-size: 0.8rem;
|
| 745 |
+
color: var(--text-secondary);
|
| 746 |
+
line-height: 1.5;
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.evidence-icon {
|
| 750 |
+
flex-shrink: 0;
|
| 751 |
+
margin-top: 1px;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.evidence-text {
|
| 755 |
+
flex: 1;
|
| 756 |
+
overflow: hidden;
|
| 757 |
+
display: -webkit-box;
|
| 758 |
+
-webkit-line-clamp: 2;
|
| 759 |
+
-webkit-box-orient: vertical;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
/* ============================================
|
| 763 |
+
INPUT BAR
|
| 764 |
+
============================================ */
|
| 765 |
+
.input-wrapper {
|
| 766 |
+
position: sticky;
|
| 767 |
+
bottom: 0;
|
| 768 |
+
z-index: 30;
|
| 769 |
+
flex-shrink: 0;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
.input-fade {
|
| 773 |
+
height: 50px;
|
| 774 |
+
background: linear-gradient(to bottom, transparent, var(--bg-primary));
|
| 775 |
+
pointer-events: none;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.input-bar {
|
| 779 |
+
background: var(--bg-primary);
|
| 780 |
+
padding: 0 16px 16px;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
.input-container {
|
| 784 |
+
max-width: var(--input-max-width);
|
| 785 |
+
margin: 0 auto;
|
| 786 |
+
display: flex;
|
| 787 |
+
align-items: flex-end;
|
| 788 |
+
gap: 8px;
|
| 789 |
+
background: var(--bg-input);
|
| 790 |
+
border: 1px solid var(--border-input);
|
| 791 |
+
border-radius: var(--radius-pill);
|
| 792 |
+
padding: 6px 6px 6px 20px;
|
| 793 |
+
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
.input-container:focus-within {
|
| 797 |
+
border-color: var(--accent);
|
| 798 |
+
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1);
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.input-field {
|
| 802 |
+
flex: 1;
|
| 803 |
+
background: transparent;
|
| 804 |
+
border: none;
|
| 805 |
+
outline: none;
|
| 806 |
+
color: var(--text-primary);
|
| 807 |
+
font-size: 0.92rem;
|
| 808 |
+
line-height: 1.5;
|
| 809 |
+
resize: none;
|
| 810 |
+
padding: 8px 0;
|
| 811 |
+
max-height: 120px;
|
| 812 |
+
overflow-y: auto;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.input-field::placeholder {
|
| 816 |
+
color: var(--text-muted);
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
.send-btn {
|
| 820 |
+
width: 36px;
|
| 821 |
+
height: 36px;
|
| 822 |
+
display: flex;
|
| 823 |
+
align-items: center;
|
| 824 |
+
justify-content: center;
|
| 825 |
+
border-radius: var(--radius-round);
|
| 826 |
+
background: var(--text-muted);
|
| 827 |
+
color: var(--bg-primary);
|
| 828 |
+
transition: all var(--transition-fast);
|
| 829 |
+
flex-shrink: 0;
|
| 830 |
+
opacity: 0.4;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.send-btn:not(:disabled) {
|
| 834 |
+
background: var(--accent);
|
| 835 |
+
opacity: 1;
|
| 836 |
+
cursor: pointer;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.send-btn:not(:disabled):hover {
|
| 840 |
+
background: var(--accent-hover);
|
| 841 |
+
transform: scale(1.05);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.send-btn:disabled {
|
| 845 |
+
cursor: not-allowed;
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
.input-disclaimer {
|
| 849 |
+
max-width: var(--input-max-width);
|
| 850 |
+
margin: 8px auto 0;
|
| 851 |
+
text-align: center;
|
| 852 |
+
font-size: 0.68rem;
|
| 853 |
+
color: var(--text-muted);
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
/* ============================================
|
| 857 |
+
ERROR MESSAGE
|
| 858 |
+
============================================ */
|
| 859 |
+
.msg-error {
|
| 860 |
+
display: flex;
|
| 861 |
+
align-items: flex-start;
|
| 862 |
+
gap: 12px;
|
| 863 |
+
margin-bottom: 28px;
|
| 864 |
+
animation: msgIn 0.25s ease-out;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.msg-error-content {
|
| 868 |
+
padding: 12px 16px;
|
| 869 |
+
background: var(--bg-flagged);
|
| 870 |
+
border: 1px solid rgba(231, 76, 60, 0.2);
|
| 871 |
+
border-radius: var(--radius-md);
|
| 872 |
+
color: var(--flagged);
|
| 873 |
+
font-size: 0.88rem;
|
| 874 |
+
line-height: 1.5;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
/* ============================================
|
| 878 |
+
REJECTION MESSAGE
|
| 879 |
+
============================================ */
|
| 880 |
+
.msg-rejection {
|
| 881 |
+
padding: 12px 16px;
|
| 882 |
+
background: rgba(243, 156, 18, 0.06);
|
| 883 |
+
border: 1px solid rgba(243, 156, 18, 0.15);
|
| 884 |
+
border-radius: var(--radius-md);
|
| 885 |
+
color: var(--warning);
|
| 886 |
+
font-size: 0.88rem;
|
| 887 |
+
line-height: 1.5;
|
| 888 |
+
display: flex;
|
| 889 |
+
align-items: flex-start;
|
| 890 |
+
gap: 8px;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
/* ============================================
|
| 894 |
+
ANIMATIONS
|
| 895 |
+
============================================ */
|
| 896 |
+
@keyframes fadeIn {
|
| 897 |
+
from { opacity: 0; }
|
| 898 |
+
to { opacity: 1; }
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
@keyframes msgIn {
|
| 902 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 903 |
+
to { opacity: 1; transform: translateY(0); }
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
/* ============================================
|
| 907 |
+
RESPONSIVE
|
| 908 |
+
============================================ */
|
| 909 |
+
@media (max-width: 1024px) {
|
| 910 |
+
.sidebar {
|
| 911 |
+
position: fixed;
|
| 912 |
+
left: 0;
|
| 913 |
+
top: 0;
|
| 914 |
+
transform: translateX(-100%);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.sidebar.open {
|
| 918 |
+
transform: translateX(0);
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.header-menu-btn {
|
| 922 |
+
display: flex !important;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.main {
|
| 926 |
+
width: 100%;
|
| 927 |
+
}
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
@media (max-width: 768px) {
|
| 931 |
+
.suggestions {
|
| 932 |
+
grid-template-columns: 1fr;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.msg-user-bubble {
|
| 936 |
+
max-width: 88%;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
.welcome-title {
|
| 940 |
+
font-size: 1.6rem;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.brand-tag {
|
| 944 |
+
display: none;
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
/* ============================================
|
| 949 |
+
INLINE RISK HIGHLIGHTING (Grammarly-style)
|
| 950 |
+
============================================ */
|
| 951 |
+
.risk-sentence {
|
| 952 |
+
position: relative;
|
| 953 |
+
cursor: help;
|
| 954 |
+
transition: background var(--transition-fast);
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.risk-sentence.risk-caution {
|
| 958 |
+
text-decoration: underline;
|
| 959 |
+
text-decoration-color: #f39c12;
|
| 960 |
+
text-decoration-style: wavy;
|
| 961 |
+
text-underline-offset: 4px;
|
| 962 |
+
text-decoration-thickness: 1px;
|
| 963 |
+
border-left: 2px solid #f39c12;
|
| 964 |
+
padding-left: 6px;
|
| 965 |
+
margin-left: 2px;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
.risk-sentence.risk-danger {
|
| 969 |
+
text-decoration: underline;
|
| 970 |
+
text-decoration-color: #e74c3c;
|
| 971 |
+
text-decoration-style: wavy;
|
| 972 |
+
text-underline-offset: 4px;
|
| 973 |
+
text-decoration-thickness: 1.5px;
|
| 974 |
+
border-left: 2px solid #e74c3c;
|
| 975 |
+
padding-left: 6px;
|
| 976 |
+
margin-left: 2px;
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.risk-sentence.risk-caution:hover {
|
| 980 |
+
background: rgba(243, 156, 18, 0.08);
|
| 981 |
+
border-radius: 3px;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.risk-sentence.risk-danger:hover {
|
| 985 |
+
background: rgba(231, 76, 60, 0.08);
|
| 986 |
+
border-radius: 3px;
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
/* Tooltip */
|
| 990 |
+
.risk-tooltip {
|
| 991 |
+
display: none;
|
| 992 |
+
position: absolute;
|
| 993 |
+
bottom: calc(100% + 8px);
|
| 994 |
+
left: 50%;
|
| 995 |
+
transform: translateX(-50%);
|
| 996 |
+
background: #1a1a2e;
|
| 997 |
+
border: 1px solid rgba(255,255,255,0.12);
|
| 998 |
+
border-radius: 8px;
|
| 999 |
+
padding: 8px 12px;
|
| 1000 |
+
font-size: 0.75rem;
|
| 1001 |
+
color: #e0e0e0;
|
| 1002 |
+
white-space: nowrap;
|
| 1003 |
+
z-index: 200;
|
| 1004 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
| 1005 |
+
pointer-events: none;
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
.risk-tooltip::after {
|
| 1009 |
+
content: '';
|
| 1010 |
+
position: absolute;
|
| 1011 |
+
top: 100%;
|
| 1012 |
+
left: 50%;
|
| 1013 |
+
transform: translateX(-50%);
|
| 1014 |
+
border: 5px solid transparent;
|
| 1015 |
+
border-top-color: #1a1a2e;
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
.risk-tooltip .tooltip-risk {
|
| 1019 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 1020 |
+
font-weight: 600;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.risk-tooltip .tooltip-risk.caution { color: #f39c12; }
|
| 1024 |
+
.risk-tooltip .tooltip-risk.danger { color: #e74c3c; }
|
| 1025 |
+
|
| 1026 |
+
.risk-sentence:hover .risk-tooltip {
|
| 1027 |
+
display: block;
|
| 1028 |
+
animation: fadeIn 0.15s ease;
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
/* Highlight animation on load */
|
| 1032 |
+
@keyframes highlightCaution {
|
| 1033 |
+
from { text-decoration-color: transparent; }
|
| 1034 |
+
to { text-decoration-color: #f39c12; }
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
@keyframes highlightDanger {
|
| 1038 |
+
from { text-decoration-color: transparent; }
|
| 1039 |
+
to { text-decoration-color: #e74c3c; }
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
.risk-sentence.risk-caution.animate-in {
|
| 1043 |
+
animation: highlightCaution 0.6s ease forwards;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
.risk-sentence.risk-danger.animate-in {
|
| 1047 |
+
animation: highlightDanger 0.6s ease forwards;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
/* ============================================
|
| 1051 |
+
DELETE CHAT FUNCTIONALITY
|
| 1052 |
+
============================================ */
|
| 1053 |
+
.history-item-wrapper {
|
| 1054 |
+
position: relative;
|
| 1055 |
+
display: flex;
|
| 1056 |
+
align-items: center;
|
| 1057 |
+
gap: 4px;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
.history-item-wrapper .history-item {
|
| 1061 |
+
flex: 1;
|
| 1062 |
+
min-width: 0;
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
.delete-chat-btn {
|
| 1066 |
+
opacity: 0;
|
| 1067 |
+
width: 28px;
|
| 1068 |
+
height: 28px;
|
| 1069 |
+
display: flex;
|
| 1070 |
+
align-items: center;
|
| 1071 |
+
justify-content: center;
|
| 1072 |
+
border-radius: var(--radius-sm);
|
| 1073 |
+
color: var(--text-muted);
|
| 1074 |
+
transition: all var(--transition-fast);
|
| 1075 |
+
flex-shrink: 0;
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
.history-item-wrapper:hover .delete-chat-btn {
|
| 1079 |
+
opacity: 1;
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
.delete-chat-btn:hover {
|
| 1083 |
+
background: rgba(231, 76, 60, 0.15);
|
| 1084 |
+
color: var(--flagged);
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.header-delete-btn {
|
| 1088 |
+
width: 36px;
|
| 1089 |
+
height: 36px;
|
| 1090 |
+
display: flex;
|
| 1091 |
+
align-items: center;
|
| 1092 |
+
justify-content: center;
|
| 1093 |
+
border-radius: var(--radius-sm);
|
| 1094 |
+
color: var(--text-secondary);
|
| 1095 |
+
transition: all var(--transition-fast);
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
.header-delete-btn:hover {
|
| 1099 |
+
background: rgba(231, 76, 60, 0.15);
|
| 1100 |
+
color: var(--flagged);
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
/* Delete confirmation modal */
|
| 1104 |
+
.delete-modal {
|
| 1105 |
+
position: fixed;
|
| 1106 |
+
top: 0;
|
| 1107 |
+
left: 0;
|
| 1108 |
+
right: 0;
|
| 1109 |
+
bottom: 0;
|
| 1110 |
+
background: rgba(0, 0, 0, 0.7);
|
| 1111 |
+
display: none;
|
| 1112 |
+
align-items: center;
|
| 1113 |
+
justify-content: center;
|
| 1114 |
+
z-index: 1000;
|
| 1115 |
+
animation: fadeIn 0.2s ease;
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
.delete-modal.show {
|
| 1119 |
+
display: flex;
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
.delete-modal-content {
|
| 1123 |
+
background: var(--bg-secondary);
|
| 1124 |
+
border: 1px solid var(--border-light);
|
| 1125 |
+
border-radius: var(--radius-md);
|
| 1126 |
+
padding: 24px;
|
| 1127 |
+
max-width: 400px;
|
| 1128 |
+
width: 90%;
|
| 1129 |
+
box-shadow: var(--shadow-lg);
|
| 1130 |
+
animation: msgIn 0.3s ease;
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
+
.delete-modal-title {
|
| 1134 |
+
font-size: 1.1rem;
|
| 1135 |
+
font-weight: 600;
|
| 1136 |
+
color: var(--text-primary);
|
| 1137 |
+
margin-bottom: 12px;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.delete-modal-text {
|
| 1141 |
+
font-size: 0.9rem;
|
| 1142 |
+
color: var(--text-secondary);
|
| 1143 |
+
line-height: 1.6;
|
| 1144 |
+
margin-bottom: 20px;
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
+
.delete-modal-actions {
|
| 1148 |
+
display: flex;
|
| 1149 |
+
gap: 8px;
|
| 1150 |
+
justify-content: flex-end;
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
.delete-modal-btn {
|
| 1154 |
+
padding: 8px 16px;
|
| 1155 |
+
border-radius: var(--radius-sm);
|
| 1156 |
+
font-size: 0.88rem;
|
| 1157 |
+
font-weight: 500;
|
| 1158 |
+
transition: all var(--transition-fast);
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
.delete-modal-btn.cancel {
|
| 1162 |
+
background: var(--bg-card);
|
| 1163 |
+
color: var(--text-secondary);
|
| 1164 |
+
border: 1px solid var(--border);
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
.delete-modal-btn.cancel:hover {
|
| 1168 |
+
background: var(--bg-hover);
|
| 1169 |
+
color: var(--text-primary);
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
.delete-modal-btn.confirm {
|
| 1173 |
+
background: var(--flagged);
|
| 1174 |
+
color: #ffffff;
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
.delete-modal-btn.confirm:hover {
|
| 1178 |
+
background: #c0392b;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
/* ============================================
|
| 1182 |
+
PIPELINE PROGRESS — Compact Inline
|
| 1183 |
+
============================================ */
|
| 1184 |
+
.pipeline-progress {
|
| 1185 |
+
display: inline-flex;
|
| 1186 |
+
align-items: center;
|
| 1187 |
+
gap: 6px;
|
| 1188 |
+
padding: 8px 14px;
|
| 1189 |
+
background: var(--bg-card);
|
| 1190 |
+
border: 1px solid var(--border);
|
| 1191 |
+
border-radius: var(--radius-pill);
|
| 1192 |
+
margin-bottom: 12px;
|
| 1193 |
+
animation: msgIn 0.3s ease-out;
|
| 1194 |
+
flex-wrap: wrap;
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
.pipeline-header {
|
| 1198 |
+
display: flex;
|
| 1199 |
+
align-items: center;
|
| 1200 |
+
gap: 6px;
|
| 1201 |
+
font-size: 0.7rem;
|
| 1202 |
+
font-weight: 600;
|
| 1203 |
+
color: var(--text-muted);
|
| 1204 |
+
text-transform: uppercase;
|
| 1205 |
+
letter-spacing: 0.8px;
|
| 1206 |
+
white-space: nowrap;
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
.pipeline-header-dot {
|
| 1210 |
+
width: 5px;
|
| 1211 |
+
height: 5px;
|
| 1212 |
+
border-radius: 50%;
|
| 1213 |
+
background: var(--accent);
|
| 1214 |
+
animation: pulseDot 1.2s ease-in-out infinite;
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
.pipeline-header-dot.done {
|
| 1218 |
+
background: var(--safe);
|
| 1219 |
+
animation: none;
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
.pipeline-phases {
|
| 1223 |
+
display: flex;
|
| 1224 |
+
align-items: center;
|
| 1225 |
+
gap: 3px;
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
.pipeline-phase {
|
| 1229 |
+
display: contents;
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
.pipeline-phase-title {
|
| 1233 |
+
display: none;
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
.pipeline-steps {
|
| 1237 |
+
display: flex;
|
| 1238 |
+
align-items: center;
|
| 1239 |
+
gap: 3px;
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
.pipeline-step {
|
| 1243 |
+
position: relative;
|
| 1244 |
+
transition: all 0.3s ease;
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.pipeline-step-icon {
|
| 1248 |
+
width: 14px;
|
| 1249 |
+
height: 14px;
|
| 1250 |
+
display: flex;
|
| 1251 |
+
align-items: center;
|
| 1252 |
+
justify-content: center;
|
| 1253 |
+
border-radius: 50%;
|
| 1254 |
+
font-size: 0.5rem;
|
| 1255 |
+
flex-shrink: 0;
|
| 1256 |
+
transition: all 0.3s ease;
|
| 1257 |
+
}
|
| 1258 |
+
|
| 1259 |
+
.pipeline-step-text {
|
| 1260 |
+
display: none;
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
.pipeline-step.pending .pipeline-step-icon {
|
| 1264 |
+
border: 1.5px solid var(--text-muted);
|
| 1265 |
+
opacity: 0.3;
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
.pipeline-step.active .pipeline-step-icon {
|
| 1269 |
+
border: 1.5px solid var(--accent);
|
| 1270 |
+
background: rgba(74, 158, 255, 0.15);
|
| 1271 |
+
animation: pulseDot 0.8s ease-in-out infinite;
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
.pipeline-step.done .pipeline-step-icon {
|
| 1275 |
+
border: none;
|
| 1276 |
+
background: var(--safe);
|
| 1277 |
+
color: #0a0a0f;
|
| 1278 |
+
font-weight: 700;
|
| 1279 |
+
animation: stepDone 0.3s ease;
|
| 1280 |
+
}
|
| 1281 |
+
|
| 1282 |
+
@keyframes stepDone {
|
| 1283 |
+
0% { transform: scale(0.5); }
|
| 1284 |
+
50% { transform: scale(1.3); }
|
| 1285 |
+
100% { transform: scale(1); }
|
| 1286 |
+
}
|
| 1287 |
+
|
| 1288 |
+
/* Tooltip on hover for each step */
|
| 1289 |
+
.pipeline-step .step-tooltip {
|
| 1290 |
+
display: none;
|
| 1291 |
+
position: absolute;
|
| 1292 |
+
bottom: calc(100% + 6px);
|
| 1293 |
+
left: 50%;
|
| 1294 |
+
transform: translateX(-50%);
|
| 1295 |
+
background: var(--bg-secondary);
|
| 1296 |
+
border: 1px solid var(--border-light);
|
| 1297 |
+
border-radius: 6px;
|
| 1298 |
+
padding: 4px 8px;
|
| 1299 |
+
font-size: 0.65rem;
|
| 1300 |
+
color: var(--text-secondary);
|
| 1301 |
+
white-space: nowrap;
|
| 1302 |
+
z-index: 100;
|
| 1303 |
+
pointer-events: none;
|
| 1304 |
+
}
|
| 1305 |
+
|
| 1306 |
+
.pipeline-step:hover .step-tooltip {
|
| 1307 |
+
display: block;
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
/* Phase separator */
|
| 1311 |
+
.pipeline-phase-sep {
|
| 1312 |
+
width: 1px;
|
| 1313 |
+
height: 12px;
|
| 1314 |
+
background: var(--border-light);
|
| 1315 |
+
margin: 0 4px;
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
.pipeline-complete {
|
| 1319 |
+
display: none;
|
| 1320 |
+
align-items: center;
|
| 1321 |
+
gap: 4px;
|
| 1322 |
+
font-size: 0.7rem;
|
| 1323 |
+
font-weight: 500;
|
| 1324 |
+
margin-left: 4px;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
.pipeline-complete.show {
|
| 1328 |
+
display: flex;
|
| 1329 |
+
animation: fadeIn 0.3s ease;
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
.pipeline-complete.safe { color: var(--safe); }
|
| 1333 |
+
.pipeline-complete.flagged { color: var(--flagged); }
|
| 1334 |
+
|
| 1335 |
+
.pipeline-complete-icon {
|
| 1336 |
+
font-size: 0.8rem;
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
.pipeline-current-label {
|
| 1340 |
+
font-size: 0.7rem;
|
| 1341 |
+
color: var(--text-secondary);
|
| 1342 |
+
margin-left: 4px;
|
| 1343 |
+
white-space: nowrap;
|
| 1344 |
+
animation: fadeIn 0.2s ease;
|
| 1345 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Bio-RAG — Medical Hallucination Detector</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
|
| 13 |
+
<!-- SIDEBAR -->
|
| 14 |
+
<aside class="sidebar" id="sidebar">
|
| 15 |
+
<div class="sidebar-header">
|
| 16 |
+
<button class="sidebar-toggle" id="sidebarToggle" title="Close sidebar">
|
| 17 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 18 |
+
<path d="M3 12h18M3 6h18M3 18h18"/>
|
| 19 |
+
</svg>
|
| 20 |
+
</button>
|
| 21 |
+
<button class="new-chat-btn" id="newChatBtn">
|
| 22 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 23 |
+
<path d="M12 5v14M5 12h14"/>
|
| 24 |
+
</svg>
|
| 25 |
+
New Chat
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div class="sidebar-history" id="sidebarHistory">
|
| 30 |
+
<!-- Chat history items will be injected here by JS -->
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div class="sidebar-footer">
|
| 34 |
+
<div class="sidebar-badge">
|
| 35 |
+
<span class="badge-dot"></span>
|
| 36 |
+
Diabetes Domain Only
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</aside>
|
| 40 |
+
|
| 41 |
+
<!-- MAIN CONTENT -->
|
| 42 |
+
<main class="main" id="main">
|
| 43 |
+
|
| 44 |
+
<!-- HEADER -->
|
| 45 |
+
<header class="header">
|
| 46 |
+
<div class="header-left">
|
| 47 |
+
<button class="header-menu-btn" id="menuBtn" title="Open sidebar">
|
| 48 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 49 |
+
<path d="M3 12h18M3 6h18M3 18h18"/>
|
| 50 |
+
</svg>
|
| 51 |
+
</button>
|
| 52 |
+
|
| 53 |
+
<div class="header-brand">
|
| 54 |
+
<span class="brand-icon">🧬</span>
|
| 55 |
+
<span class="brand-name">Bio-RAG</span>
|
| 56 |
+
<span class="brand-tag">Clinical Fact-Checker</span>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div class="header-right">
|
| 61 |
+
<button class="header-btn" id="headerNewChat" title="New chat">
|
| 62 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 63 |
+
<path d="M12 5v14M5 12h14"/>
|
| 64 |
+
</svg>
|
| 65 |
+
</button>
|
| 66 |
+
<button class="header-btn header-delete-btn" id="headerDeleteChat" title="Delete current chat" style="display: none;">
|
| 67 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 68 |
+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
| 69 |
+
</svg>
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
</header>
|
| 73 |
+
|
| 74 |
+
<!-- CHAT AREA -->
|
| 75 |
+
<div class="chat-area" id="chatArea">
|
| 76 |
+
|
| 77 |
+
<!-- EMPTY STATE / WELCOME -->
|
| 78 |
+
<div class="welcome" id="welcomeScreen">
|
| 79 |
+
<div class="welcome-icon">🧬</div>
|
| 80 |
+
<h1 class="welcome-title">Bio-RAG</h1>
|
| 81 |
+
<p class="welcome-subtitle">Medical Question Answering with Hallucination Detection</p>
|
| 82 |
+
<p class="welcome-desc">Ask any question about diabetes. The system retrieves evidence from PubMed, generates an answer, then verifies every claim for accuracy.</p>
|
| 83 |
+
|
| 84 |
+
<div class="suggestions">
|
| 85 |
+
<button class="suggestion-card" data-question="What are the early symptoms of type 2 diabetes?">
|
| 86 |
+
<span class="suggestion-icon">🔍</span>
|
| 87 |
+
<span class="suggestion-text">What are the early symptoms of type 2 diabetes?</span>
|
| 88 |
+
</button>
|
| 89 |
+
<button class="suggestion-card" data-question="Is metformin safe for diabetic patients with chronic kidney disease?">
|
| 90 |
+
<span class="suggestion-icon">💊</span>
|
| 91 |
+
<span class="suggestion-text">Is metformin safe for patients with kidney disease?</span>
|
| 92 |
+
</button>
|
| 93 |
+
<button class="suggestion-card" data-question="How does insulin resistance develop in type 2 diabetes?">
|
| 94 |
+
<span class="suggestion-icon">🧪</span>
|
| 95 |
+
<span class="suggestion-text">How does insulin resistance develop in type 2 diabetes?</span>
|
| 96 |
+
</button>
|
| 97 |
+
<button class="suggestion-card" data-question="Can type 2 diabetes be prevented through lifestyle changes?">
|
| 98 |
+
<span class="suggestion-icon">🏃</span>
|
| 99 |
+
<span class="suggestion-text">Can type 2 diabetes be prevented through lifestyle changes?</span>
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<!-- MESSAGES CONTAINER -->
|
| 105 |
+
<div class="messages" id="messages">
|
| 106 |
+
<!-- Messages will be injected here by JS -->
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<!-- INPUT BAR -->
|
| 112 |
+
<div class="input-wrapper">
|
| 113 |
+
<div class="input-fade"></div>
|
| 114 |
+
<div class="input-bar">
|
| 115 |
+
<div class="input-container">
|
| 116 |
+
<textarea id="questionInput" class="input-field" placeholder="Ask a diabetes-related question..." rows="1"
|
| 117 |
+
maxlength="2000"></textarea>
|
| 118 |
+
<button class="send-btn" id="sendBtn" disabled title="Send message">
|
| 119 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
| 120 |
+
<path d="M12 19V5M5 12l7-7 7 7"/>
|
| 121 |
+
</svg>
|
| 122 |
+
</button>
|
| 123 |
+
</div>
|
| 124 |
+
<p class="input-disclaimer">Bio-RAG verifies claims against PubMed evidence. Not a substitute for professional medical advice.</p>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
</main>
|
| 129 |
+
|
| 130 |
+
<!-- DELETE CONFIRMATION MODAL -->
|
| 131 |
+
<div class="delete-modal" id="deleteModal">
|
| 132 |
+
<div class="delete-modal-content">
|
| 133 |
+
<div class="delete-modal-title">Delete Chat?</div>
|
| 134 |
+
<div class="delete-modal-text">Are you sure you want to delete this conversation? This action cannot be undone.</div>
|
| 135 |
+
<div class="delete-modal-actions">
|
| 136 |
+
<button class="delete-modal-btn cancel" id="deleteCancelBtn">Cancel</button>
|
| 137 |
+
<button class="delete-modal-btn confirm" id="deleteConfirmBtn">Delete</button>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<script src="/static/js/app.js"></script>
|
| 143 |
+
</body>
|
| 144 |
+
</html>
|
static/js/app.js
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================
|
| 2 |
+
Bio-RAG — Application Logic
|
| 3 |
+
============================================ */
|
| 4 |
+
|
| 5 |
+
// --- DOM Elements ---
|
| 6 |
+
const $ = (sel) => document.querySelector(sel);
|
| 7 |
+
const $$ = (sel) => document.querySelectorAll(sel);
|
| 8 |
+
|
| 9 |
+
const DOM = {
|
| 10 |
+
sidebar: $('#sidebar'),
|
| 11 |
+
sidebarToggle: $('#sidebarToggle'),
|
| 12 |
+
sidebarHistory: $('#sidebarHistory'),
|
| 13 |
+
menuBtn: $('#menuBtn'),
|
| 14 |
+
newChatBtn: $('#newChatBtn'),
|
| 15 |
+
headerNewChat: $('#headerNewChat'),
|
| 16 |
+
headerDeleteChat: $('#headerDeleteChat'),
|
| 17 |
+
chatArea: $('#chatArea'),
|
| 18 |
+
messages: $('#messages'),
|
| 19 |
+
welcomeScreen: $('#welcomeScreen'),
|
| 20 |
+
questionInput: $('#questionInput'),
|
| 21 |
+
sendBtn: $('#sendBtn'),
|
| 22 |
+
deleteModal: $('#deleteModal'),
|
| 23 |
+
deleteCancelBtn: $('#deleteCancelBtn'),
|
| 24 |
+
deleteConfirmBtn: $('#deleteConfirmBtn'),
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// --- State ---
|
| 28 |
+
const state = {
|
| 29 |
+
isProcessing: false,
|
| 30 |
+
conversations: JSON.parse(localStorage.getItem('biorag_history') || '[]'),
|
| 31 |
+
currentMessages: [],
|
| 32 |
+
currentChatId: null,
|
| 33 |
+
chatToDelete: null,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// ============================================
|
| 37 |
+
// INITIALIZATION
|
| 38 |
+
// ============================================
|
| 39 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 40 |
+
initEventListeners();
|
| 41 |
+
renderHistory();
|
| 42 |
+
autoResizeTextarea();
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
function initEventListeners() {
|
| 46 |
+
// Send
|
| 47 |
+
DOM.sendBtn.addEventListener('click', handleSend);
|
| 48 |
+
DOM.questionInput.addEventListener('keydown', (e) => {
|
| 49 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 50 |
+
e.preventDefault();
|
| 51 |
+
handleSend();
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
// Input state
|
| 56 |
+
DOM.questionInput.addEventListener('input', () => {
|
| 57 |
+
autoResizeTextarea();
|
| 58 |
+
DOM.sendBtn.disabled = !DOM.questionInput.value.trim();
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
// Sidebar
|
| 62 |
+
DOM.sidebarToggle.addEventListener('click', () => toggleSidebar(false));
|
| 63 |
+
DOM.menuBtn.addEventListener('click', () => toggleSidebar(true));
|
| 64 |
+
|
| 65 |
+
// New chat
|
| 66 |
+
DOM.newChatBtn.addEventListener('click', newChat);
|
| 67 |
+
DOM.headerNewChat.addEventListener('click', newChat);
|
| 68 |
+
|
| 69 |
+
// Delete chat
|
| 70 |
+
DOM.headerDeleteChat.addEventListener('click', () => {
|
| 71 |
+
if (state.currentChatId) {
|
| 72 |
+
showDeleteModal(state.currentChatId);
|
| 73 |
+
}
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
DOM.deleteCancelBtn.addEventListener('click', hideDeleteModal);
|
| 77 |
+
DOM.deleteConfirmBtn.addEventListener('click', confirmDelete);
|
| 78 |
+
|
| 79 |
+
// Close modal on background click
|
| 80 |
+
DOM.deleteModal.addEventListener('click', (e) => {
|
| 81 |
+
if (e.target === DOM.deleteModal) {
|
| 82 |
+
hideDeleteModal();
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Suggestion cards
|
| 87 |
+
$$('.suggestion-card').forEach(card => {
|
| 88 |
+
card.addEventListener('click', () => {
|
| 89 |
+
const question = card.dataset.question;
|
| 90 |
+
DOM.questionInput.value = question;
|
| 91 |
+
DOM.sendBtn.disabled = false;
|
| 92 |
+
handleSend();
|
| 93 |
+
});
|
| 94 |
+
});
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// ============================================
|
| 98 |
+
// SIDEBAR
|
| 99 |
+
// ============================================
|
| 100 |
+
function toggleSidebar(open) {
|
| 101 |
+
if (open) {
|
| 102 |
+
DOM.sidebar.classList.remove('collapsed');
|
| 103 |
+
DOM.sidebar.classList.add('open');
|
| 104 |
+
} else {
|
| 105 |
+
DOM.sidebar.classList.add('collapsed');
|
| 106 |
+
DOM.sidebar.classList.remove('open');
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function renderHistory() {
|
| 111 |
+
DOM.sidebarHistory.innerHTML = '';
|
| 112 |
+
if (state.conversations.length === 0) return;
|
| 113 |
+
|
| 114 |
+
const now = new Date();
|
| 115 |
+
const today = [];
|
| 116 |
+
const yesterday = [];
|
| 117 |
+
const older = [];
|
| 118 |
+
|
| 119 |
+
state.conversations.forEach(conv => {
|
| 120 |
+
const d = new Date(conv.timestamp);
|
| 121 |
+
const diffDays = Math.floor((now - d) / 86400000);
|
| 122 |
+
if (diffDays === 0) today.push(conv);
|
| 123 |
+
else if (diffDays === 1) yesterday.push(conv);
|
| 124 |
+
else older.push(conv);
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
if (today.length) addHistorySection('Today', today);
|
| 128 |
+
if (yesterday.length) addHistorySection('Yesterday', yesterday);
|
| 129 |
+
if (older.length) addHistorySection('Previous', older);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function addHistorySection(title, items) {
|
| 133 |
+
const h = document.createElement('div');
|
| 134 |
+
h.className = 'history-section-title';
|
| 135 |
+
h.textContent = title;
|
| 136 |
+
DOM.sidebarHistory.appendChild(h);
|
| 137 |
+
|
| 138 |
+
items.forEach(conv => {
|
| 139 |
+
const wrapper = document.createElement('div');
|
| 140 |
+
wrapper.className = 'history-item-wrapper';
|
| 141 |
+
|
| 142 |
+
const btn = document.createElement('button');
|
| 143 |
+
btn.className = 'history-item';
|
| 144 |
+
btn.textContent = conv.title;
|
| 145 |
+
btn.addEventListener('click', () => loadConversation(conv));
|
| 146 |
+
|
| 147 |
+
const deleteBtn = document.createElement('button');
|
| 148 |
+
deleteBtn.className = 'delete-chat-btn';
|
| 149 |
+
deleteBtn.title = 'Delete chat';
|
| 150 |
+
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 151 |
+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
| 152 |
+
</svg>`;
|
| 153 |
+
deleteBtn.addEventListener('click', (e) => {
|
| 154 |
+
e.stopPropagation();
|
| 155 |
+
showDeleteModal(conv.id);
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
wrapper.appendChild(btn);
|
| 159 |
+
wrapper.appendChild(deleteBtn);
|
| 160 |
+
DOM.sidebarHistory.appendChild(wrapper);
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function loadConversation(conv) {
|
| 165 |
+
state.currentChatId = conv.id;
|
| 166 |
+
state.currentMessages = conv.messages || [];
|
| 167 |
+
DOM.messages.innerHTML = '';
|
| 168 |
+
DOM.welcomeScreen.style.display = 'none';
|
| 169 |
+
DOM.headerDeleteChat.style.display = 'flex';
|
| 170 |
+
|
| 171 |
+
state.currentMessages.forEach(msg => {
|
| 172 |
+
if (msg.role === 'user') {
|
| 173 |
+
addUserMessageToDOM(msg.content);
|
| 174 |
+
} else {
|
| 175 |
+
addBotMessageToDOM(msg.content, msg.resultData, false);
|
| 176 |
+
}
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
scrollToBottom();
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// ============================================
|
| 183 |
+
// NEW CHAT
|
| 184 |
+
// ============================================
|
| 185 |
+
function newChat() {
|
| 186 |
+
saveCurrentConversation();
|
| 187 |
+
state.currentChatId = null;
|
| 188 |
+
state.currentMessages = [];
|
| 189 |
+
DOM.messages.innerHTML = '';
|
| 190 |
+
DOM.welcomeScreen.style.display = '';
|
| 191 |
+
DOM.questionInput.value = '';
|
| 192 |
+
DOM.sendBtn.disabled = true;
|
| 193 |
+
DOM.headerDeleteChat.style.display = 'none';
|
| 194 |
+
scrollToBottom();
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function saveCurrentConversation() {
|
| 198 |
+
if (state.currentMessages.length === 0) return;
|
| 199 |
+
|
| 200 |
+
const firstUserMsg = state.currentMessages.find(m => m.role === 'user');
|
| 201 |
+
const title = firstUserMsg
|
| 202 |
+
? firstUserMsg.content.slice(0, 50) + (firstUserMsg.content.length > 50 ? '...' : '')
|
| 203 |
+
: 'Untitled';
|
| 204 |
+
|
| 205 |
+
const conv = {
|
| 206 |
+
id: Date.now(),
|
| 207 |
+
title,
|
| 208 |
+
timestamp: new Date().toISOString(),
|
| 209 |
+
messages: state.currentMessages,
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
state.conversations.unshift(conv);
|
| 213 |
+
if (state.conversations.length > 30) state.conversations.pop();
|
| 214 |
+
|
| 215 |
+
localStorage.setItem('biorag_history', JSON.stringify(state.conversations));
|
| 216 |
+
renderHistory();
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// ============================================
|
| 220 |
+
// DELETE CHAT
|
| 221 |
+
// ============================================
|
| 222 |
+
function showDeleteModal(chatId) {
|
| 223 |
+
state.chatToDelete = chatId;
|
| 224 |
+
DOM.deleteModal.classList.add('show');
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function hideDeleteModal() {
|
| 228 |
+
state.chatToDelete = null;
|
| 229 |
+
DOM.deleteModal.classList.remove('show');
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function confirmDelete() {
|
| 233 |
+
if (!state.chatToDelete) return;
|
| 234 |
+
|
| 235 |
+
// Remove from conversations
|
| 236 |
+
state.conversations = state.conversations.filter(c => c.id !== state.chatToDelete);
|
| 237 |
+
localStorage.setItem('biorag_history', JSON.stringify(state.conversations));
|
| 238 |
+
|
| 239 |
+
// If deleting current chat, start new chat
|
| 240 |
+
if (state.currentChatId === state.chatToDelete) {
|
| 241 |
+
state.currentChatId = null;
|
| 242 |
+
state.currentMessages = [];
|
| 243 |
+
DOM.messages.innerHTML = '';
|
| 244 |
+
DOM.welcomeScreen.style.display = '';
|
| 245 |
+
DOM.headerDeleteChat.style.display = 'none';
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Update UI
|
| 249 |
+
renderHistory();
|
| 250 |
+
hideDeleteModal();
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// ============================================
|
| 254 |
+
// SEND & RECEIVE
|
| 255 |
+
// ============================================
|
| 256 |
+
async function handleSend() {
|
| 257 |
+
const question = DOM.questionInput.value.trim();
|
| 258 |
+
if (!question || state.isProcessing) return;
|
| 259 |
+
state.isProcessing = true;
|
| 260 |
+
DOM.sendBtn.disabled = true;
|
| 261 |
+
DOM.questionInput.value = '';
|
| 262 |
+
autoResizeTextarea();
|
| 263 |
+
DOM.welcomeScreen.style.display = 'none';
|
| 264 |
+
addUserMessageToDOM(question);
|
| 265 |
+
state.currentMessages.push({ role: 'user', content: question });
|
| 266 |
+
scrollToBottom();
|
| 267 |
+
|
| 268 |
+
// Create bot message wrapper
|
| 269 |
+
const botWrapper = document.createElement('div');
|
| 270 |
+
botWrapper.className = 'msg-bot';
|
| 271 |
+
const avatar = document.createElement('div');
|
| 272 |
+
avatar.className = 'msg-bot-avatar';
|
| 273 |
+
avatar.textContent = '🧬';
|
| 274 |
+
const botContent = document.createElement('div');
|
| 275 |
+
botContent.className = 'msg-bot-content';
|
| 276 |
+
|
| 277 |
+
const progressEl = createPipelineProgress();
|
| 278 |
+
botContent.appendChild(progressEl);
|
| 279 |
+
|
| 280 |
+
const textEl = document.createElement('div');
|
| 281 |
+
textEl.className = 'msg-bot-text';
|
| 282 |
+
textEl.style.display = 'none';
|
| 283 |
+
botContent.appendChild(textEl);
|
| 284 |
+
|
| 285 |
+
botWrapper.appendChild(avatar);
|
| 286 |
+
botWrapper.appendChild(botContent);
|
| 287 |
+
DOM.messages.appendChild(botWrapper);
|
| 288 |
+
|
| 289 |
+
animatePipelineStep(progressEl, 0);
|
| 290 |
+
scrollToBottom();
|
| 291 |
+
|
| 292 |
+
try {
|
| 293 |
+
const response = await fetch('/api/ask-stream', {
|
| 294 |
+
method: 'POST',
|
| 295 |
+
headers: { 'Content-Type': 'application/json' },
|
| 296 |
+
body: JSON.stringify({ question }),
|
| 297 |
+
});
|
| 298 |
+
const reader = response.body.getReader();
|
| 299 |
+
const decoder = new TextDecoder();
|
| 300 |
+
let buffer = '';
|
| 301 |
+
let finalResult = null;
|
| 302 |
+
|
| 303 |
+
while (true) {
|
| 304 |
+
const { done, value } = await reader.read();
|
| 305 |
+
if (done) break;
|
| 306 |
+
buffer += decoder.decode(value, { stream: true });
|
| 307 |
+
const lines = buffer.split('\n');
|
| 308 |
+
buffer = lines.pop() || '';
|
| 309 |
+
for (const line of lines) {
|
| 310 |
+
if (!line.startsWith('data: ')) continue;
|
| 311 |
+
try {
|
| 312 |
+
const event = JSON.parse(line.slice(6).trim());
|
| 313 |
+
if (event.step !== undefined) {
|
| 314 |
+
if (event.status === 'active') animatePipelineStep(progressEl, event.step);
|
| 315 |
+
else if (event.status === 'done') completePipelineStep(progressEl, event.step);
|
| 316 |
+
scrollToBottom();
|
| 317 |
+
}
|
| 318 |
+
if (event.answer_ready) {
|
| 319 |
+
textEl.style.display = '';
|
| 320 |
+
typewriter(textEl, event.answer);
|
| 321 |
+
}
|
| 322 |
+
if (event.complete) finalResult = event.result;
|
| 323 |
+
if (event.error) throw new Error(event.error);
|
| 324 |
+
} catch (e) { if (e.message && !e.message.includes('JSON')) throw e; }
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Pipeline complete
|
| 329 |
+
const dot = progressEl.querySelector('.pipeline-header-dot');
|
| 330 |
+
if (dot) dot.classList.add('done');
|
| 331 |
+
const label = progressEl.querySelector('.pipeline-current-label');
|
| 332 |
+
if (label) label.textContent = '';
|
| 333 |
+
const comp = progressEl.querySelector('.pipeline-complete');
|
| 334 |
+
if (comp) {
|
| 335 |
+
comp.className = 'pipeline-complete show safe';
|
| 336 |
+
comp.innerHTML = '<span class="pipeline-complete-icon">✓</span> Done';
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
if (finalResult) {
|
| 340 |
+
const answerText = finalResult.final_answer || finalResult.rejection_message || 'No response.';
|
| 341 |
+
const isRejection = !!finalResult.rejection_message && (!finalResult.claim_checks || finalResult.claim_checks.length === 0);
|
| 342 |
+
|
| 343 |
+
if (isRejection) {
|
| 344 |
+
botWrapper.remove();
|
| 345 |
+
addRejectionToDOM(answerText);
|
| 346 |
+
} else {
|
| 347 |
+
textEl.innerHTML = formatText(answerText);
|
| 348 |
+
setTimeout(() => highlightRisksInText(textEl, finalResult), 500);
|
| 349 |
+
const panel = buildVerificationPanel(finalResult);
|
| 350 |
+
botContent.appendChild(panel);
|
| 351 |
+
}
|
| 352 |
+
state.currentMessages.push({ role: 'assistant', content: answerText, resultData: finalResult });
|
| 353 |
+
}
|
| 354 |
+
scrollToBottom();
|
| 355 |
+
} catch (err) {
|
| 356 |
+
botWrapper.remove();
|
| 357 |
+
addErrorToDOM(err.message || 'Connection failed.');
|
| 358 |
+
}
|
| 359 |
+
state.isProcessing = false;
|
| 360 |
+
DOM.sendBtn.disabled = !DOM.questionInput.value.trim();
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// ============================================
|
| 364 |
+
// DOM BUILDERS
|
| 365 |
+
// ============================================
|
| 366 |
+
function addUserMessageToDOM(text) {
|
| 367 |
+
const div = document.createElement('div');
|
| 368 |
+
div.className = 'msg-user';
|
| 369 |
+
div.innerHTML = `<div class="msg-user-bubble">${escapeHTML(text)}</div>`;
|
| 370 |
+
DOM.messages.appendChild(div);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
async function addBotMessageToDOM(text, resultData, animate) {
|
| 374 |
+
const wrapper = document.createElement('div');
|
| 375 |
+
wrapper.className = 'msg-bot';
|
| 376 |
+
|
| 377 |
+
const avatar = document.createElement('div');
|
| 378 |
+
avatar.className = 'msg-bot-avatar';
|
| 379 |
+
avatar.textContent = '🧬';
|
| 380 |
+
|
| 381 |
+
const content = document.createElement('div');
|
| 382 |
+
content.className = 'msg-bot-content';
|
| 383 |
+
|
| 384 |
+
const textEl = document.createElement('div');
|
| 385 |
+
textEl.className = 'msg-bot-text';
|
| 386 |
+
content.appendChild(textEl);
|
| 387 |
+
|
| 388 |
+
wrapper.appendChild(avatar);
|
| 389 |
+
wrapper.appendChild(content);
|
| 390 |
+
DOM.messages.appendChild(wrapper);
|
| 391 |
+
|
| 392 |
+
// Typewriter or instant
|
| 393 |
+
if (animate) {
|
| 394 |
+
await typewriter(textEl, text);
|
| 395 |
+
} else {
|
| 396 |
+
textEl.innerHTML = formatText(text);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// Verification panel
|
| 400 |
+
if (resultData && resultData.claim_checks && resultData.claim_checks.length > 0) {
|
| 401 |
+
// Apply inline risk highlighting after text is rendered
|
| 402 |
+
setTimeout(() => {
|
| 403 |
+
highlightRisksInText(textEl, resultData);
|
| 404 |
+
}, animate ? 300 : 0);
|
| 405 |
+
|
| 406 |
+
const panel = buildVerificationPanel(resultData);
|
| 407 |
+
content.appendChild(panel);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
scrollToBottom();
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
function addRejectionToDOM(text) {
|
| 414 |
+
const wrapper = document.createElement('div');
|
| 415 |
+
wrapper.className = 'msg-bot';
|
| 416 |
+
|
| 417 |
+
const avatar = document.createElement('div');
|
| 418 |
+
avatar.className = 'msg-bot-avatar';
|
| 419 |
+
avatar.textContent = '🧬';
|
| 420 |
+
|
| 421 |
+
const content = document.createElement('div');
|
| 422 |
+
content.className = 'msg-bot-content';
|
| 423 |
+
|
| 424 |
+
const rejection = document.createElement('div');
|
| 425 |
+
rejection.className = 'msg-rejection';
|
| 426 |
+
rejection.innerHTML = `<span>⚠️</span><span>${escapeHTML(text)}</span>`;
|
| 427 |
+
content.appendChild(rejection);
|
| 428 |
+
|
| 429 |
+
wrapper.appendChild(avatar);
|
| 430 |
+
wrapper.appendChild(content);
|
| 431 |
+
DOM.messages.appendChild(wrapper);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
function addErrorToDOM(text) {
|
| 435 |
+
const wrapper = document.createElement('div');
|
| 436 |
+
wrapper.className = 'msg-error';
|
| 437 |
+
|
| 438 |
+
const avatar = document.createElement('div');
|
| 439 |
+
avatar.className = 'msg-bot-avatar';
|
| 440 |
+
avatar.textContent = '🧬';
|
| 441 |
+
|
| 442 |
+
const content = document.createElement('div');
|
| 443 |
+
content.className = 'msg-error-content';
|
| 444 |
+
content.textContent = `Error: ${text}`;
|
| 445 |
+
|
| 446 |
+
wrapper.appendChild(avatar);
|
| 447 |
+
wrapper.appendChild(content);
|
| 448 |
+
DOM.messages.appendChild(wrapper);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// ============================================
|
| 452 |
+
// THINKING INDICATOR
|
| 453 |
+
// ============================================
|
| 454 |
+
function showThinking() {
|
| 455 |
+
const wrapper = document.createElement('div');
|
| 456 |
+
wrapper.className = 'thinking';
|
| 457 |
+
|
| 458 |
+
const avatar = document.createElement('div');
|
| 459 |
+
avatar.className = 'msg-bot-avatar';
|
| 460 |
+
avatar.textContent = '🧬';
|
| 461 |
+
|
| 462 |
+
const content = document.createElement('div');
|
| 463 |
+
content.className = 'thinking-content';
|
| 464 |
+
|
| 465 |
+
const dots = document.createElement('div');
|
| 466 |
+
dots.className = 'thinking-dots';
|
| 467 |
+
dots.innerHTML = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
|
| 468 |
+
|
| 469 |
+
const steps = document.createElement('div');
|
| 470 |
+
steps.className = 'thinking-steps';
|
| 471 |
+
|
| 472 |
+
content.appendChild(dots);
|
| 473 |
+
content.appendChild(steps);
|
| 474 |
+
|
| 475 |
+
wrapper.appendChild(avatar);
|
| 476 |
+
wrapper.appendChild(content);
|
| 477 |
+
DOM.messages.appendChild(wrapper);
|
| 478 |
+
|
| 479 |
+
return wrapper;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
function updateThinkingStep(el, index, text) {
|
| 483 |
+
const stepsContainer = el.querySelector('.thinking-steps');
|
| 484 |
+
if (!stepsContainer) return;
|
| 485 |
+
|
| 486 |
+
// Mark previous as done
|
| 487 |
+
const prevSteps = stepsContainer.querySelectorAll('.thinking-step');
|
| 488 |
+
prevSteps.forEach(s => {
|
| 489 |
+
s.classList.remove('active');
|
| 490 |
+
s.classList.add('done');
|
| 491 |
+
const icon = s.querySelector('.step-icon');
|
| 492 |
+
if (icon) icon.textContent = '✓';
|
| 493 |
+
});
|
| 494 |
+
|
| 495 |
+
// Add new step
|
| 496 |
+
const step = document.createElement('div');
|
| 497 |
+
step.className = 'thinking-step active';
|
| 498 |
+
step.innerHTML = `<span class="step-icon">○</span> ${escapeHTML(text)}`;
|
| 499 |
+
stepsContainer.appendChild(step);
|
| 500 |
+
|
| 501 |
+
scrollToBottom();
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// ============================================
|
| 505 |
+
// VERIFICATION PANEL
|
| 506 |
+
// ============================================
|
| 507 |
+
function buildVerificationPanel(data) {
|
| 508 |
+
const claims = data.claim_checks || [];
|
| 509 |
+
const maxRisk = data.max_risk_score || 0;
|
| 510 |
+
const isSafe = data.safe !== false && maxRisk < 0.7;
|
| 511 |
+
const evidence = data.evidence || [];
|
| 512 |
+
|
| 513 |
+
const panel = document.createElement('div');
|
| 514 |
+
panel.className = `verification-panel ${isSafe ? 'safe' : 'flagged'}`;
|
| 515 |
+
|
| 516 |
+
// Summary
|
| 517 |
+
const summary = document.createElement('div');
|
| 518 |
+
summary.className = 'verification-summary';
|
| 519 |
+
summary.innerHTML = `
|
| 520 |
+
<div class="verification-info">
|
| 521 |
+
<div class="verification-status ${isSafe ? 'safe' : 'flagged'}">
|
| 522 |
+
${isSafe ? '✅' : '⚠️'} ${isSafe ? 'Safe' : 'Flagged'} — Risk: ${maxRisk.toFixed(4)}
|
| 523 |
+
</div>
|
| 524 |
+
<div class="verification-meta">
|
| 525 |
+
${claims.length} claims verified • ${Math.min(evidence.length, 3)} sources cited
|
| 526 |
+
</div>
|
| 527 |
+
</div>
|
| 528 |
+
<div class="verification-toggle">View Details ▼</div>
|
| 529 |
+
`;
|
| 530 |
+
|
| 531 |
+
// Details
|
| 532 |
+
const details = document.createElement('div');
|
| 533 |
+
details.className = 'verification-details';
|
| 534 |
+
|
| 535 |
+
let detailsHTML = '<div class="verification-details-inner">';
|
| 536 |
+
detailsHTML += '<div class="claims-title">Claims & Risk Scores</div>';
|
| 537 |
+
|
| 538 |
+
// Sort claims by risk (highest first)
|
| 539 |
+
const sortedClaims = [...claims].sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0));
|
| 540 |
+
|
| 541 |
+
sortedClaims.forEach(c => {
|
| 542 |
+
const risk = c.risk_score || 0;
|
| 543 |
+
const pct = Math.min(risk * 100, 100);
|
| 544 |
+
const level = risk >= 0.7 ? 'high' : risk >= 0.3 ? 'medium' : 'low';
|
| 545 |
+
|
| 546 |
+
detailsHTML += `
|
| 547 |
+
<div class="claim-item">
|
| 548 |
+
<div class="claim-risk-bar-container">
|
| 549 |
+
<div class="claim-risk-bar">
|
| 550 |
+
<div class="claim-risk-bar-fill ${level}" style="width: ${pct}%"></div>
|
| 551 |
+
</div>
|
| 552 |
+
<span class="claim-risk-value">${risk.toFixed(4)}</span>
|
| 553 |
+
</div>
|
| 554 |
+
<div class="claim-text">${escapeHTML(c.claim || '')}</div>
|
| 555 |
+
</div>
|
| 556 |
+
`;
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
// Evidence
|
| 560 |
+
if (evidence.length > 0) {
|
| 561 |
+
detailsHTML += '<div class="evidence-title">Retrieved Evidence</div>';
|
| 562 |
+
evidence.slice(0, 3).forEach((ev, i) => {
|
| 563 |
+
const text = typeof ev === 'string' ? ev : (ev.text || JSON.stringify(ev));
|
| 564 |
+
detailsHTML += `
|
| 565 |
+
<div class="evidence-item">
|
| 566 |
+
<span class="evidence-icon">📄</span>
|
| 567 |
+
<span class="evidence-text">Doc ${i + 1}: ${escapeHTML(text.slice(0, 150))}...</span>
|
| 568 |
+
</div>
|
| 569 |
+
`;
|
| 570 |
+
});
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
detailsHTML += '</div>';
|
| 574 |
+
details.innerHTML = detailsHTML;
|
| 575 |
+
|
| 576 |
+
// Toggle
|
| 577 |
+
summary.addEventListener('click', () => {
|
| 578 |
+
const isOpen = details.classList.toggle('open');
|
| 579 |
+
summary.querySelector('.verification-toggle').textContent = isOpen ? 'Hide Details ▲' : 'View Details ▼';
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
panel.appendChild(summary);
|
| 583 |
+
panel.appendChild(details);
|
| 584 |
+
|
| 585 |
+
return panel;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// ============================================
|
| 589 |
+
// TYPEWRITER EFFECT
|
| 590 |
+
// ============================================
|
| 591 |
+
async function typewriter(element, text) {
|
| 592 |
+
const words = text.split(' ');
|
| 593 |
+
const cursor = document.createElement('span');
|
| 594 |
+
cursor.className = 'cursor';
|
| 595 |
+
let currentHTML = '';
|
| 596 |
+
|
| 597 |
+
element.appendChild(cursor);
|
| 598 |
+
|
| 599 |
+
for (let i = 0; i < words.length; i++) {
|
| 600 |
+
currentHTML += (i > 0 ? ' ' : '') + escapeHTML(words[i]);
|
| 601 |
+
element.innerHTML = formatText(currentHTML);
|
| 602 |
+
element.appendChild(cursor);
|
| 603 |
+
scrollToBottom();
|
| 604 |
+
await delay(25);
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
cursor.remove();
|
| 608 |
+
element.innerHTML = formatText(text);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
// ============================================
|
| 612 |
+
// INLINE RISK HIGHLIGHTING
|
| 613 |
+
// ============================================
|
| 614 |
+
function highlightRisksInText(textElement, resultData) {
|
| 615 |
+
if (!resultData || !resultData.claim_checks || resultData.claim_checks.length === 0) return;
|
| 616 |
+
|
| 617 |
+
const originalText = resultData.original_answer || textElement.textContent;
|
| 618 |
+
const sentences = splitIntoSentences(originalText);
|
| 619 |
+
const claims = resultData.claim_checks;
|
| 620 |
+
|
| 621 |
+
// Map each sentence to its highest risk score
|
| 622 |
+
const sentenceRisks = sentences.map(sentence => {
|
| 623 |
+
const matchingClaims = findMatchingClaims(sentence, claims);
|
| 624 |
+
const maxRisk = matchingClaims.length > 0
|
| 625 |
+
? Math.max(...matchingClaims.map(c => c.risk_score || 0))
|
| 626 |
+
: 0;
|
| 627 |
+
const topClaim = matchingClaims.sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0))[0];
|
| 628 |
+
return { sentence, maxRisk, topClaim };
|
| 629 |
+
});
|
| 630 |
+
|
| 631 |
+
// Build highlighted HTML
|
| 632 |
+
let html = '';
|
| 633 |
+
sentenceRisks.forEach(({ sentence, maxRisk, topClaim }) => {
|
| 634 |
+
if (maxRisk >= 0.7) {
|
| 635 |
+
html += buildHighlightedSentence(sentence, maxRisk, topClaim, 'danger');
|
| 636 |
+
} else if (maxRisk >= 0.15) {
|
| 637 |
+
html += buildHighlightedSentence(sentence, maxRisk, topClaim, 'caution');
|
| 638 |
+
} else {
|
| 639 |
+
html += escapeHTML(sentence) + ' ';
|
| 640 |
+
}
|
| 641 |
+
});
|
| 642 |
+
|
| 643 |
+
// Apply with animation
|
| 644 |
+
textElement.innerHTML = `<p>${html.trim()}</p>`;
|
| 645 |
+
|
| 646 |
+
// Trigger animation
|
| 647 |
+
setTimeout(() => {
|
| 648 |
+
textElement.querySelectorAll('.risk-sentence').forEach(el => {
|
| 649 |
+
el.classList.add('animate-in');
|
| 650 |
+
});
|
| 651 |
+
}, 100);
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
function buildHighlightedSentence(sentence, risk, claim, level) {
|
| 655 |
+
const tooltipLabel = level === 'danger'
|
| 656 |
+
? 'Unverified or contradicted'
|
| 657 |
+
: 'Low confidence';
|
| 658 |
+
const claimText = claim ? escapeHTML(claim.claim || '').slice(0, 60) + '...' : '';
|
| 659 |
+
|
| 660 |
+
return `<span class="risk-sentence risk-${level}">` +
|
| 661 |
+
`${escapeHTML(sentence)} ` +
|
| 662 |
+
`<span class="risk-tooltip">` +
|
| 663 |
+
`<span class="tooltip-risk ${level}">Risk: ${risk.toFixed(3)}</span><br>` +
|
| 664 |
+
`${tooltipLabel}` +
|
| 665 |
+
`${claimText ? '<br><em>' + claimText + '</em>' : ''}` +
|
| 666 |
+
`</span>` +
|
| 667 |
+
`</span> `;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
function splitIntoSentences(text) {
|
| 671 |
+
// Split on sentence boundaries but keep the delimiter
|
| 672 |
+
const raw = text.split(/(?<=[.!?])\s+/);
|
| 673 |
+
return raw.filter(s => s.trim().length > 5);
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
function findMatchingClaims(sentence, claims) {
|
| 677 |
+
const sentenceClean = sentence.toLowerCase().replace(/[^\w\s]/g, '');
|
| 678 |
+
const sentenceWords = new Set(
|
| 679 |
+
sentenceClean.split(/\s+/).filter(w => w.length > 3)
|
| 680 |
+
);
|
| 681 |
+
|
| 682 |
+
if (sentenceWords.size === 0) return [];
|
| 683 |
+
|
| 684 |
+
const results = [];
|
| 685 |
+
|
| 686 |
+
claims.forEach(claim => {
|
| 687 |
+
const claimText = (claim.claim || '').toLowerCase().replace(/[^\w\s]/g, '');
|
| 688 |
+
const claimWords = claimText.split(/\s+/).filter(w => w.length > 3);
|
| 689 |
+
|
| 690 |
+
if (claimWords.length === 0) return;
|
| 691 |
+
|
| 692 |
+
// Count matches in both directions
|
| 693 |
+
const claimInSentence = claimWords.filter(w => sentenceWords.has(w)).length;
|
| 694 |
+
const sentenceInClaim = [...sentenceWords].filter(w => claimWords.includes(w)).length;
|
| 695 |
+
|
| 696 |
+
const claimMatchRatio = claimInSentence / claimWords.length;
|
| 697 |
+
const sentenceMatchRatio = sentenceInClaim / sentenceWords.size;
|
| 698 |
+
|
| 699 |
+
// Both directions must match at least 50%
|
| 700 |
+
// This prevents a short claim from matching many long sentences
|
| 701 |
+
if (claimMatchRatio >= 0.5 && sentenceMatchRatio >= 0.3) {
|
| 702 |
+
results.push(claim);
|
| 703 |
+
}
|
| 704 |
+
});
|
| 705 |
+
|
| 706 |
+
return results;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// ============================================
|
| 710 |
+
// PIPELINE PROGRESS INDICATOR
|
| 711 |
+
// ============================================
|
| 712 |
+
function createPipelineProgress() {
|
| 713 |
+
const div = document.createElement('div');
|
| 714 |
+
div.className = 'pipeline-progress';
|
| 715 |
+
|
| 716 |
+
const stepNames = [
|
| 717 |
+
'Domain Check', 'Query Expansion', 'Retrieval', 'Generation',
|
| 718 |
+
'Decomposition', 'Evidence', 'NLI', 'Risk Score', 'Decision'
|
| 719 |
+
];
|
| 720 |
+
|
| 721 |
+
let html = `<div class="pipeline-header"><span class="pipeline-header-dot"></span>Pipeline</div>`;
|
| 722 |
+
html += `<div class="pipeline-phases">`;
|
| 723 |
+
html += `<div class="pipeline-phase"><div class="pipeline-steps">`;
|
| 724 |
+
|
| 725 |
+
for (let i = 0; i < 4; i++) {
|
| 726 |
+
html += `<div class="pipeline-step pending" data-step="${i}">
|
| 727 |
+
<span class="pipeline-step-icon">✓</span>
|
| 728 |
+
<span class="step-tooltip">${stepNames[i]}</span>
|
| 729 |
+
</div>`;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
html += `</div></div>`;
|
| 733 |
+
html += `<div class="pipeline-phase-sep"></div>`;
|
| 734 |
+
html += `<div class="pipeline-phase"><div class="pipeline-steps">`;
|
| 735 |
+
|
| 736 |
+
for (let i = 4; i < 9; i++) {
|
| 737 |
+
html += `<div class="pipeline-step pending" data-step="${i}">
|
| 738 |
+
<span class="pipeline-step-icon">✓</span>
|
| 739 |
+
<span class="step-tooltip">${stepNames[i]}</span>
|
| 740 |
+
</div>`;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
html += `</div></div>`;
|
| 744 |
+
html += `</div>`;
|
| 745 |
+
html += `<div class="pipeline-complete"></div>`;
|
| 746 |
+
html += `<span class="pipeline-current-label" id="pipelineLabel"></span>`;
|
| 747 |
+
|
| 748 |
+
div.innerHTML = html;
|
| 749 |
+
return div;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
async function animatePipelineStep(progressEl, stepIndex) {
|
| 753 |
+
const step = progressEl.querySelector(`[data-step="${stepIndex}"]`);
|
| 754 |
+
if (!step) return;
|
| 755 |
+
|
| 756 |
+
const stepNames = [
|
| 757 |
+
'Domain Check...', 'Expanding Query...', 'Retrieving Evidence...', 'Generating Answer...',
|
| 758 |
+
'Decomposing Claims...', 'Retrieving Per-Claim...', 'NLI Evaluation...', 'Risk Scoring...', 'Final Decision...'
|
| 759 |
+
];
|
| 760 |
+
|
| 761 |
+
for (let i = 0; i < stepIndex; i++) {
|
| 762 |
+
const prev = progressEl.querySelector(`[data-step="${i}"]`);
|
| 763 |
+
if (prev && !prev.classList.contains('done')) {
|
| 764 |
+
prev.classList.remove('pending', 'active');
|
| 765 |
+
prev.classList.add('done');
|
| 766 |
+
}
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
step.classList.remove('pending');
|
| 770 |
+
step.classList.add('active');
|
| 771 |
+
|
| 772 |
+
// Update label
|
| 773 |
+
const label = progressEl.querySelector('.pipeline-current-label');
|
| 774 |
+
if (label) label.textContent = stepNames[stepIndex] || '';
|
| 775 |
+
|
| 776 |
+
scrollToBottom();
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
function completePipelineStep(progressEl, stepIndex) {
|
| 780 |
+
const step = progressEl.querySelector(`[data-step="${stepIndex}"]`);
|
| 781 |
+
if (!step) return;
|
| 782 |
+
step.classList.remove('pending', 'active');
|
| 783 |
+
step.classList.add('done');
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
function showPipelineComplete(progressEl) {
|
| 787 |
+
// Mark all steps as done
|
| 788 |
+
progressEl.querySelectorAll('.pipeline-step').forEach(s => {
|
| 789 |
+
s.classList.remove('pending', 'active');
|
| 790 |
+
s.classList.add('done');
|
| 791 |
+
});
|
| 792 |
+
|
| 793 |
+
// Update header dot
|
| 794 |
+
const dot = progressEl.querySelector('.pipeline-header-dot');
|
| 795 |
+
if (dot) dot.classList.add('done');
|
| 796 |
+
|
| 797 |
+
// Show simple completion
|
| 798 |
+
const complete = progressEl.querySelector('.pipeline-complete');
|
| 799 |
+
if (complete) {
|
| 800 |
+
complete.className = 'pipeline-complete show safe';
|
| 801 |
+
complete.innerHTML = '<span class="pipeline-complete-icon">✓</span> Pipeline Complete';
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
scrollToBottom();
|
| 805 |
+
|
| 806 |
+
// Collapse after 2 seconds
|
| 807 |
+
setTimeout(() => {
|
| 808 |
+
progressEl.classList.add('collapsed');
|
| 809 |
+
}, 2000);
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
// ============================================
|
| 813 |
+
// UTILITIES
|
| 814 |
+
// ============================================
|
| 815 |
+
function escapeHTML(str) {
|
| 816 |
+
const div = document.createElement('div');
|
| 817 |
+
div.textContent = str;
|
| 818 |
+
return div.innerHTML;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
function formatText(text) {
|
| 822 |
+
// Convert line breaks to paragraphs
|
| 823 |
+
return text.split(/\n\n+/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('');
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
function scrollToBottom() {
|
| 827 |
+
DOM.chatArea.scrollTop = DOM.chatArea.scrollHeight;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
function autoResizeTextarea() {
|
| 831 |
+
const el = DOM.questionInput;
|
| 832 |
+
el.style.height = 'auto';
|
| 833 |
+
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
function delay(ms) {
|
| 837 |
+
return new Promise(r => setTimeout(r, ms));
|
| 838 |
+
}
|
utils/helpers.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
def clean_text(text):
|
| 5 |
+
"""تنظيف النص من المسافات الزائدة والرموز الغريبة"""
|
| 6 |
+
if not text:
|
| 7 |
+
return ""
|
| 8 |
+
# إزالة المسافات المتكررة والأسطر الفارغة
|
| 9 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 10 |
+
return text
|
| 11 |
+
|
| 12 |
+
def format_claims_for_display(claims_list):
|
| 13 |
+
"""تنسيق قائمة الادعاءات الطبية لعرضها بشكل منظم"""
|
| 14 |
+
formatted_text = ""
|
| 15 |
+
for i, claim in enumerate(claims_list, 1):
|
| 16 |
+
formatted_text += f"{i}. {claim}\n"
|
| 17 |
+
return formatted_text
|
| 18 |
+
|
| 19 |
+
def calculate_percentage(score):
|
| 20 |
+
"""تحويل السكور العشري إلى نسبة مئوية للعرض"""
|
| 21 |
+
return f"{score * 100:.1f}%"
|
web_app.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, request, jsonify, send_from_directory, Response
|
| 2 |
+
import json as json_lib
|
| 3 |
+
import os
|
| 4 |
+
from src.bio_rag.pipeline import BioRAGPipeline
|
| 5 |
+
from src.bio_rag.config import BioRAGConfig
|
| 6 |
+
|
| 7 |
+
app = Flask(__name__, static_folder='static')
|
| 8 |
+
|
| 9 |
+
# Load pipeline once at startup
|
| 10 |
+
print("Loading Bio-RAG pipeline...")
|
| 11 |
+
config = BioRAGConfig()
|
| 12 |
+
pipeline = BioRAGPipeline(config)
|
| 13 |
+
print("Pipeline ready!")
|
| 14 |
+
|
| 15 |
+
@app.route('/')
|
| 16 |
+
def index():
|
| 17 |
+
return send_from_directory('static', 'index.html')
|
| 18 |
+
|
| 19 |
+
@app.route('/api/ask', methods=['POST'])
|
| 20 |
+
def ask():
|
| 21 |
+
try:
|
| 22 |
+
data = request.get_json()
|
| 23 |
+
question = data.get('question', '').strip()
|
| 24 |
+
|
| 25 |
+
if not question:
|
| 26 |
+
return jsonify({'error': 'No question provided'}), 400
|
| 27 |
+
|
| 28 |
+
result = pipeline.ask(question)
|
| 29 |
+
|
| 30 |
+
return jsonify(result.to_dict())
|
| 31 |
+
|
| 32 |
+
except Exception as e:
|
| 33 |
+
return jsonify({'error': str(e)}), 500
|
| 34 |
+
|
| 35 |
+
@app.route('/api/ask-stream', methods=['POST'])
|
| 36 |
+
def ask_stream():
|
| 37 |
+
data = request.get_json()
|
| 38 |
+
question = data.get('question', '').strip()
|
| 39 |
+
if not question:
|
| 40 |
+
return jsonify({'error': 'No question provided'}), 400
|
| 41 |
+
|
| 42 |
+
def generate():
|
| 43 |
+
import time
|
| 44 |
+
try:
|
| 45 |
+
yield f"data: {json_lib.dumps({'step': 0, 'status': 'active'})}\n\n"
|
| 46 |
+
time.sleep(0.1)
|
| 47 |
+
yield f"data: {json_lib.dumps({'step': 0, 'status': 'done'})}\n\n"
|
| 48 |
+
time.sleep(0.1)
|
| 49 |
+
|
| 50 |
+
is_valid, msg = pipeline.query_processor.validate_domain(question)
|
| 51 |
+
if not is_valid:
|
| 52 |
+
r = {'question': question, 'original_answer': '', 'final_answer': msg, 'evidence': [], 'claims': [], 'claim_checks': [], 'max_risk_score': 0, 'safe': True, 'rejection_message': msg}
|
| 53 |
+
yield f"data: {json_lib.dumps({'complete': True, 'result': r})}\n\n"
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
yield f"data: {json_lib.dumps({'step': 1, 'status': 'active'})}\n\n"
|
| 57 |
+
time.sleep(0.1)
|
| 58 |
+
queries = pipeline.query_processor.expand_queries(question)
|
| 59 |
+
yield f"data: {json_lib.dumps({'step': 1, 'status': 'done'})}\n\n"
|
| 60 |
+
time.sleep(0.1)
|
| 61 |
+
|
| 62 |
+
yield f"data: {json_lib.dumps({'step': 2, 'status': 'active'})}\n\n"
|
| 63 |
+
time.sleep(0.1)
|
| 64 |
+
passages = pipeline.retriever.retrieve(queries)
|
| 65 |
+
yield f"data: {json_lib.dumps({'step': 2, 'status': 'done'})}\n\n"
|
| 66 |
+
time.sleep(0.1)
|
| 67 |
+
|
| 68 |
+
if len(passages) < 3:
|
| 69 |
+
r = {'question': question, 'original_answer': '', 'final_answer': 'Insufficient evidence.', 'evidence': [], 'claims': [], 'claim_checks': [], 'max_risk_score': 0, 'safe': True, 'rejection_message': 'Insufficient evidence.'}
|
| 70 |
+
yield f"data: {json_lib.dumps({'complete': True, 'result': r})}\n\n"
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
yield f"data: {json_lib.dumps({'step': 3, 'status': 'active'})}\n\n"
|
| 74 |
+
time.sleep(0.1)
|
| 75 |
+
original_answer = pipeline.generator.generate(question, passages)
|
| 76 |
+
yield f"data: {json_lib.dumps({'step': 3, 'status': 'done'})}\n\n"
|
| 77 |
+
time.sleep(0.1)
|
| 78 |
+
|
| 79 |
+
# Send answer_ready event
|
| 80 |
+
try:
|
| 81 |
+
answer_event = json_lib.dumps({'answer_ready': True, 'answer': original_answer}, ensure_ascii=False)
|
| 82 |
+
print(f"[DEBUG] answer_ready event length: {len(answer_event)}")
|
| 83 |
+
yield f"data: {answer_event}\n\n"
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"[ERROR] Failed to send answer_ready: {e}")
|
| 86 |
+
yield f"data: {json_lib.dumps({'answer_ready': True, 'answer': 'Error encoding answer'})}\n\n"
|
| 87 |
+
|
| 88 |
+
yield f"data: {json_lib.dumps({'step': 4, 'status': 'active'})}\n\n"
|
| 89 |
+
time.sleep(0.1)
|
| 90 |
+
try:
|
| 91 |
+
co = pipeline.claim_decomposer.decompose(question, original_answer)
|
| 92 |
+
claims = co if isinstance(co, list) and len(co) > 0 else [original_answer]
|
| 93 |
+
except Exception:
|
| 94 |
+
claims = [original_answer]
|
| 95 |
+
yield f"data: {json_lib.dumps({'step': 4, 'status': 'done'})}\n\n"
|
| 96 |
+
time.sleep(0.1)
|
| 97 |
+
|
| 98 |
+
yield f"data: {json_lib.dumps({'step': 5, 'status': 'active'})}\n\n"
|
| 99 |
+
time.sleep(0.1)
|
| 100 |
+
claim_checks = []
|
| 101 |
+
max_risk = 0.0
|
| 102 |
+
for claim in claims:
|
| 103 |
+
eq = f"{question} {claim}"
|
| 104 |
+
cp = pipeline.retriever.retrieve([eq])[:10]
|
| 105 |
+
ce = " ".join([p.text for p in cp])[:1500]
|
| 106 |
+
nli = pipeline.nli_evaluator.evaluate(claim, [ce])
|
| 107 |
+
pf = pipeline.risk_scorer.calculate_profile(claim)
|
| 108 |
+
rs = pipeline.risk_scorer.compute_weighted_risk(nli, pf)
|
| 109 |
+
max_risk = max(max_risk, rs)
|
| 110 |
+
claim_checks.append({"claim": claim, "nli_prob": round(nli, 4), "severity_score": pf.severity, "type_score": pf.type_score, "omission_score": pf.omission, "risk_score": round(rs, 4)})
|
| 111 |
+
yield f"data: {json_lib.dumps({'step': 5, 'status': 'done'})}\n\n"
|
| 112 |
+
time.sleep(0.1)
|
| 113 |
+
|
| 114 |
+
yield f"data: {json_lib.dumps({'step': 6, 'status': 'active'})}\n\n"
|
| 115 |
+
time.sleep(0.05)
|
| 116 |
+
yield f"data: {json_lib.dumps({'step': 6, 'status': 'done'})}\n\n"
|
| 117 |
+
time.sleep(0.05)
|
| 118 |
+
|
| 119 |
+
yield f"data: {json_lib.dumps({'step': 7, 'status': 'active'})}\n\n"
|
| 120 |
+
time.sleep(0.05)
|
| 121 |
+
yield f"data: {json_lib.dumps({'step': 7, 'status': 'done'})}\n\n"
|
| 122 |
+
time.sleep(0.05)
|
| 123 |
+
|
| 124 |
+
yield f"data: {json_lib.dumps({'step': 8, 'status': 'active'})}\n\n"
|
| 125 |
+
time.sleep(0.1)
|
| 126 |
+
is_safe = max_risk < 0.7
|
| 127 |
+
fa = original_answer if is_safe else f"WARNING: This answer contains potentially unverified medical information.\n\n{original_answer}"
|
| 128 |
+
yield f"data: {json_lib.dumps({'step': 8, 'status': 'done'})}\n\n"
|
| 129 |
+
time.sleep(0.1)
|
| 130 |
+
|
| 131 |
+
ev = [{'text': p.text if hasattr(p, 'text') else str(p), 'qid': p.qid if hasattr(p, 'qid') else ''} for p in passages[:3]]
|
| 132 |
+
r = {'question': question, 'original_answer': original_answer, 'final_answer': fa, 'evidence': ev, 'claims': claims, 'claim_checks': claim_checks, 'max_risk_score': round(max_risk, 4), 'safe': is_safe, 'rejection_message': ''}
|
| 133 |
+
yield f"data: {json_lib.dumps({'complete': True, 'result': r})}\n\n"
|
| 134 |
+
except Exception as e:
|
| 135 |
+
yield f"data: {json_lib.dumps({'error': str(e)})}\n\n"
|
| 136 |
+
|
| 137 |
+
return Response(generate(), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', 'Connection': 'keep-alive'})
|
| 138 |
+
|
| 139 |
+
if __name__ == '__main__':
|
| 140 |
+
import os
|
| 141 |
+
port = int(os.environ.get('PORT', 7860))
|
| 142 |
+
app.run(debug=False, host='0.0.0.0', port=port)
|