Spaces:
Sleeping
Sleeping
Upload 12 files
Browse files- .gitignore +12 -0
- app.py +234 -0
- build_vector_db.py +212 -0
- data/conditions/anemia.txt +51 -0
- data/lab_markers/complete_blood_count.txt +42 -0
- data/lab_markers/metabolic_panel.txt +45 -0
- data/nutrition/iron_deficiency.txt +48 -0
- pdf_extractor.py +168 -0
- rag_engine.py +253 -0
- static/script.js +385 -0
- static/style.css +32 -0
- templates/index.html +322 -0
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*$py.class
|
| 4 |
+
*.so
|
| 5 |
+
.Python
|
| 6 |
+
venv/
|
| 7 |
+
env/
|
| 8 |
+
.env
|
| 9 |
+
.venv
|
| 10 |
+
chroma_db/
|
| 11 |
+
*.pdf
|
| 12 |
+
.DS_Store
|
app.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lab Report Decoder - Flask Application
|
| 3 |
+
Professional web interface for lab report analysis
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import Flask, render_template, request, jsonify, session
|
| 7 |
+
from werkzeug.utils import secure_filename
|
| 8 |
+
import os
|
| 9 |
+
import tempfile
|
| 10 |
+
import secrets
|
| 11 |
+
from pdf_extractor import LabReportExtractor
|
| 12 |
+
from rag_engine import LabReportRAG
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
app = Flask(__name__)
|
| 18 |
+
app.secret_key = os.getenv('SECRET_KEY', secrets.token_hex(16))
|
| 19 |
+
|
| 20 |
+
# Note: No OpenAI API key needed - using Hugging Face models!
|
| 21 |
+
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
| 22 |
+
app.config['UPLOAD_FOLDER'] = tempfile.gettempdir()
|
| 23 |
+
|
| 24 |
+
# Initialize RAG system (singleton)
|
| 25 |
+
rag_system = None
|
| 26 |
+
|
| 27 |
+
def get_rag_system():
|
| 28 |
+
"""Lazy load RAG system"""
|
| 29 |
+
global rag_system
|
| 30 |
+
if rag_system is None:
|
| 31 |
+
rag_system = LabReportRAG()
|
| 32 |
+
return rag_system
|
| 33 |
+
|
| 34 |
+
@app.route('/')
|
| 35 |
+
def index():
|
| 36 |
+
"""Main page"""
|
| 37 |
+
return render_template('index.html')
|
| 38 |
+
|
| 39 |
+
@app.route('/api/upload', methods=['POST'])
|
| 40 |
+
def upload_file():
|
| 41 |
+
"""Handle PDF upload and extraction"""
|
| 42 |
+
try:
|
| 43 |
+
if 'file' not in request.files:
|
| 44 |
+
return jsonify({'error': 'No file provided'}), 400
|
| 45 |
+
|
| 46 |
+
file = request.files['file']
|
| 47 |
+
|
| 48 |
+
if file.filename == '':
|
| 49 |
+
return jsonify({'error': 'No file selected'}), 400
|
| 50 |
+
|
| 51 |
+
if not file.filename.lower().endswith('.pdf'):
|
| 52 |
+
return jsonify({'error': 'Only PDF files are allowed'}), 400
|
| 53 |
+
|
| 54 |
+
# Save file temporarily
|
| 55 |
+
filename = secure_filename(file.filename)
|
| 56 |
+
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
| 57 |
+
file.save(filepath)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
# Extract lab results
|
| 61 |
+
extractor = LabReportExtractor()
|
| 62 |
+
results = extractor.extract_from_pdf(filepath)
|
| 63 |
+
|
| 64 |
+
if not results:
|
| 65 |
+
return jsonify({'error': 'No lab results found in PDF'}), 400
|
| 66 |
+
|
| 67 |
+
# Convert to JSON-serializable format
|
| 68 |
+
results_data = [
|
| 69 |
+
{
|
| 70 |
+
'test_name': r.test_name,
|
| 71 |
+
'value': r.value,
|
| 72 |
+
'unit': r.unit,
|
| 73 |
+
'reference_range': r.reference_range,
|
| 74 |
+
'status': r.status
|
| 75 |
+
}
|
| 76 |
+
for r in results
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
# Store in session
|
| 80 |
+
session['results'] = results_data
|
| 81 |
+
|
| 82 |
+
return jsonify({
|
| 83 |
+
'success': True,
|
| 84 |
+
'results': results_data,
|
| 85 |
+
'count': len(results_data)
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
finally:
|
| 89 |
+
# Clean up temp file
|
| 90 |
+
if os.path.exists(filepath):
|
| 91 |
+
os.remove(filepath)
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
return jsonify({'error': str(e)}), 500
|
| 95 |
+
|
| 96 |
+
@app.route('/api/explain', methods=['POST'])
|
| 97 |
+
def explain_results():
|
| 98 |
+
"""Generate explanations for lab results"""
|
| 99 |
+
try:
|
| 100 |
+
results_data = session.get('results')
|
| 101 |
+
|
| 102 |
+
if not results_data:
|
| 103 |
+
return jsonify({'error': 'No results found. Please upload a PDF first.'}), 400
|
| 104 |
+
|
| 105 |
+
# Convert back to LabResult objects
|
| 106 |
+
from pdf_extractor import LabResult
|
| 107 |
+
results = [
|
| 108 |
+
LabResult(
|
| 109 |
+
test_name=r['test_name'],
|
| 110 |
+
value=r['value'],
|
| 111 |
+
unit=r['unit'],
|
| 112 |
+
reference_range=r['reference_range'],
|
| 113 |
+
status=r['status']
|
| 114 |
+
)
|
| 115 |
+
for r in results_data
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
# Generate explanations
|
| 119 |
+
rag = get_rag_system()
|
| 120 |
+
explanations = rag.explain_all_results(results)
|
| 121 |
+
|
| 122 |
+
return jsonify({
|
| 123 |
+
'success': True,
|
| 124 |
+
'explanations': explanations
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
return jsonify({'error': str(e)}), 500
|
| 129 |
+
|
| 130 |
+
@app.route('/api/ask', methods=['POST'])
|
| 131 |
+
def ask_question():
|
| 132 |
+
"""Answer follow-up questions"""
|
| 133 |
+
try:
|
| 134 |
+
data = request.get_json()
|
| 135 |
+
question = data.get('question', '').strip()
|
| 136 |
+
|
| 137 |
+
if not question:
|
| 138 |
+
return jsonify({'error': 'No question provided'}), 400
|
| 139 |
+
|
| 140 |
+
results_data = session.get('results')
|
| 141 |
+
|
| 142 |
+
if not results_data:
|
| 143 |
+
return jsonify({'error': 'No results found. Please upload a PDF first.'}), 400
|
| 144 |
+
|
| 145 |
+
# Convert back to LabResult objects
|
| 146 |
+
from pdf_extractor import LabResult
|
| 147 |
+
results = [
|
| 148 |
+
LabResult(
|
| 149 |
+
test_name=r['test_name'],
|
| 150 |
+
value=r['value'],
|
| 151 |
+
unit=r['unit'],
|
| 152 |
+
reference_range=r['reference_range'],
|
| 153 |
+
status=r['status']
|
| 154 |
+
)
|
| 155 |
+
for r in results_data
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
# Get answer
|
| 159 |
+
rag = get_rag_system()
|
| 160 |
+
answer = rag.answer_followup_question(question, results)
|
| 161 |
+
|
| 162 |
+
return jsonify({
|
| 163 |
+
'success': True,
|
| 164 |
+
'question': question,
|
| 165 |
+
'answer': answer
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
return jsonify({'error': str(e)}), 500
|
| 170 |
+
|
| 171 |
+
@app.route('/api/summary', methods=['GET'])
|
| 172 |
+
def get_summary():
|
| 173 |
+
"""Generate overall summary"""
|
| 174 |
+
try:
|
| 175 |
+
results_data = session.get('results')
|
| 176 |
+
|
| 177 |
+
if not results_data:
|
| 178 |
+
return jsonify({'error': 'No results found. Please upload a PDF first.'}), 400
|
| 179 |
+
|
| 180 |
+
# Convert back to LabResult objects
|
| 181 |
+
from pdf_extractor import LabResult
|
| 182 |
+
results = [
|
| 183 |
+
LabResult(
|
| 184 |
+
test_name=r['test_name'],
|
| 185 |
+
value=r['value'],
|
| 186 |
+
unit=r['unit'],
|
| 187 |
+
reference_range=r['reference_range'],
|
| 188 |
+
status=r['status']
|
| 189 |
+
)
|
| 190 |
+
for r in results_data
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
# Generate summary
|
| 194 |
+
rag = get_rag_system()
|
| 195 |
+
summary = rag.generate_summary(results)
|
| 196 |
+
|
| 197 |
+
# Calculate statistics
|
| 198 |
+
stats = {
|
| 199 |
+
'total': len(results),
|
| 200 |
+
'normal': sum(1 for r in results if r.status == 'normal'),
|
| 201 |
+
'high': sum(1 for r in results if r.status == 'high'),
|
| 202 |
+
'low': sum(1 for r in results if r.status == 'low'),
|
| 203 |
+
'unknown': sum(1 for r in results if r.status == 'unknown')
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return jsonify({
|
| 207 |
+
'success': True,
|
| 208 |
+
'summary': summary,
|
| 209 |
+
'stats': stats
|
| 210 |
+
})
|
| 211 |
+
|
| 212 |
+
except Exception as e:
|
| 213 |
+
return jsonify({'error': str(e)}), 500
|
| 214 |
+
|
| 215 |
+
@app.route('/api/clear', methods=['POST'])
|
| 216 |
+
def clear_session():
|
| 217 |
+
"""Clear session data"""
|
| 218 |
+
session.clear()
|
| 219 |
+
return jsonify({'success': True})
|
| 220 |
+
|
| 221 |
+
@app.errorhandler(413)
|
| 222 |
+
def request_entity_too_large(error):
|
| 223 |
+
return jsonify({'error': 'File too large. Maximum size is 16MB.'}), 413
|
| 224 |
+
|
| 225 |
+
@app.errorhandler(500)
|
| 226 |
+
def internal_error(error):
|
| 227 |
+
return jsonify({'error': 'Internal server error'}), 500
|
| 228 |
+
|
| 229 |
+
if __name__ == '__main__':
|
| 230 |
+
if not(os.path.isdir('chroma_db/')):
|
| 231 |
+
os.system("python build_vector_db.py")
|
| 232 |
+
#any available port
|
| 233 |
+
port = int(os.environ.get("PORT", 5000))
|
| 234 |
+
app.run(host="0.0.0.0", port=port)
|
build_vector_db.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build Vector Database for Lab Report Decoder
|
| 3 |
+
Uses Hugging Face sentence-transformers for embeddings
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from sentence_transformers import SentenceTransformer
|
| 9 |
+
import chromadb
|
| 10 |
+
from chromadb.config import Settings
|
| 11 |
+
import glob
|
| 12 |
+
|
| 13 |
+
def load_documents_from_directory(directory: str) -> list:
|
| 14 |
+
"""Load all text files from a directory"""
|
| 15 |
+
documents = []
|
| 16 |
+
|
| 17 |
+
if not os.path.exists(directory):
|
| 18 |
+
print(f"β οΈ Directory not found: {directory}")
|
| 19 |
+
return documents
|
| 20 |
+
|
| 21 |
+
# Find all .txt files
|
| 22 |
+
txt_files = glob.glob(os.path.join(directory, "**", "*.txt"), recursive=True)
|
| 23 |
+
|
| 24 |
+
for filepath in txt_files:
|
| 25 |
+
try:
|
| 26 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 27 |
+
content = f.read()
|
| 28 |
+
if content.strip():
|
| 29 |
+
documents.append({
|
| 30 |
+
'content': content,
|
| 31 |
+
'source': filepath,
|
| 32 |
+
'filename': os.path.basename(filepath)
|
| 33 |
+
})
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"Error reading {filepath}: {e}")
|
| 36 |
+
|
| 37 |
+
return documents
|
| 38 |
+
|
| 39 |
+
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list:
|
| 40 |
+
"""Split text into overlapping chunks"""
|
| 41 |
+
chunks = []
|
| 42 |
+
start = 0
|
| 43 |
+
|
| 44 |
+
while start < len(text):
|
| 45 |
+
end = start + chunk_size
|
| 46 |
+
chunk = text[start:end]
|
| 47 |
+
|
| 48 |
+
# Try to break at sentence boundary
|
| 49 |
+
if end < len(text):
|
| 50 |
+
last_period = chunk.rfind('.')
|
| 51 |
+
last_newline = chunk.rfind('\n')
|
| 52 |
+
break_point = max(last_period, last_newline)
|
| 53 |
+
|
| 54 |
+
if break_point > chunk_size * 0.5: # Only if break point is reasonable
|
| 55 |
+
chunk = chunk[:break_point + 1]
|
| 56 |
+
end = start + break_point + 1
|
| 57 |
+
|
| 58 |
+
chunks.append(chunk.strip())
|
| 59 |
+
start = end - overlap
|
| 60 |
+
|
| 61 |
+
return chunks
|
| 62 |
+
|
| 63 |
+
def build_knowledge_base():
|
| 64 |
+
"""Build the vector database from medical documents"""
|
| 65 |
+
|
| 66 |
+
print("π Loading medical documents...")
|
| 67 |
+
|
| 68 |
+
# Load documents from data directory
|
| 69 |
+
data_dir = 'data/'
|
| 70 |
+
all_documents = []
|
| 71 |
+
|
| 72 |
+
if not os.path.exists(data_dir):
|
| 73 |
+
print(f"β οΈ Creating data directory: {data_dir}")
|
| 74 |
+
os.makedirs(data_dir, exist_ok=True)
|
| 75 |
+
os.makedirs(os.path.join(data_dir, 'lab_markers'), exist_ok=True)
|
| 76 |
+
os.makedirs(os.path.join(data_dir, 'nutrition'), exist_ok=True)
|
| 77 |
+
os.makedirs(os.path.join(data_dir, 'conditions'), exist_ok=True)
|
| 78 |
+
print("β οΈ Please add medical reference documents to the data/ folder")
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
# Load from all subdirectories
|
| 82 |
+
for subdir in ['lab_markers', 'nutrition', 'conditions']:
|
| 83 |
+
subdir_path = os.path.join(data_dir, subdir)
|
| 84 |
+
docs = load_documents_from_directory(subdir_path)
|
| 85 |
+
all_documents.extend(docs)
|
| 86 |
+
|
| 87 |
+
if not all_documents:
|
| 88 |
+
print("β οΈ No documents found in data/ directory")
|
| 89 |
+
print("Please add .txt files with medical information")
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
print(f"β
Loaded {len(all_documents)} documents")
|
| 93 |
+
|
| 94 |
+
# Chunk documents
|
| 95 |
+
print("βοΈ Splitting documents into chunks...")
|
| 96 |
+
all_chunks = []
|
| 97 |
+
all_metadata = []
|
| 98 |
+
|
| 99 |
+
for doc in all_documents:
|
| 100 |
+
chunks = chunk_text(doc['content'], chunk_size=1000, overlap=200)
|
| 101 |
+
for i, chunk in enumerate(chunks):
|
| 102 |
+
all_chunks.append(chunk)
|
| 103 |
+
all_metadata.append({
|
| 104 |
+
'source': doc['source'],
|
| 105 |
+
'filename': doc['filename'],
|
| 106 |
+
'chunk_id': i
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
print(f"β
Created {len(all_chunks)} text chunks")
|
| 110 |
+
|
| 111 |
+
# Load embedding model
|
| 112 |
+
print("π§ Loading embedding model (this may take a moment)...")
|
| 113 |
+
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 114 |
+
print("β
Embedding model loaded")
|
| 115 |
+
|
| 116 |
+
# Create embeddings
|
| 117 |
+
print("π Creating embeddings (this may take a few minutes)...")
|
| 118 |
+
embeddings = embedding_model.encode(
|
| 119 |
+
all_chunks,
|
| 120 |
+
show_progress_bar=True,
|
| 121 |
+
convert_to_numpy=True
|
| 122 |
+
)
|
| 123 |
+
print(f"β
Created {len(embeddings)} embeddings")
|
| 124 |
+
|
| 125 |
+
# Create ChromaDB collection
|
| 126 |
+
print("πΎ Building ChromaDB vector store...")
|
| 127 |
+
|
| 128 |
+
# Initialize client
|
| 129 |
+
db_path = "./chroma_db"
|
| 130 |
+
client = chromadb.PersistentClient(path=db_path)
|
| 131 |
+
|
| 132 |
+
# Delete existing collection if it exists
|
| 133 |
+
try:
|
| 134 |
+
client.delete_collection("lab_reports")
|
| 135 |
+
print("ποΈ Deleted existing collection")
|
| 136 |
+
except:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
# Create new collection
|
| 140 |
+
collection = client.create_collection(
|
| 141 |
+
name="lab_reports",
|
| 142 |
+
metadata={"description": "Medical lab report information"}
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Add documents in batches
|
| 146 |
+
batch_size = 100
|
| 147 |
+
for i in range(0, len(all_chunks), batch_size):
|
| 148 |
+
batch_chunks = all_chunks[i:i + batch_size]
|
| 149 |
+
batch_embeddings = embeddings[i:i + batch_size].tolist()
|
| 150 |
+
batch_ids = [f"doc_{j}" for j in range(i, i + len(batch_chunks))]
|
| 151 |
+
batch_metadata = all_metadata[i:i + batch_size]
|
| 152 |
+
|
| 153 |
+
collection.add(
|
| 154 |
+
documents=batch_chunks,
|
| 155 |
+
embeddings=batch_embeddings,
|
| 156 |
+
ids=batch_ids,
|
| 157 |
+
metadatas=batch_metadata
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
print("β
Vector database built successfully!")
|
| 161 |
+
print(f"π Database location: {db_path}")
|
| 162 |
+
print(f"π Total vectors: {len(all_chunks)}")
|
| 163 |
+
|
| 164 |
+
return collection
|
| 165 |
+
|
| 166 |
+
def test_retrieval(collection):
|
| 167 |
+
"""Test the retrieval system"""
|
| 168 |
+
if collection is None:
|
| 169 |
+
print("\nβ οΈ No collection to test")
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
print("\nπ Testing retrieval system...")
|
| 173 |
+
|
| 174 |
+
# Load embedding model for queries
|
| 175 |
+
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 176 |
+
|
| 177 |
+
test_queries = [
|
| 178 |
+
"What does low hemoglobin mean?",
|
| 179 |
+
"What foods are high in iron?",
|
| 180 |
+
"Normal range for glucose"
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
for query in test_queries:
|
| 184 |
+
print(f"\nπ Query: {query}")
|
| 185 |
+
|
| 186 |
+
# Create query embedding
|
| 187 |
+
query_embedding = embedding_model.encode(query).tolist()
|
| 188 |
+
|
| 189 |
+
# Search
|
| 190 |
+
results = collection.query(
|
| 191 |
+
query_embeddings=[query_embedding],
|
| 192 |
+
n_results=2
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
if results and results['documents']:
|
| 196 |
+
print(f" β
Found {len(results['documents'][0])} relevant documents")
|
| 197 |
+
print(f" π Top result preview: {results['documents'][0][0][:150]}...")
|
| 198 |
+
else:
|
| 199 |
+
print(" β No results found")
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
print("π Building Lab Report Decoder Vector Database\n")
|
| 203 |
+
|
| 204 |
+
# Build the database
|
| 205 |
+
collection = build_knowledge_base()
|
| 206 |
+
|
| 207 |
+
# Test it
|
| 208 |
+
if collection:
|
| 209 |
+
test_retrieval(collection)
|
| 210 |
+
print("\nπ Setup complete! You can now run the Flask application.")
|
| 211 |
+
else:
|
| 212 |
+
print("\nβ οΈ Please add medical documents to the data/ folder and run again.")
|
data/conditions/anemia.txt
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Understanding Anemia
|
| 2 |
+
|
| 3 |
+
WHAT IS ANEMIA?
|
| 4 |
+
Anemia occurs when you don't have enough healthy red blood cells to carry adequate oxygen to your body's tissues. This makes you feel tired and weak.
|
| 5 |
+
|
| 6 |
+
TYPES OF ANEMIA:
|
| 7 |
+
|
| 8 |
+
Iron-Deficiency Anemia (Most Common)
|
| 9 |
+
- Causes: Not enough iron in diet, blood loss, pregnancy, poor absorption
|
| 10 |
+
- Symptoms: Fatigue, weakness, pale skin, cold hands and feet, brittle nails
|
| 11 |
+
- Treatment: Iron-rich diet, iron supplements, address underlying cause
|
| 12 |
+
|
| 13 |
+
Vitamin Deficiency Anemia
|
| 14 |
+
- Causes: Lack of vitamin B12 or folate
|
| 15 |
+
- Symptoms: Similar to iron deficiency, plus numbness, balance problems
|
| 16 |
+
- Treatment: Dietary changes, supplements, B12 injections if needed
|
| 17 |
+
|
| 18 |
+
Anemia of Chronic Disease
|
| 19 |
+
- Causes: Cancer, kidney disease, rheumatoid arthritis, Crohn's disease
|
| 20 |
+
- Treatment: Treat underlying condition, may need erythropoietin therapy
|
| 21 |
+
|
| 22 |
+
SYMPTOMS TO WATCH FOR:
|
| 23 |
+
- Persistent fatigue and weakness
|
| 24 |
+
- Pale or yellowish skin
|
| 25 |
+
- Irregular heartbeat
|
| 26 |
+
- Shortness of breath
|
| 27 |
+
- Dizziness or lightheadedness
|
| 28 |
+
- Chest pain
|
| 29 |
+
- Cold hands and feet
|
| 30 |
+
- Headaches
|
| 31 |
+
|
| 32 |
+
WHEN TO SEE A DOCTOR:
|
| 33 |
+
- Severe fatigue interfering with daily activities
|
| 34 |
+
- Persistent symptoms despite dietary changes
|
| 35 |
+
- Family history of inherited anemias
|
| 36 |
+
- Heavy menstrual bleeding
|
| 37 |
+
- Signs of internal bleeding
|
| 38 |
+
|
| 39 |
+
PREVENTION:
|
| 40 |
+
- Eat iron-rich foods regularly
|
| 41 |
+
- Include vitamin C to help absorption
|
| 42 |
+
- Don't skip meals
|
| 43 |
+
- If vegetarian, plan meals carefully for adequate iron
|
| 44 |
+
- Consider supplementation if at risk (pregnant women, heavy periods)
|
| 45 |
+
|
| 46 |
+
LIFESTYLE TIPS:
|
| 47 |
+
- Pace yourself - take breaks when tired
|
| 48 |
+
- Stay hydrated
|
| 49 |
+
- Avoid excessive exercise until levels improve
|
| 50 |
+
- Manage stress
|
| 51 |
+
- Get adequate sleep
|
data/lab_markers/complete_blood_count.txt
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Complete Blood Count (CBC)
|
| 2 |
+
|
| 3 |
+
A Complete Blood Count measures several components of your blood to assess overall health and detect disorders.
|
| 4 |
+
|
| 5 |
+
WHITE BLOOD CELLS (WBC/Leukocytes)
|
| 6 |
+
Normal Range: 4,500-11,000 cells/mcL
|
| 7 |
+
What it measures: White blood cells fight infections
|
| 8 |
+
High WBC: May indicate infection, inflammation, leukemia, stress, or tissue damage
|
| 9 |
+
Low WBC: May indicate bone marrow problems, autoimmune disorders, or certain medications
|
| 10 |
+
Dietary support: Vitamin C (citrus, bell peppers), Vitamin E (nuts, seeds), zinc (shellfish, legumes)
|
| 11 |
+
|
| 12 |
+
RED BLOOD CELLS (RBC/Erythrocytes)
|
| 13 |
+
Normal Range:
|
| 14 |
+
- Men: 4.7-6.1 million cells/mcL
|
| 15 |
+
- Women: 4.2-5.4 million cells/mcL
|
| 16 |
+
What it measures: Red blood cells carry oxygen throughout your body
|
| 17 |
+
High RBC: May indicate dehydration, lung disease, or polycythemia
|
| 18 |
+
Low RBC: May indicate anemia, blood loss, or nutritional deficiencies
|
| 19 |
+
|
| 20 |
+
HEMOGLOBIN (Hgb)
|
| 21 |
+
Normal Range:
|
| 22 |
+
- Men: 13.5-17.5 g/dL
|
| 23 |
+
- Women: 12.0-15.5 g/dL
|
| 24 |
+
What it measures: The oxygen-carrying protein in red blood cells
|
| 25 |
+
High Hemoglobin: Dehydration, lung disease, smoking, high altitude
|
| 26 |
+
Low Hemoglobin (Anemia): Iron deficiency, B12 deficiency, chronic disease, blood loss
|
| 27 |
+
Foods to increase: Red meat, liver, spinach, lentils, fortified cereals, pumpkin seeds
|
| 28 |
+
Take with Vitamin C to enhance iron absorption
|
| 29 |
+
|
| 30 |
+
HEMATOCRIT (Hct)
|
| 31 |
+
Normal Range:
|
| 32 |
+
- Men: 38.3-48.6%
|
| 33 |
+
- Women: 35.5-44.9%
|
| 34 |
+
What it measures: Percentage of blood volume made up of red blood cells
|
| 35 |
+
High: Dehydration, lung disease, polycythemia
|
| 36 |
+
Low: Anemia, blood loss, malnutrition
|
| 37 |
+
|
| 38 |
+
PLATELETS
|
| 39 |
+
Normal Range: 150,000-400,000 platelets/mcL
|
| 40 |
+
What it measures: Blood clotting cells
|
| 41 |
+
High: May indicate inflammation, iron deficiency, or bone marrow disorders
|
| 42 |
+
Low: May indicate bleeding disorders, autoimmune conditions, or certain medications
|
data/lab_markers/metabolic_panel.txt
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Comprehensive Metabolic Panel (CMP)
|
| 2 |
+
|
| 3 |
+
GLUCOSE (Blood Sugar)
|
| 4 |
+
Normal Range: 70-100 mg/dL (fasting)
|
| 5 |
+
What it measures: Amount of sugar in your blood
|
| 6 |
+
High (Hyperglycemia): May indicate diabetes, prediabetes, stress, or certain medications
|
| 7 |
+
- Symptoms: Increased thirst, frequent urination, fatigue, blurred vision
|
| 8 |
+
- Dietary changes: Reduce refined carbs, increase fiber, choose whole grains
|
| 9 |
+
- Lifestyle: Regular exercise, maintain healthy weight, manage stress
|
| 10 |
+
Low (Hypoglycemia): May indicate insulin overproduction or certain medications
|
| 11 |
+
- Symptoms: Shakiness, sweating, confusion, hunger
|
| 12 |
+
- What to do: Eat small frequent meals, balance carbs with protein
|
| 13 |
+
|
| 14 |
+
CALCIUM
|
| 15 |
+
Normal Range: 8.5-10.5 mg/dL
|
| 16 |
+
What it measures: Calcium levels for bone health and muscle function
|
| 17 |
+
High: May indicate hyperparathyroidism, certain cancers, or excess vitamin D
|
| 18 |
+
Low: May indicate vitamin D deficiency, malabsorption, or kidney disease
|
| 19 |
+
Foods rich in calcium: Dairy products, leafy greens, fortified plant milk, sardines with bones
|
| 20 |
+
|
| 21 |
+
SODIUM
|
| 22 |
+
Normal Range: 135-145 mEq/L
|
| 23 |
+
What it measures: Electrolyte balance and hydration
|
| 24 |
+
High: Dehydration, excess salt intake, kidney problems
|
| 25 |
+
Low: Overhydration, heart failure, kidney disease, certain medications
|
| 26 |
+
Balance tips: Moderate salt intake, stay hydrated, eat potassium-rich foods
|
| 27 |
+
|
| 28 |
+
POTASSIUM
|
| 29 |
+
Normal Range: 3.5-5.0 mEq/L
|
| 30 |
+
What it measures: Electrolyte important for heart and muscle function
|
| 31 |
+
High: Kidney disease, certain medications, excess supplementation
|
| 32 |
+
Low: Diuretics, vomiting, diarrhea, poor diet
|
| 33 |
+
Foods high in potassium: Bananas, potatoes, spinach, beans, avocados
|
| 34 |
+
|
| 35 |
+
BUN (Blood Urea Nitrogen)
|
| 36 |
+
Normal Range: 7-20 mg/dL
|
| 37 |
+
What it measures: Kidney function
|
| 38 |
+
High: Dehydration, kidney disease, high protein diet
|
| 39 |
+
Low: Liver disease, malnutrition, overhydration
|
| 40 |
+
|
| 41 |
+
CREATININE
|
| 42 |
+
Normal Range: 0.7-1.3 mg/dL (men), 0.6-1.1 mg/dL (women)
|
| 43 |
+
What it measures: Kidney function
|
| 44 |
+
High: Kidney disease, dehydration, muscle injury
|
| 45 |
+
Low: Low muscle mass, malnutrition
|
data/nutrition/iron_deficiency.txt
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Iron Deficiency and Dietary Solutions
|
| 2 |
+
|
| 3 |
+
IRON-RICH FOODS
|
| 4 |
+
|
| 5 |
+
Heme Iron (Better Absorbed - from animal sources):
|
| 6 |
+
- Red meat (beef, lamb): 2-3 mg per 3 oz
|
| 7 |
+
- Liver (beef, chicken): 5-7 mg per 3 oz
|
| 8 |
+
- Oysters and clams: 8-24 mg per 3 oz
|
| 9 |
+
- Sardines: 2-3 mg per 3 oz
|
| 10 |
+
- Dark meat poultry: 1-2 mg per 3 oz
|
| 11 |
+
|
| 12 |
+
Non-Heme Iron (from plant sources):
|
| 13 |
+
- Lentils: 3 mg per 1/2 cup cooked
|
| 14 |
+
- Spinach: 3 mg per 1/2 cup cooked
|
| 15 |
+
- Tofu: 3 mg per 1/2 cup
|
| 16 |
+
- Beans (kidney, black): 2-3 mg per 1/2 cup
|
| 17 |
+
- Fortified cereals: 4-18 mg per serving
|
| 18 |
+
- Pumpkin seeds: 4 mg per 1 oz
|
| 19 |
+
- Quinoa: 2 mg per cup cooked
|
| 20 |
+
- Dark chocolate: 3 mg per 1 oz
|
| 21 |
+
|
| 22 |
+
ENHANCING IRON ABSORPTION
|
| 23 |
+
|
| 24 |
+
Pair iron-rich foods with Vitamin C:
|
| 25 |
+
- Citrus fruits (oranges, grapefruit, lemon)
|
| 26 |
+
- Bell peppers
|
| 27 |
+
- Strawberries
|
| 28 |
+
- Tomatoes
|
| 29 |
+
- Broccoli
|
| 30 |
+
Example: Spinach salad with strawberries and orange segments
|
| 31 |
+
|
| 32 |
+
FOODS THAT BLOCK IRON ABSORPTION (avoid with iron-rich meals):
|
| 33 |
+
- Coffee and tea (wait 1-2 hours)
|
| 34 |
+
- Dairy products (calcium competes with iron)
|
| 35 |
+
- High-fiber foods in excess
|
| 36 |
+
- Antacids
|
| 37 |
+
|
| 38 |
+
MEAL IDEAS FOR IRON BOOST:
|
| 39 |
+
Breakfast: Fortified cereal with strawberries and orange juice
|
| 40 |
+
Lunch: Spinach salad with chickpeas, bell peppers, and lemon dressing
|
| 41 |
+
Dinner: Lean beef with broccoli and tomato sauce
|
| 42 |
+
Snack: Pumpkin seeds with dried apricots
|
| 43 |
+
|
| 44 |
+
SUPPLEMENTATION TIPS:
|
| 45 |
+
- Take iron supplements on an empty stomach with vitamin C
|
| 46 |
+
- May cause constipation - increase fiber and water
|
| 47 |
+
- Avoid taking with calcium supplements or antacids
|
| 48 |
+
- Consult doctor for proper dosage
|
pdf_extractor.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF Extraction Module for Lab Reports
|
| 3 |
+
Extracts lab test names, values, and ranges from uploaded PDF files
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pdfplumber
|
| 7 |
+
import re
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class LabResult:
|
| 13 |
+
"""Represents a single lab test result"""
|
| 14 |
+
test_name: str
|
| 15 |
+
value: str
|
| 16 |
+
unit: str
|
| 17 |
+
reference_range: str
|
| 18 |
+
status: str # 'normal', 'high', 'low', 'unknown'
|
| 19 |
+
|
| 20 |
+
class LabReportExtractor:
|
| 21 |
+
"""Extract structured data from lab report PDFs"""
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
# Common lab test patterns
|
| 25 |
+
self.test_patterns = [
|
| 26 |
+
r'(Hemoglobin|Hgb|Hb)\s*:?\s*([\d.]+)\s*([a-zA-Z/]+)?\s*(?:Ref\.?\s*Range:?\s*)?([\d.\-\s]+)',
|
| 27 |
+
r'(WBC|White Blood Cell|Leukocyte)\s*:?\s*([\d.]+)\s*([a-zA-Z/]+)?\s*(?:Ref\.?\s*Range:?\s*)?([\d.\-\s]+)',
|
| 28 |
+
r'(Glucose|Blood Sugar)\s*:?\s*([\d.]+)\s*([a-zA-Z/]+)?\s*(?:Ref\.?\s*Range:?\s*)?([\d.\-\s]+)',
|
| 29 |
+
r'(Iron|Ferritin)\s*:?\s*([\d.]+)\s*([a-zA-Z/]+)?\s*(?:Ref\.?\s*Range:?\s*)?([\d.\-\s]+)',
|
| 30 |
+
r'(Cholesterol|LDL|HDL)\s*:?\s*([\d.]+)\s*([a-zA-Z/]+)?\s*(?:Ref\.?\s*Range:?\s*)?([\d.\-\s]+)',
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
def extract_from_pdf(self, pdf_path: str) -> List[LabResult]:
|
| 34 |
+
"""Extract lab results from PDF file"""
|
| 35 |
+
results = []
|
| 36 |
+
|
| 37 |
+
with pdfplumber.open(pdf_path) as pdf:
|
| 38 |
+
for page in pdf.pages:
|
| 39 |
+
text = page.extract_text()
|
| 40 |
+
|
| 41 |
+
# Try to extract tables first (more structured)
|
| 42 |
+
tables = page.extract_tables()
|
| 43 |
+
if tables:
|
| 44 |
+
results.extend(self._parse_tables(tables))
|
| 45 |
+
|
| 46 |
+
# Fall back to pattern matching
|
| 47 |
+
results.extend(self._parse_text(text))
|
| 48 |
+
|
| 49 |
+
# Remove duplicates
|
| 50 |
+
unique_results = self._deduplicate_results(results)
|
| 51 |
+
|
| 52 |
+
return unique_results
|
| 53 |
+
|
| 54 |
+
def _parse_tables(self, tables: List) -> List[LabResult]:
|
| 55 |
+
"""Parse lab results from extracted tables"""
|
| 56 |
+
results = []
|
| 57 |
+
|
| 58 |
+
for table in tables:
|
| 59 |
+
if not table or len(table) < 2:
|
| 60 |
+
continue
|
| 61 |
+
|
| 62 |
+
# Assume first row is header
|
| 63 |
+
headers = [h.lower() if h else '' for h in table[0]]
|
| 64 |
+
|
| 65 |
+
# Find relevant columns
|
| 66 |
+
test_col = self._find_column(headers, ['test', 'name', 'component'])
|
| 67 |
+
value_col = self._find_column(headers, ['value', 'result'])
|
| 68 |
+
unit_col = self._find_column(headers, ['unit', 'units'])
|
| 69 |
+
range_col = self._find_column(headers, ['range', 'reference', 'normal'])
|
| 70 |
+
|
| 71 |
+
# Parse data rows
|
| 72 |
+
for row in table[1:]:
|
| 73 |
+
if not row or len(row) <= max(test_col or 0, value_col or 0):
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
test_name = row[test_col] if test_col is not None else ''
|
| 77 |
+
value = row[value_col] if value_col is not None else ''
|
| 78 |
+
unit = row[unit_col] if unit_col is not None else ''
|
| 79 |
+
ref_range = row[range_col] if range_col is not None else ''
|
| 80 |
+
|
| 81 |
+
if test_name and value:
|
| 82 |
+
status = self._determine_status(value, ref_range)
|
| 83 |
+
results.append(LabResult(
|
| 84 |
+
test_name=test_name.strip(),
|
| 85 |
+
value=str(value).strip(),
|
| 86 |
+
unit=str(unit).strip() if unit else '',
|
| 87 |
+
reference_range=str(ref_range).strip() if ref_range else '',
|
| 88 |
+
status=status
|
| 89 |
+
))
|
| 90 |
+
|
| 91 |
+
return results
|
| 92 |
+
|
| 93 |
+
def _parse_text(self, text: str) -> List[LabResult]:
|
| 94 |
+
"""Parse lab results using regex patterns"""
|
| 95 |
+
results = []
|
| 96 |
+
|
| 97 |
+
for pattern in self.test_patterns:
|
| 98 |
+
matches = re.finditer(pattern, text, re.IGNORECASE)
|
| 99 |
+
for match in matches:
|
| 100 |
+
groups = match.groups()
|
| 101 |
+
if len(groups) >= 2:
|
| 102 |
+
test_name = groups[0]
|
| 103 |
+
value = groups[1]
|
| 104 |
+
unit = groups[2] if len(groups) > 2 else ''
|
| 105 |
+
ref_range = groups[3] if len(groups) > 3 else ''
|
| 106 |
+
|
| 107 |
+
status = self._determine_status(value, ref_range)
|
| 108 |
+
results.append(LabResult(
|
| 109 |
+
test_name=test_name,
|
| 110 |
+
value=value,
|
| 111 |
+
unit=unit or '',
|
| 112 |
+
reference_range=ref_range or '',
|
| 113 |
+
status=status
|
| 114 |
+
))
|
| 115 |
+
|
| 116 |
+
return results
|
| 117 |
+
|
| 118 |
+
def _find_column(self, headers: List[str], keywords: List[str]) -> Optional[int]:
|
| 119 |
+
"""Find column index by keywords"""
|
| 120 |
+
for i, header in enumerate(headers):
|
| 121 |
+
for keyword in keywords:
|
| 122 |
+
if keyword in header:
|
| 123 |
+
return i
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
def _determine_status(self, value: str, ref_range: str) -> str:
|
| 127 |
+
"""Determine if value is normal, high, or low"""
|
| 128 |
+
try:
|
| 129 |
+
val = float(value.replace(',', ''))
|
| 130 |
+
|
| 131 |
+
# Parse reference range
|
| 132 |
+
range_match = re.search(r'([\d.]+)\s*-\s*([\d.]+)', ref_range)
|
| 133 |
+
if range_match:
|
| 134 |
+
low = float(range_match.group(1))
|
| 135 |
+
high = float(range_match.group(2))
|
| 136 |
+
|
| 137 |
+
if val < low:
|
| 138 |
+
return 'low'
|
| 139 |
+
elif val > high:
|
| 140 |
+
return 'high'
|
| 141 |
+
else:
|
| 142 |
+
return 'normal'
|
| 143 |
+
except (ValueError, AttributeError):
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
return 'unknown'
|
| 147 |
+
|
| 148 |
+
def _deduplicate_results(self, results: List[LabResult]) -> List[LabResult]:
|
| 149 |
+
"""Remove duplicate test results"""
|
| 150 |
+
seen = set()
|
| 151 |
+
unique = []
|
| 152 |
+
|
| 153 |
+
for result in results:
|
| 154 |
+
key = (result.test_name.lower(), result.value)
|
| 155 |
+
if key not in seen:
|
| 156 |
+
seen.add(key)
|
| 157 |
+
unique.append(result)
|
| 158 |
+
|
| 159 |
+
return unique
|
| 160 |
+
|
| 161 |
+
# Example usage
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
extractor = LabReportExtractor()
|
| 164 |
+
results = extractor.extract_from_pdf("sample_lab_report.pdf")
|
| 165 |
+
|
| 166 |
+
for result in results:
|
| 167 |
+
print(f"{result.test_name}: {result.value} {result.unit} [{result.status}]")
|
| 168 |
+
print(f" Reference: {result.reference_range}")
|
rag_engine.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RAG Query Engine for Lab Report Decoder
|
| 3 |
+
Uses Hugging Face models for embeddings and generation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
from transformers import pipeline, AutoTokenizer, AutoModelForCausalLM
|
| 8 |
+
import chromadb
|
| 9 |
+
from chromadb.config import Settings
|
| 10 |
+
from typing import List, Dict
|
| 11 |
+
from pdf_extractor import LabResult
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
class LabReportRAG:
|
| 15 |
+
"""RAG system for explaining lab results using Hugging Face models"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, db_path: str = "./chroma_db"):
|
| 18 |
+
"""Initialize the RAG system with Hugging Face models"""
|
| 19 |
+
|
| 20 |
+
print("π Loading Hugging Face models...")
|
| 21 |
+
|
| 22 |
+
# Use smaller, faster models for embeddings
|
| 23 |
+
self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 24 |
+
|
| 25 |
+
# Use a medical-focused or general LLM
|
| 26 |
+
# Options:
|
| 27 |
+
# - "microsoft/Phi-3-mini-4k-instruct" (good balance)
|
| 28 |
+
# - "google/flan-t5-base" (lighter)
|
| 29 |
+
# - "meta-llama/Llama-2-7b-chat-hf" (requires auth)
|
| 30 |
+
|
| 31 |
+
model_name = "microsoft/Phi-3-mini-4k-instruct"
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
| 35 |
+
self.llm = AutoModelForCausalLM.from_pretrained(
|
| 36 |
+
model_name,
|
| 37 |
+
trust_remote_code=True,
|
| 38 |
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
| 39 |
+
device_map="auto" if torch.cuda.is_available() else None
|
| 40 |
+
)
|
| 41 |
+
print(f"β
Loaded model: {model_name}")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"β οΈ Could not load {model_name}, falling back to simpler model")
|
| 44 |
+
# Fallback to lighter model
|
| 45 |
+
self.text_generator = pipeline(
|
| 46 |
+
"text-generation",
|
| 47 |
+
model="google/flan-t5-base",
|
| 48 |
+
max_length=512
|
| 49 |
+
)
|
| 50 |
+
self.llm = None
|
| 51 |
+
|
| 52 |
+
# Load vector store
|
| 53 |
+
try:
|
| 54 |
+
self.client = chromadb.PersistentClient(path=db_path)
|
| 55 |
+
self.collection = self.client.get_collection("lab_reports")
|
| 56 |
+
print("β
Vector database loaded")
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"β οΈ No vector database found. Please run build_vector_db.py first.")
|
| 59 |
+
self.collection = None
|
| 60 |
+
|
| 61 |
+
def _generate_with_phi(self, prompt: str, max_tokens: int = 512) -> str:
|
| 62 |
+
"""Generate text using Phi-3 model"""
|
| 63 |
+
inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True, max_length=2048)
|
| 64 |
+
|
| 65 |
+
if torch.cuda.is_available():
|
| 66 |
+
inputs = {k: v.to('cuda') for k, v in inputs.items()}
|
| 67 |
+
|
| 68 |
+
outputs = self.llm.generate(
|
| 69 |
+
**inputs,
|
| 70 |
+
max_new_tokens=max_tokens,
|
| 71 |
+
temperature=0.7,
|
| 72 |
+
do_sample=True,
|
| 73 |
+
top_p=0.9
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 77 |
+
# Remove the prompt from response
|
| 78 |
+
response = response.replace(prompt, "").strip()
|
| 79 |
+
return response
|
| 80 |
+
|
| 81 |
+
def _generate_with_fallback(self, prompt: str) -> str:
|
| 82 |
+
"""Generate text using fallback pipeline"""
|
| 83 |
+
result = self.text_generator(prompt, max_length=512, num_return_sequences=1)
|
| 84 |
+
return result[0]['generated_text']
|
| 85 |
+
|
| 86 |
+
def _generate_text(self, prompt: str) -> str:
|
| 87 |
+
"""Generate text using available model"""
|
| 88 |
+
try:
|
| 89 |
+
if self.llm is not None:
|
| 90 |
+
return self._generate_with_phi(prompt)
|
| 91 |
+
else:
|
| 92 |
+
return self._generate_with_fallback(prompt)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
print(f"Generation error: {e}")
|
| 95 |
+
return "Sorry, I encountered an error generating the explanation."
|
| 96 |
+
|
| 97 |
+
def _retrieve_context(self, query: str, k: int = 3) -> str:
|
| 98 |
+
"""Retrieve relevant context from vector database"""
|
| 99 |
+
if self.collection is None:
|
| 100 |
+
return "No medical reference data available."
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
# Create query embedding
|
| 104 |
+
query_embedding = self.embedding_model.encode(query).tolist()
|
| 105 |
+
|
| 106 |
+
# Query the collection
|
| 107 |
+
results = self.collection.query(
|
| 108 |
+
query_embeddings=[query_embedding],
|
| 109 |
+
n_results=k
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Combine documents
|
| 113 |
+
if results and results['documents']:
|
| 114 |
+
context = "\n\n".join(results['documents'][0])
|
| 115 |
+
return context
|
| 116 |
+
else:
|
| 117 |
+
return "No relevant information found."
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f"Retrieval error: {e}")
|
| 120 |
+
return "Error retrieving medical information."
|
| 121 |
+
|
| 122 |
+
def explain_result(self, result: LabResult) -> str:
|
| 123 |
+
"""Generate explanation for a single lab result"""
|
| 124 |
+
|
| 125 |
+
# Retrieve relevant context
|
| 126 |
+
query = f"{result.test_name} {result.status} meaning causes treatment"
|
| 127 |
+
context = self._retrieve_context(query, k=3)
|
| 128 |
+
|
| 129 |
+
# Create prompt
|
| 130 |
+
prompt = f"""You are a helpful medical assistant. Explain this lab result in simple terms.
|
| 131 |
+
|
| 132 |
+
Medical Information:
|
| 133 |
+
{context}
|
| 134 |
+
|
| 135 |
+
Lab Test: {result.test_name}
|
| 136 |
+
Value: {result.value} {result.unit}
|
| 137 |
+
Reference Range: {result.reference_range}
|
| 138 |
+
Status: {result.status}
|
| 139 |
+
|
| 140 |
+
Please explain:
|
| 141 |
+
1. What this test measures
|
| 142 |
+
2. What this result means
|
| 143 |
+
3. Possible causes if abnormal
|
| 144 |
+
4. Dietary recommendations if applicable
|
| 145 |
+
|
| 146 |
+
Keep it simple and clear. Answer:"""
|
| 147 |
+
|
| 148 |
+
# Generate explanation
|
| 149 |
+
explanation = self._generate_text(prompt)
|
| 150 |
+
|
| 151 |
+
return explanation
|
| 152 |
+
|
| 153 |
+
def explain_all_results(self, results: List[LabResult]) -> Dict[str, str]:
|
| 154 |
+
"""Generate explanations for all lab results"""
|
| 155 |
+
explanations = {}
|
| 156 |
+
|
| 157 |
+
for result in results:
|
| 158 |
+
print(f"Explaining {result.test_name}...")
|
| 159 |
+
explanation = self.explain_result(result)
|
| 160 |
+
explanations[result.test_name] = explanation
|
| 161 |
+
|
| 162 |
+
return explanations
|
| 163 |
+
|
| 164 |
+
def answer_followup_question(self, question: str, lab_results: List[LabResult]) -> str:
|
| 165 |
+
"""Answer follow-up questions about lab results"""
|
| 166 |
+
|
| 167 |
+
# Create context from lab results
|
| 168 |
+
results_context = "\n".join([
|
| 169 |
+
f"{r.test_name}: {r.value} {r.unit} (Status: {r.status}, Range: {r.reference_range})"
|
| 170 |
+
for r in lab_results
|
| 171 |
+
])
|
| 172 |
+
|
| 173 |
+
# Retrieve relevant medical information
|
| 174 |
+
medical_context = self._retrieve_context(question, k=3)
|
| 175 |
+
|
| 176 |
+
# Create prompt
|
| 177 |
+
prompt = f"""You are a medical assistant. Answer this question based on the patient's lab results and medical information.
|
| 178 |
+
|
| 179 |
+
Patient's Lab Results:
|
| 180 |
+
{results_context}
|
| 181 |
+
|
| 182 |
+
Medical Information:
|
| 183 |
+
{medical_context}
|
| 184 |
+
|
| 185 |
+
Question: {question}
|
| 186 |
+
|
| 187 |
+
Provide a clear, helpful answer. Answer:"""
|
| 188 |
+
|
| 189 |
+
# Generate answer
|
| 190 |
+
answer = self._generate_text(prompt)
|
| 191 |
+
|
| 192 |
+
return answer
|
| 193 |
+
|
| 194 |
+
def generate_summary(self, results: List[LabResult]) -> str:
|
| 195 |
+
"""Generate overall summary of lab results"""
|
| 196 |
+
|
| 197 |
+
abnormal = [r for r in results if r.status in ['high', 'low']]
|
| 198 |
+
normal = [r for r in results if r.status == 'normal']
|
| 199 |
+
|
| 200 |
+
if not abnormal:
|
| 201 |
+
return "β
Great news! All your lab results are within normal ranges. Keep up the good work with your health!"
|
| 202 |
+
|
| 203 |
+
# Get context about abnormal results
|
| 204 |
+
queries = [f"{r.test_name} {r.status}" for r in abnormal]
|
| 205 |
+
combined_query = " ".join(queries)
|
| 206 |
+
context = self._retrieve_context(combined_query, k=4)
|
| 207 |
+
|
| 208 |
+
# Create summary prompt
|
| 209 |
+
abnormal_list = "\n".join([
|
| 210 |
+
f"- {r.test_name}: {r.value} {r.unit} ({r.status})"
|
| 211 |
+
for r in abnormal
|
| 212 |
+
])
|
| 213 |
+
|
| 214 |
+
prompt = f"""Provide a brief summary of these lab results.
|
| 215 |
+
|
| 216 |
+
Normal Results: {len(normal)} tests
|
| 217 |
+
Abnormal Results: {len(abnormal)} tests
|
| 218 |
+
|
| 219 |
+
Abnormal Tests:
|
| 220 |
+
{abnormal_list}
|
| 221 |
+
|
| 222 |
+
Medical Context:
|
| 223 |
+
{context}
|
| 224 |
+
|
| 225 |
+
Write a 2-3 paragraph summary explaining what these results mean overall and general recommendations. Be reassuring but honest. Summary:"""
|
| 226 |
+
|
| 227 |
+
# Generate summary
|
| 228 |
+
summary = self._generate_text(prompt)
|
| 229 |
+
|
| 230 |
+
return summary
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# Example usage
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
from pdf_extractor import LabResult
|
| 236 |
+
|
| 237 |
+
# Initialize RAG system
|
| 238 |
+
print("Initializing RAG system...")
|
| 239 |
+
rag = LabReportRAG()
|
| 240 |
+
|
| 241 |
+
# Example result
|
| 242 |
+
test_result = LabResult(
|
| 243 |
+
test_name="Hemoglobin",
|
| 244 |
+
value="10.5",
|
| 245 |
+
unit="g/dL",
|
| 246 |
+
reference_range="12.0-15.5",
|
| 247 |
+
status="low"
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Generate explanation
|
| 251 |
+
print("\nGenerating explanation...")
|
| 252 |
+
explanation = rag.explain_result(test_result)
|
| 253 |
+
print(f"\n{explanation}")
|
static/script.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ===== GLOBAL STATE =====
|
| 2 |
+
let currentResults = null;
|
| 3 |
+
let explanations = null;
|
| 4 |
+
|
| 5 |
+
// ===== INITIALIZATION =====
|
| 6 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 7 |
+
// Navigation is now handled via onclick="showSection", but we init other listeners
|
| 8 |
+
initFileUpload();
|
| 9 |
+
initScrollAnimations();
|
| 10 |
+
initEnterKey();
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// ===== NAVIGATION (Compatible with Tailwind HTML) =====
|
| 14 |
+
function showSection(sectionId) {
|
| 15 |
+
// Hide all sections
|
| 16 |
+
document.querySelectorAll('.section').forEach(sec => {
|
| 17 |
+
sec.classList.remove('active');
|
| 18 |
+
sec.style.display = 'none';
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
// Show target section
|
| 22 |
+
const target = document.getElementById(sectionId);
|
| 23 |
+
if (target) {
|
| 24 |
+
target.style.display = 'block';
|
| 25 |
+
// Small timeout to allow display:block to apply before adding active class for animation
|
| 26 |
+
setTimeout(() => {
|
| 27 |
+
target.classList.add('active');
|
| 28 |
+
}, 10);
|
| 29 |
+
|
| 30 |
+
// Scroll to top
|
| 31 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// ===== SCROLL ANIMATIONS =====
|
| 36 |
+
function initScrollAnimations() {
|
| 37 |
+
const observer = new IntersectionObserver((entries) => {
|
| 38 |
+
entries.forEach(entry => {
|
| 39 |
+
if (entry.isIntersecting) {
|
| 40 |
+
entry.target.classList.remove('opacity-0', 'translate-y-4');
|
| 41 |
+
entry.target.classList.add('opacity-100', 'translate-y-0');
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
}, { threshold: 0.1 });
|
| 45 |
+
|
| 46 |
+
// Add animation classes to cards
|
| 47 |
+
document.querySelectorAll('.feature-card, .upload-card').forEach(card => {
|
| 48 |
+
card.classList.add('transition-all', 'duration-700', 'opacity-0', 'translate-y-4');
|
| 49 |
+
observer.observe(card);
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// ===== FILE UPLOAD =====
|
| 54 |
+
function initFileUpload() {
|
| 55 |
+
const fileInput = document.getElementById('fileInput');
|
| 56 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 57 |
+
|
| 58 |
+
if (!fileInput || !uploadArea) return;
|
| 59 |
+
|
| 60 |
+
// Click to upload (handled by onclick in HTML, but keeping listener just in case)
|
| 61 |
+
fileInput.addEventListener('change', handleFileSelect);
|
| 62 |
+
|
| 63 |
+
// Drag and drop Visuals
|
| 64 |
+
uploadArea.addEventListener('dragover', (e) => {
|
| 65 |
+
e.preventDefault();
|
| 66 |
+
// Add Tailwind classes for drag state
|
| 67 |
+
uploadArea.classList.add('border-secondary', 'bg-blue-50', 'scale-[1.02]');
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
uploadArea.addEventListener('dragleave', () => {
|
| 71 |
+
// Remove Tailwind classes
|
| 72 |
+
uploadArea.classList.remove('border-secondary', 'bg-blue-50', 'scale-[1.02]');
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
uploadArea.addEventListener('drop', (e) => {
|
| 76 |
+
e.preventDefault();
|
| 77 |
+
uploadArea.classList.remove('border-secondary', 'bg-blue-50', 'scale-[1.02]');
|
| 78 |
+
|
| 79 |
+
const file = e.dataTransfer.files[0];
|
| 80 |
+
if (file && file.type === 'application/pdf') {
|
| 81 |
+
uploadFile(file);
|
| 82 |
+
} else {
|
| 83 |
+
showToast('Please upload a PDF file', 'error');
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function handleFileSelect(e) {
|
| 89 |
+
const file = e.target.files[0];
|
| 90 |
+
if (file) {
|
| 91 |
+
uploadFile(file);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function uploadFile(file) {
|
| 96 |
+
const formData = new FormData();
|
| 97 |
+
formData.append('file', file);
|
| 98 |
+
|
| 99 |
+
// Show progress
|
| 100 |
+
document.getElementById('uploadArea').classList.add('hidden');
|
| 101 |
+
document.getElementById('uploadProgress').classList.remove('hidden');
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
const response = await fetch('/api/upload', {
|
| 105 |
+
method: 'POST',
|
| 106 |
+
body: formData
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
const data = await response.json();
|
| 110 |
+
|
| 111 |
+
if (!response.ok) {
|
| 112 |
+
throw new Error(data.error || 'Upload failed');
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
currentResults = data.results;
|
| 116 |
+
showToast(`β Found ${data.count} lab results!`, 'success');
|
| 117 |
+
|
| 118 |
+
// Generate explanations
|
| 119 |
+
await generateExplanations();
|
| 120 |
+
|
| 121 |
+
// Display results
|
| 122 |
+
displayResults();
|
| 123 |
+
|
| 124 |
+
// Switch to results tab automatically
|
| 125 |
+
showSection('results-section');
|
| 126 |
+
|
| 127 |
+
} catch (error) {
|
| 128 |
+
showToast(error.message, 'error');
|
| 129 |
+
resetUploadArea();
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async function generateExplanations() {
|
| 134 |
+
// Optional: show a mini toast or loading indicator
|
| 135 |
+
try {
|
| 136 |
+
const response = await fetch('/api/explain', { method: 'POST' });
|
| 137 |
+
const data = await response.json();
|
| 138 |
+
if (!response.ok) throw new Error(data.error);
|
| 139 |
+
explanations = data.explanations;
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.warn('Auto-explanation generation failed:', error);
|
| 142 |
+
// We continue anyway, results will just say "Loading..." or show basic info
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function displayResults() {
|
| 147 |
+
const container = document.getElementById('resultsContainer');
|
| 148 |
+
|
| 149 |
+
if (!currentResults || currentResults.length === 0) {
|
| 150 |
+
container.innerHTML = '<div class="text-center text-gray-500 py-10">No results found</div>';
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Update summary stats in the Summary tab
|
| 155 |
+
updateSummaryStats();
|
| 156 |
+
|
| 157 |
+
// Clear container
|
| 158 |
+
container.innerHTML = '';
|
| 159 |
+
|
| 160 |
+
// Create result cards with Tailwind Styling
|
| 161 |
+
currentResults.forEach((result, index) => {
|
| 162 |
+
const card = createResultCard(result, index);
|
| 163 |
+
container.appendChild(card);
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
// Reset upload area for next time
|
| 167 |
+
resetUploadArea();
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function updateSummaryStats() {
|
| 171 |
+
const stats = { normal: 0, high: 0, low: 0 };
|
| 172 |
+
|
| 173 |
+
currentResults.forEach(result => {
|
| 174 |
+
// Normalize status string
|
| 175 |
+
const status = result.status ? result.status.toLowerCase() : 'normal';
|
| 176 |
+
if (status.includes('high')) stats.high++;
|
| 177 |
+
else if (status.includes('low')) stats.low++;
|
| 178 |
+
else stats.normal++;
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
// Update the DOM elements in the Summary Section
|
| 182 |
+
document.getElementById('normalCount').textContent = stats.normal;
|
| 183 |
+
document.getElementById('highCount').textContent = stats.high;
|
| 184 |
+
document.getElementById('lowCount').textContent = stats.low;
|
| 185 |
+
|
| 186 |
+
// Reveal the stats container
|
| 187 |
+
const statsContainer = document.getElementById('summaryStats');
|
| 188 |
+
if(statsContainer) statsContainer.classList.remove('hidden');
|
| 189 |
+
|
| 190 |
+
const generateBtn = document.getElementById('generateSummaryBtn');
|
| 191 |
+
if(generateBtn) generateBtn.classList.remove('hidden');
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function createResultCard(result, index) {
|
| 195 |
+
const card = document.createElement('div');
|
| 196 |
+
// TAILWIND STYLING: Card container
|
| 197 |
+
card.className = 'bg-white rounded-xl shadow-sm border border-slate-100 p-5 mb-4 hover:shadow-md transition-shadow';
|
| 198 |
+
|
| 199 |
+
// Status colors
|
| 200 |
+
let statusColors = 'bg-green-100 text-green-700'; // Default normal
|
| 201 |
+
let borderClass = 'border-l-4 border-green-500';
|
| 202 |
+
|
| 203 |
+
const statusLower = result.status ? result.status.toLowerCase() : '';
|
| 204 |
+
if (statusLower.includes('high')) {
|
| 205 |
+
statusColors = 'bg-red-100 text-red-700';
|
| 206 |
+
borderClass = 'border-l-4 border-red-500';
|
| 207 |
+
} else if (statusLower.includes('low')) {
|
| 208 |
+
statusColors = 'bg-yellow-100 text-yellow-800';
|
| 209 |
+
borderClass = 'border-l-4 border-yellow-500';
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
const explanation = explanations && explanations[result.test_name]
|
| 213 |
+
? explanations[result.test_name]
|
| 214 |
+
: 'Analysis available in Chat or Summary.';
|
| 215 |
+
|
| 216 |
+
// HTML Structure using Tailwind
|
| 217 |
+
card.innerHTML = `
|
| 218 |
+
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 ${borderClass} pl-4">
|
| 219 |
+
<div class="flex-grow">
|
| 220 |
+
<h3 class="text-lg font-bold text-slate-800">${escapeHtml(result.test_name)}</h3>
|
| 221 |
+
<p class="text-sm text-slate-500">Ref Range: ${escapeHtml(result.reference_range || 'N/A')}</p>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<div class="flex items-center gap-3 w-full md:w-auto justify-between md:justify-end">
|
| 225 |
+
<span class="text-xl font-bold text-slate-700">${escapeHtml(result.value)} <span class="text-sm font-normal text-slate-500">${escapeHtml(result.unit)}</span></span>
|
| 226 |
+
<span class="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide ${statusColors}">
|
| 227 |
+
${escapeHtml(result.status)}
|
| 228 |
+
</span>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div class="mt-4 pt-3 border-t border-slate-50">
|
| 233 |
+
<p class="text-sm text-slate-600 leading-relaxed">
|
| 234 |
+
<span class="font-semibold text-primary">Insight:</span> ${escapeHtml(explanation)}
|
| 235 |
+
</p>
|
| 236 |
+
</div>
|
| 237 |
+
`;
|
| 238 |
+
|
| 239 |
+
return card;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function resetUploadArea() {
|
| 243 |
+
document.getElementById('uploadArea').classList.remove('hidden');
|
| 244 |
+
document.getElementById('uploadProgress').classList.add('hidden');
|
| 245 |
+
document.getElementById('fileInput').value = '';
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// ===== SUMMARY GENERATION =====
|
| 249 |
+
async function generateSummary() {
|
| 250 |
+
if (!currentResults) {
|
| 251 |
+
showToast('Please upload a lab report first', 'error');
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const contentDiv = document.getElementById('summaryContent');
|
| 256 |
+
contentDiv.innerHTML = '<div class="flex justify-center p-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>';
|
| 257 |
+
|
| 258 |
+
try {
|
| 259 |
+
const response = await fetch('/api/summary');
|
| 260 |
+
const data = await response.json();
|
| 261 |
+
|
| 262 |
+
if (!response.ok) throw new Error(data.error);
|
| 263 |
+
|
| 264 |
+
// Render summary with Markdown-like paragraphs
|
| 265 |
+
contentDiv.innerHTML = `
|
| 266 |
+
<div class="prose prose-slate max-w-none">
|
| 267 |
+
<h3 class="text-xl font-semibold mb-4 text-primary">Analysis Report</h3>
|
| 268 |
+
<div class="text-slate-700 leading-relaxed whitespace-pre-line">
|
| 269 |
+
${escapeHtml(data.summary).replace(/\n/g, '<br>')}
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
`;
|
| 273 |
+
|
| 274 |
+
} catch (error) {
|
| 275 |
+
showToast('Error: ' + error.message, 'error');
|
| 276 |
+
contentDiv.innerHTML = '<p class="text-red-500 text-center">Failed to generate summary.</p>';
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// ===== CHAT FUNCTIONALITY =====
|
| 281 |
+
function initEnterKey() {
|
| 282 |
+
const input = document.getElementById('chatInput');
|
| 283 |
+
if (input) {
|
| 284 |
+
input.addEventListener('keypress', (e) => {
|
| 285 |
+
if (e.key === 'Enter') askQuestion();
|
| 286 |
+
});
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
async function askQuestion() {
|
| 291 |
+
if (!currentResults) {
|
| 292 |
+
showToast('Please upload a lab report first', 'error');
|
| 293 |
+
return;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
const input = document.getElementById('chatInput');
|
| 297 |
+
const question = input.value.trim();
|
| 298 |
+
|
| 299 |
+
if (!question) return;
|
| 300 |
+
|
| 301 |
+
// Clear input
|
| 302 |
+
input.value = '';
|
| 303 |
+
|
| 304 |
+
// Add user message
|
| 305 |
+
addChatMessage(question, 'user');
|
| 306 |
+
|
| 307 |
+
// Show loading
|
| 308 |
+
const loadingId = addChatMessage('Analyzing...', 'assistant', true);
|
| 309 |
+
|
| 310 |
+
try {
|
| 311 |
+
const response = await fetch('/api/ask', {
|
| 312 |
+
method: 'POST',
|
| 313 |
+
headers: { 'Content-Type': 'application/json' },
|
| 314 |
+
body: JSON.stringify({ question })
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
const data = await response.json();
|
| 318 |
+
if (!response.ok) throw new Error(data.error);
|
| 319 |
+
|
| 320 |
+
// Remove loading
|
| 321 |
+
const loadingEl = document.getElementById(loadingId);
|
| 322 |
+
if(loadingEl) loadingEl.remove();
|
| 323 |
+
|
| 324 |
+
// Add response
|
| 325 |
+
addChatMessage(data.answer, 'assistant');
|
| 326 |
+
|
| 327 |
+
} catch (error) {
|
| 328 |
+
const loadingEl = document.getElementById(loadingId);
|
| 329 |
+
if(loadingEl) loadingEl.remove();
|
| 330 |
+
showToast(error.message, 'error');
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
function addChatMessage(text, sender, isLoading = false) {
|
| 335 |
+
const container = document.getElementById('chatMessages');
|
| 336 |
+
|
| 337 |
+
const wrapper = document.createElement('div');
|
| 338 |
+
// Flex alignment based on sender
|
| 339 |
+
wrapper.className = `flex w-full mb-4 ${sender === 'user' ? 'justify-end' : 'justify-start'}`;
|
| 340 |
+
|
| 341 |
+
const bubble = document.createElement('div');
|
| 342 |
+
// Bubble Styling
|
| 343 |
+
const baseStyle = "max-w-[85%] rounded-2xl px-5 py-3 shadow-sm text-sm leading-relaxed";
|
| 344 |
+
const userStyle = "bg-secondary text-white rounded-br-none"; // Blue bubble
|
| 345 |
+
const aiStyle = "bg-slate-100 text-slate-800 rounded-tl-none border border-slate-200"; // Grey bubble
|
| 346 |
+
|
| 347 |
+
bubble.className = `${baseStyle} ${sender === 'user' ? userStyle : aiStyle} ${isLoading ? 'animate-pulse' : ''}`;
|
| 348 |
+
|
| 349 |
+
bubble.innerHTML = escapeHtml(text).replace(/\n/g, '<br>');
|
| 350 |
+
if (isLoading) bubble.id = `loading-${Date.now()}`;
|
| 351 |
+
|
| 352 |
+
wrapper.appendChild(bubble);
|
| 353 |
+
container.appendChild(wrapper);
|
| 354 |
+
|
| 355 |
+
// Scroll to bottom
|
| 356 |
+
container.scrollTop = container.scrollHeight;
|
| 357 |
+
|
| 358 |
+
return bubble.id;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// ===== TOAST NOTIFICATIONS =====
|
| 362 |
+
function showToast(message, type = 'info') {
|
| 363 |
+
const toast = document.getElementById('toast');
|
| 364 |
+
toast.textContent = message;
|
| 365 |
+
|
| 366 |
+
// Tailwind classes for toast
|
| 367 |
+
toast.className = `fixed bottom-5 right-5 px-6 py-3 rounded-lg shadow-2xl transform transition-all duration-300 z-50 text-white font-medium translate-y-0 opacity-100`;
|
| 368 |
+
|
| 369 |
+
if (type === 'error') toast.classList.add('bg-red-600');
|
| 370 |
+
else if (type === 'success') toast.classList.add('bg-green-600');
|
| 371 |
+
else toast.classList.add('bg-slate-800');
|
| 372 |
+
|
| 373 |
+
setTimeout(() => {
|
| 374 |
+
toast.classList.remove('translate-y-0', 'opacity-100');
|
| 375 |
+
toast.classList.add('translate-y-20', 'opacity-0');
|
| 376 |
+
}, 4000);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// ===== UTILS =====
|
| 380 |
+
function escapeHtml(text) {
|
| 381 |
+
if (!text) return '';
|
| 382 |
+
const div = document.createElement('div');
|
| 383 |
+
div.textContent = text;
|
| 384 |
+
return div.innerHTML;
|
| 385 |
+
}
|
static/style.css
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* We are moving the styling to the HTML.
|
| 2 |
+
This file now only handles the visibility logic. */
|
| 3 |
+
|
| 4 |
+
.section {
|
| 5 |
+
display: none; /* Hide all sections by default */
|
| 6 |
+
animation: fadeIn 0.3s ease-in-out;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.section.active {
|
| 10 |
+
display: block; /* Only show the active section */
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/* Smooth fade in animation */
|
| 14 |
+
@keyframes fadeIn {
|
| 15 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 16 |
+
to { opacity: 1; transform: translateY(0); }
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Custom scrollbar for chat */
|
| 20 |
+
.chat-messages::-webkit-scrollbar {
|
| 21 |
+
width: 6px;
|
| 22 |
+
}
|
| 23 |
+
.chat-messages::-webkit-scrollbar-track {
|
| 24 |
+
background: #f1f1f1;
|
| 25 |
+
}
|
| 26 |
+
.chat-messages::-webkit-scrollbar-thumb {
|
| 27 |
+
background: #cbd5e1;
|
| 28 |
+
border-radius: 3px;
|
| 29 |
+
}
|
| 30 |
+
.chat-messages::-webkit-scrollbar-thumb:hover {
|
| 31 |
+
background: #94a3b8;
|
| 32 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Lab Report Decoder - AI-Powered Medical Analysis</title>
|
| 7 |
+
|
| 8 |
+
<!-- Load Tailwind CSS -->
|
| 9 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 10 |
+
|
| 11 |
+
<!-- Link to our simple CSS for toggling sections -->
|
| 12 |
+
<link
|
| 13 |
+
rel="stylesheet"
|
| 14 |
+
href="{{ url_for('static', filename='css/styley.css') }}"
|
| 15 |
+
/>
|
| 16 |
+
|
| 17 |
+
<!-- Font -->
|
| 18 |
+
<link
|
| 19 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
| 20 |
+
rel="stylesheet"
|
| 21 |
+
/>
|
| 22 |
+
|
| 23 |
+
<!-- Configure Tailwind Colors -->
|
| 24 |
+
<script>
|
| 25 |
+
tailwind.config = {
|
| 26 |
+
theme: {
|
| 27 |
+
extend: {
|
| 28 |
+
colors: {
|
| 29 |
+
primary: "#659BB9" /* Deep Blue */,
|
| 30 |
+
secondary: "rgb(51 99 176)" /* Bright Blue */,
|
| 31 |
+
surface: "#ffffff" /* White */,
|
| 32 |
+
background: "#f8fafc" /* Very Light Blue-Grey */,
|
| 33 |
+
},
|
| 34 |
+
fontFamily: {
|
| 35 |
+
sans: ["Inter", "sans-serif"],
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
};
|
| 40 |
+
</script>
|
| 41 |
+
</head>
|
| 42 |
+
<body class="bg-background font-sans text-slate-800 h-screen flex flex-col">
|
| 43 |
+
<!-- Header -->
|
| 44 |
+
<header class="bg-primary text-white shadow-lg sticky top-0 z-50">
|
| 45 |
+
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 46 |
+
<div class="flex items-center justify-between h-16">
|
| 47 |
+
<!-- Logo -->
|
| 48 |
+
<div
|
| 49 |
+
class="flex items-center space-x-3 cursor-pointer"
|
| 50 |
+
onclick="showSection('upload-section')"
|
| 51 |
+
>
|
| 52 |
+
<span class="text-2xl">π¬</span>
|
| 53 |
+
<span class="font-bold text-xl tracking-tight"
|
| 54 |
+
>Lab Report Decoder</span
|
| 55 |
+
>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<!-- Navigation -->
|
| 59 |
+
<nav class="hidden md:flex space-x-1">
|
| 60 |
+
<button
|
| 61 |
+
onclick="showSection('upload-section')"
|
| 62 |
+
class="nav-btn px-4 py-2 rounded-lg text-sm font-medium hover:bg-white/10 transition-colors text-white/90 hover:text-white"
|
| 63 |
+
>
|
| 64 |
+
Upload
|
| 65 |
+
</button>
|
| 66 |
+
<button
|
| 67 |
+
onclick="showSection('results-section')"
|
| 68 |
+
class="nav-btn px-4 py-2 rounded-lg text-sm font-medium hover:bg-white/10 transition-colors text-white/90 hover:text-white"
|
| 69 |
+
>
|
| 70 |
+
Results
|
| 71 |
+
</button>
|
| 72 |
+
<button
|
| 73 |
+
onclick="showSection('chat-section')"
|
| 74 |
+
class="nav-btn px-4 py-2 rounded-lg text-sm font-medium hover:bg-white/10 transition-colors text-white/90 hover:text-white"
|
| 75 |
+
>
|
| 76 |
+
Ask Questions
|
| 77 |
+
</button>
|
| 78 |
+
<button
|
| 79 |
+
onclick="showSection('summary-section')"
|
| 80 |
+
class="nav-btn px-4 py-2 rounded-lg text-sm font-medium hover:bg-white/10 transition-colors text-white/90 hover:text-white"
|
| 81 |
+
>
|
| 82 |
+
Summary
|
| 83 |
+
</button>
|
| 84 |
+
</nav>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</header>
|
| 88 |
+
|
| 89 |
+
<!-- Main Content -->
|
| 90 |
+
<main class="flex-grow max-w-5xl w-full mx-auto px-4 sm:px-6 py-12">
|
| 91 |
+
<!-- SECTION 1: UPLOAD (Default Active) -->
|
| 92 |
+
<div id="upload-section" class="section active space-y-8">
|
| 93 |
+
<!-- Hero Text -->
|
| 94 |
+
<div class="text-center max-w-2xl mx-auto mb-10">
|
| 95 |
+
<h1 class="text-4xl font-extrabold text-primary mb-4 tracking-tight">
|
| 96 |
+
Understand Your Lab Results
|
| 97 |
+
</h1>
|
| 98 |
+
<p class="text-lg text-slate-500">
|
| 99 |
+
Upload your PDF report and get plain English explanations, dietary
|
| 100 |
+
advice, and AI-powered insights.
|
| 101 |
+
</p>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<!-- Upload Card -->
|
| 105 |
+
<div
|
| 106 |
+
class="bg-surface rounded-2xl shadow-xl border border-slate-100 p-10 max-w-3xl mx-auto transition-transform hover:scale-[1.01] duration-300"
|
| 107 |
+
>
|
| 108 |
+
<div
|
| 109 |
+
class="upload-area group border-2 border-dashed border-blue-200 bg-blue-50/50 rounded-xl p-12 text-center hover:border-secondary hover:bg-blue-50 transition-all cursor-pointer"
|
| 110 |
+
id="uploadArea"
|
| 111 |
+
onclick="document.getElementById('fileInput').click()"
|
| 112 |
+
>
|
| 113 |
+
<div
|
| 114 |
+
class="text-5xl mb-4 group-hover:scale-110 transition-transform duration-300"
|
| 115 |
+
>
|
| 116 |
+
π
|
| 117 |
+
</div>
|
| 118 |
+
<h3 class="text-xl font-semibold text-slate-700 mb-2">
|
| 119 |
+
Drop your lab report PDF here
|
| 120 |
+
</h3>
|
| 121 |
+
<p class="text-slate-500 text-sm mb-6">
|
| 122 |
+
or click to browse (Max 16MB)
|
| 123 |
+
</p>
|
| 124 |
+
|
| 125 |
+
<input type="file" id="fileInput" accept=".pdf" hidden />
|
| 126 |
+
|
| 127 |
+
<button
|
| 128 |
+
class="bg-secondary text-white px-8 py-3 rounded-full font-medium shadow-md shadow-blue-500/30 hover:bg-blue-600 hover:shadow-blue-500/40 transition-all transform hover:-translate-y-0.5"
|
| 129 |
+
>
|
| 130 |
+
Choose File
|
| 131 |
+
</button>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<!-- Progress Indicator (Hidden by default) -->
|
| 135 |
+
<div id="uploadProgress" class="hidden mt-8 text-center">
|
| 136 |
+
<div
|
| 137 |
+
class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-200 border-t-secondary"
|
| 138 |
+
></div>
|
| 139 |
+
<p class="text-secondary font-medium mt-3 animate-pulse">
|
| 140 |
+
Analyzing your report...
|
| 141 |
+
</p>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- Features Grid -->
|
| 146 |
+
<div class="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-12">
|
| 147 |
+
<div
|
| 148 |
+
class="bg-surface p-6 rounded-xl shadow-sm border border-slate-100 hover:shadow-md transition-shadow"
|
| 149 |
+
>
|
| 150 |
+
<div class="text-3xl mb-3">π</div>
|
| 151 |
+
<h3 class="font-semibold text-slate-800">Automatic Extraction</h3>
|
| 152 |
+
<p class="text-sm text-slate-500 mt-1">
|
| 153 |
+
Extracts all test values instantly
|
| 154 |
+
</p>
|
| 155 |
+
</div>
|
| 156 |
+
<div
|
| 157 |
+
class="bg-surface p-6 rounded-xl shadow-sm border border-slate-100 hover:shadow-md transition-shadow"
|
| 158 |
+
>
|
| 159 |
+
<div class="text-3xl mb-3">π§ </div>
|
| 160 |
+
<h3 class="font-semibold text-slate-800">AI Analysis</h3>
|
| 161 |
+
<p class="text-sm text-slate-500 mt-1">
|
| 162 |
+
Medical-grade explanations
|
| 163 |
+
</p>
|
| 164 |
+
</div>
|
| 165 |
+
<div
|
| 166 |
+
class="bg-surface p-6 rounded-xl shadow-sm border border-slate-100 hover:shadow-md transition-shadow"
|
| 167 |
+
>
|
| 168 |
+
<div class="text-3xl mb-3">π</div>
|
| 169 |
+
<h3 class="font-semibold text-slate-800">Dietary Tips</h3>
|
| 170 |
+
<p class="text-sm text-slate-500 mt-1">
|
| 171 |
+
Personalized nutrition advice
|
| 172 |
+
</p>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- SECTION 2: RESULTS -->
|
| 178 |
+
<div id="results-section" class="section space-y-8 mt-12">
|
| 179 |
+
<h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
|
| 180 |
+
<span class="bg-blue-100 text-blue-600 p-2 rounded-lg mr-3 text-xl"
|
| 181 |
+
>π</span
|
| 182 |
+
>
|
| 183 |
+
Your Results
|
| 184 |
+
</h2>
|
| 185 |
+
<div
|
| 186 |
+
id="resultsContainer"
|
| 187 |
+
class="bg-surface rounded-xl shadow-lg border border-slate-100 min-h-[400px] p-6"
|
| 188 |
+
>
|
| 189 |
+
<div
|
| 190 |
+
class="flex flex-col items-center justify-center h-full text-slate-400 py-20"
|
| 191 |
+
>
|
| 192 |
+
<div class="text-6xl mb-4 opacity-20">π</div>
|
| 193 |
+
<p>Upload a lab report to view results here</p>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<!-- SECTION 3: CHAT -->
|
| 199 |
+
<div id="chat-section" class="section space-y-8 mt-12">
|
| 200 |
+
<div
|
| 201 |
+
class="flex flex-col h-full bg-surface rounded-xl shadow-lg border border-slate-100 overflow-hidden"
|
| 202 |
+
>
|
| 203 |
+
<div class="bg-slate-50 border-b border-slate-100 p-4">
|
| 204 |
+
<h2 class="font-bold text-slate-700">Ask Questions</h2>
|
| 205 |
+
<p class="text-xs text-slate-500">
|
| 206 |
+
Ask about your specific values, diet, or lifestyle.
|
| 207 |
+
</p>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<!-- Chat Area -->
|
| 211 |
+
<div
|
| 212 |
+
id="chatMessages"
|
| 213 |
+
class="flex-grow overflow-y-auto p-6 space-y-8 bg-white"
|
| 214 |
+
>
|
| 215 |
+
<div class="text-center text-slate-400 py-10">
|
| 216 |
+
<p class="mb-4">Try asking:</p>
|
| 217 |
+
<div class="inline-flex flex-col gap-2 text-sm text-blue-600">
|
| 218 |
+
<span
|
| 219 |
+
class="bg-blue-50 px-3 py-1 rounded-full cursor-pointer hover:bg-blue-100"
|
| 220 |
+
>"Is my cholesterol dangerous?"</span
|
| 221 |
+
>
|
| 222 |
+
<span
|
| 223 |
+
class="bg-blue-50 px-3 py-1 rounded-full cursor-pointer hover:bg-blue-100"
|
| 224 |
+
>"What foods lower iron?"</span
|
| 225 |
+
>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<!-- Input Area -->
|
| 231 |
+
<div class="p-4 border-t border-slate-100 bg-slate-50">
|
| 232 |
+
<div class="flex gap-2">
|
| 233 |
+
<input
|
| 234 |
+
type="text"
|
| 235 |
+
id="chatInput"
|
| 236 |
+
class="flex-grow px-4 py-3 rounded-xl border border-slate-300 focus:border-secondary focus:ring-2 focus:ring-blue-100 outline-none transition-all"
|
| 237 |
+
placeholder="Type your medical question..."
|
| 238 |
+
/>
|
| 239 |
+
<button
|
| 240 |
+
onclick="askQuestion()"
|
| 241 |
+
class="bg-secondary text-white px-6 rounded-xl font-medium hover:bg-blue-600 transition-colors shadow-sm"
|
| 242 |
+
>
|
| 243 |
+
Send
|
| 244 |
+
</button>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<!-- SECTION 4: SUMMARY -->
|
| 251 |
+
<div id="summary-section" class="section mt-12">
|
| 252 |
+
<h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
|
| 253 |
+
<span
|
| 254 |
+
class="bg-purple-100 text-purple-600 p-2 rounded-lg mr-3 text-xl"
|
| 255 |
+
>π</span
|
| 256 |
+
>
|
| 257 |
+
Health Summary
|
| 258 |
+
</h2>
|
| 259 |
+
|
| 260 |
+
<!-- Stats Row -->
|
| 261 |
+
<div id="summaryStats" class="grid grid-cols-3 gap-4 mb-6 hidden">
|
| 262 |
+
<div class="bg-green-50 p-4 rounded-xl border border-green-100">
|
| 263 |
+
<div class="text-3xl font-bold text-green-600" id="normalCount">
|
| 264 |
+
0
|
| 265 |
+
</div>
|
| 266 |
+
<div class="text-sm text-green-700 font-medium">Normal Ranges</div>
|
| 267 |
+
</div>
|
| 268 |
+
<div class="bg-red-50 p-4 rounded-xl border border-red-100">
|
| 269 |
+
<div class="text-3xl font-bold text-red-600" id="highCount">0</div>
|
| 270 |
+
<div class="text-sm text-red-700 font-medium">High Values</div>
|
| 271 |
+
</div>
|
| 272 |
+
<div class="bg-yellow-50 p-4 rounded-xl border border-yellow-100">
|
| 273 |
+
<div class="text-3xl font-bold text-yellow-600" id="lowCount">
|
| 274 |
+
0
|
| 275 |
+
</div>
|
| 276 |
+
<div class="text-sm text-yellow-700 font-medium">Low Values</div>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
<div
|
| 281 |
+
id="summaryContent"
|
| 282 |
+
class="bg-surface rounded-xl shadow-lg border border-slate-100 p-8 min-h-[200px]"
|
| 283 |
+
>
|
| 284 |
+
<div class="text-center text-slate-400 py-10">
|
| 285 |
+
<p>Analysis summary will appear here after upload</p>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<button
|
| 290 |
+
id="generateSummaryBtn"
|
| 291 |
+
onclick="generateSummary()"
|
| 292 |
+
class="hidden mt-6 w-full py-3 bg-white border-2 border-secondary text-secondary font-bold rounded-xl hover:bg-blue-50 transition-colors"
|
| 293 |
+
>
|
| 294 |
+
Regenerate Detailed Summary
|
| 295 |
+
</button>
|
| 296 |
+
</div>
|
| 297 |
+
</main>
|
| 298 |
+
|
| 299 |
+
<!-- Footer -->
|
| 300 |
+
<footer class="bg-white border-t border-slate-200 py-6 mt-20">
|
| 301 |
+
<div class="max-w-4xl mx-auto text-center px-4">
|
| 302 |
+
<p
|
| 303 |
+
class="text-sm text-red-500 font-medium bg-red-50 inline-block px-4 py-1 rounded-full mb-2"
|
| 304 |
+
>
|
| 305 |
+
β οΈ Educational Purpose Only - Not Medical Advice
|
| 306 |
+
</p>
|
| 307 |
+
<p class="text-xs text-slate-400">
|
| 308 |
+
Always consult a professional healthcare provider for diagnosis and
|
| 309 |
+
treatment.
|
| 310 |
+
</p>
|
| 311 |
+
</div>
|
| 312 |
+
</footer>
|
| 313 |
+
|
| 314 |
+
<!-- Toast Notification -->
|
| 315 |
+
<div
|
| 316 |
+
id="toast"
|
| 317 |
+
class="fixed bottom-5 right-5 bg-slate-800 text-white px-6 py-3 rounded-lg shadow-2xl transform translate-y-20 opacity-0 transition-all duration-300 z-50"
|
| 318 |
+
></div>
|
| 319 |
+
|
| 320 |
+
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
| 321 |
+
</body>
|
| 322 |
+
</html>
|