aseelflihan commited on
Commit
2a2c039
·
0 Parent(s):

Deploy Bio-RAG

Browse files
.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)