Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| """app | |
| Automatically generated by Colab. | |
| Original file is located at | |
| https://colab.research.google.com/drive/1_pOuPpNNnrEQCdGrxl0JnaQJXAIb4F-j | |
| """ | |
| # ========================================= | |
| # TabeebAI — Unified Streamlit App | |
| # AI Medical Triage (Hugging Face Spaces) | |
| # ========================================= | |
| import os | |
| import json | |
| import time | |
| import base64 | |
| import tempfile | |
| import traceback | |
| import numpy as np | |
| import pandas as pd | |
| import streamlit as st | |
| from io import BytesIO | |
| from groq import Groq | |
| from sentence_transformers import SentenceTransformer | |
| # ========================================= | |
| # GROQ CLIENT | |
| # ========================================= | |
| api_key = os.environ.get("GROQ_API_KEY") | |
| client = Groq(api_key=api_key) if api_key else None | |
| # ========================================= | |
| # DISEASE DATASET | |
| # ========================================= | |
| _DATA_PATH = os.path.join(os.path.dirname(__file__), "data", "diseases_symptoms.csv") | |
| disease_df = None | |
| _SYMPTOM_COLS = [] | |
| try: | |
| disease_df = pd.read_csv(_DATA_PATH) | |
| disease_df.columns = ( | |
| disease_df.columns.str.lower().str.strip().str.replace(" ", "_") | |
| ) | |
| _SYMPTOM_COLS = [c for c in disease_df.columns if c != "disease"] | |
| print(f"[TabeebAI] Disease dataset: {len(disease_df)} diseases, {len(_SYMPTOM_COLS)} symptoms") | |
| except Exception as e: | |
| print(f"[TabeebAI] Disease dataset not found: {e}") | |
| def _match_symptom_to_column(symptom_name: str): | |
| query_words = set( | |
| symptom_name.lower().replace("_", " ").replace("-", " ").split() | |
| ) | |
| stop_words = {"severe", "mild", "moderate", "acute", "chronic", | |
| "sudden", "persistent", "intense", "sharp", "dull"} | |
| query_words -= stop_words | |
| best_col, best_score = None, 0 | |
| for col in _SYMPTOM_COLS: | |
| col_words = set(col.replace("_", " ").split()) | |
| overlap = len(query_words & col_words) | |
| if overlap > best_score: | |
| best_score, best_col = overlap, col | |
| return best_col if best_score > 0 else None | |
| def lookup_diseases(symptoms_list: list, top_n: int = 5) -> list: | |
| if disease_df is None or not symptoms_list: | |
| return [] | |
| matched_cols = [] | |
| for s in symptoms_list: | |
| col = _match_symptom_to_column(s.get("name", "")) | |
| if col and col not in matched_cols: | |
| matched_cols.append(col) | |
| if not matched_cols: | |
| return [] | |
| scores = disease_df[matched_cols].sum(axis=1) | |
| total_cols = len(matched_cols) | |
| results = ( | |
| disease_df[["disease"]] | |
| .assign(matched=scores, total=total_cols) | |
| .query("matched > 0") | |
| .sort_values("matched", ascending=False) | |
| .head(top_n) | |
| ) | |
| return results.to_dict(orient="records") | |
| # ========================================= | |
| # RAG SETUP | |
| # ========================================= | |
| _rag_model = None | |
| _rag_embeddings = None | |
| _rag_docs = [] | |
| def setup_rag(): | |
| global _rag_model, _rag_embeddings, _rag_docs | |
| knowledge_path = os.path.join(os.path.dirname(__file__), "data", "medical_knowledge.json") | |
| try: | |
| with open(knowledge_path, "r", encoding="utf-8") as f: | |
| _rag_docs = json.load(f) | |
| print("[TabeebAI] Loading embedding model...") | |
| _rag_model = SentenceTransformer("all-MiniLM-L6-v2") | |
| texts = [f"{d['title']}. {d['text']}" for d in _rag_docs] | |
| _rag_embeddings = _rag_model.encode( | |
| texts, normalize_embeddings=True, show_progress_bar=False | |
| ) | |
| print(f"[TabeebAI] RAG ready: {len(_rag_docs)} documents embedded") | |
| except Exception as e: | |
| print(f"[TabeebAI] RAG setup failed: {e}") | |
| def retrieve_knowledge(query: str, n: int = 3) -> list: | |
| if _rag_model is None or _rag_embeddings is None or not _rag_docs: | |
| return [] | |
| query_emb = _rag_model.encode([query], normalize_embeddings=True, show_progress_bar=False) | |
| scores = np.dot(_rag_embeddings, query_emb.T).flatten() | |
| top_idx = scores.argsort()[-n:][::-1] | |
| return [ | |
| {"title": _rag_docs[i]["title"], "text": _rag_docs[i]["text"], "score": float(scores[i])} | |
| for i in top_idx if scores[i] > 0.2 | |
| ] | |
| setup_rag() | |
| # ========================================= | |
| # LANGUAGE DETECTION | |
| # ========================================= | |
| def detect_language(text: str) -> str: | |
| if not text or not text.strip(): | |
| return "unknown" | |
| urdu_chars = sum(1 for c in text if '\u0600' <= c <= '\u06ff') | |
| devanagari_chars = sum(1 for c in text if '\u0900' <= c <= '\u097f') | |
| latin_chars = sum(1 for c in text if c.isascii() and c.isalpha()) | |
| total_alpha = sum(1 for c in text if c.isalpha()) | |
| if total_alpha == 0: | |
| return "unknown" | |
| if urdu_chars / total_alpha > 0.3: | |
| return "ur" | |
| if latin_chars / total_alpha > 0.5: | |
| return "en" | |
| return "other" | |
| # ========================================= | |
| # TRANSCRIPTION | |
| # ========================================= | |
| def _call_whisper(audio_path: str, language=None): | |
| with open(audio_path, "rb") as f: | |
| kwargs = dict(file=f, model="whisper-large-v3-turbo") | |
| if language: | |
| kwargs["language"] = language | |
| response = client.audio.transcriptions.create(**kwargs) | |
| text = response.text.strip() | |
| return text, detect_language(text) | |
| def transcribe_audio_file(audio_path: str): | |
| text, lang = _call_whisper(audio_path) | |
| if lang == "other": | |
| text, lang = _call_whisper(audio_path, language="ur") | |
| if lang == "other": | |
| return "Unsupported language detected. Please speak in Urdu or English only.", "unknown" | |
| return text, lang | |
| # ========================================= | |
| # TRANSLATION | |
| # ========================================= | |
| def translate_if_needed(text: str, lang: str) -> str: | |
| if not text or lang == "en": | |
| return text | |
| try: | |
| response = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[{ | |
| "role": "user", | |
| "content": ( | |
| "Translate the following Urdu medical text into clear English. " | |
| "Return only the translation, no explanations:\n\n" + text | |
| ) | |
| }], | |
| temperature=0.1 | |
| ) | |
| return response.choices[0].message.content.strip() | |
| except Exception as e: | |
| return f"Translation Error: {str(e)}" | |
| # ========================================= | |
| # CLASSIFICATION | |
| # ========================================= | |
| def classify_query(text: str) -> str: | |
| prompt = f"""You are a medical query classifier for a clinical triage system. | |
| Classify the following patient statement into exactly ONE category: | |
| MEDICAL — describes any symptom, pain, illness, injury, medication, or health concern | |
| CRISIS — mentions self-harm, suicide, wanting to die, or harming others | |
| NON_MEDICAL — anything unrelated to health (greetings, general questions, jokes, etc.) | |
| Statement: "{text}" | |
| Reply with ONLY one word: MEDICAL, CRISIS, or NON_MEDICAL""" | |
| try: | |
| response = client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=5, | |
| temperature=0 | |
| ) | |
| result = response.choices[0].message.content.strip().upper() | |
| if "CRISIS" in result: | |
| return "crisis" | |
| if "NON_MEDICAL" in result or "NON" in result: | |
| return "non_medical" | |
| return "medical" | |
| except Exception: | |
| return "medical" | |
| # ========================================= | |
| # SYMPTOM EXTRACTION | |
| # ========================================= | |
| def extract_symptoms(text: str) -> dict: | |
| prompt = f"""You are a clinical AI assistant. | |
| STRICT RULES: | |
| - Output MUST be ONLY in English | |
| - Return ONLY valid JSON, no extra text, no markdown | |
| Extract structured medical symptoms from this text: | |
| {text} | |
| FORMAT: | |
| {{ | |
| "chief_complaint": "", | |
| "symptoms": [ | |
| {{ | |
| "name": "", | |
| "severity": "mild/moderate/severe", | |
| "duration": "" | |
| }} | |
| ], | |
| "possible_conditions": [], | |
| "urgency": "low/medium/high", | |
| "language": "English" | |
| }}""" | |
| output = "" | |
| try: | |
| response = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[{"role": "user", "content": prompt}], | |
| temperature=0 | |
| ) | |
| output = response.choices[0].message.content | |
| output = output.replace("```json", "").replace("```", "").strip() | |
| return json.loads(output) | |
| except Exception as e: | |
| return {"error": "Parsing failed", "details": str(e), "raw_output": output} | |
| # ========================================= | |
| # RISK SCORING | |
| # ========================================= | |
| def calculate_risk_score(analysis: dict) -> int: | |
| if not analysis or "error" in analysis: | |
| return 0 | |
| score = 0 | |
| urgency_scores = {"low": 10, "medium": 40, "high": 75} | |
| score += urgency_scores.get(analysis.get("urgency", "low").lower(), 10) | |
| severity_bonus = {"mild": 3, "moderate": 8, "severe": 18} | |
| for symptom in analysis.get("symptoms", []): | |
| score += severity_bonus.get(symptom.get("severity", "mild").lower(), 3) | |
| emergency_keywords = [ | |
| "chest pain", "difficulty breathing", "shortness of breath", | |
| "unconscious", "unresponsive", "severe bleeding", "heart attack", | |
| "stroke", "choking", "not breathing", "no pulse", | |
| "سینے میں درد", "سانس لینے میں دشواری", "بے ہوش" | |
| ] | |
| full_text = analysis.get("chief_complaint", "").lower() | |
| full_text += " " + " ".join([s.get("name", "") for s in analysis.get("symptoms", [])]) | |
| for keyword in emergency_keywords: | |
| if keyword in full_text: | |
| score += 30 | |
| break | |
| score += min(len(analysis.get("symptoms", [])) * 3, 15) | |
| return min(score, 100) | |
| def get_risk_level(score: int) -> str: | |
| if score >= 71: | |
| return "RED" | |
| elif score >= 31: | |
| return "YELLOW" | |
| else: | |
| return "GREEN" | |
| # ========================================= | |
| # SOAP REPORT | |
| # ========================================= | |
| def generate_soap_report(analysis: dict, english_text: str, risk_score: int, retrieved_context: str = "") -> str: | |
| if not analysis or "error" in analysis: | |
| return "Cannot generate report — symptom extraction failed." | |
| symptoms_text = "\n".join([ | |
| f" - {s.get('name','?')} | severity: {s.get('severity','?')} | duration: {s.get('duration','?')}" | |
| for s in analysis.get("symptoms", []) | |
| ]) | |
| conditions = ", ".join(analysis.get("possible_conditions", [])) or "None identified" | |
| context_section = f"\nRETRIEVED MEDICAL KNOWLEDGE:\n{retrieved_context}\n" if retrieved_context else "" | |
| prompt = f"""You are a clinical documentation assistant. | |
| Generate a concise SOAP format medical report based on the data below. | |
| Return ONLY the report — no extra commentary, no markdown headings with #. | |
| PATIENT DATA: | |
| Chief Complaint : {analysis.get('chief_complaint', 'N/A')} | |
| Symptoms : | |
| {symptoms_text} | |
| Possible Conditions: {conditions} | |
| Urgency : {analysis.get('urgency', 'N/A')} | |
| Risk Score : {risk_score}/100 | |
| Original Statement: {english_text} | |
| {context_section} | |
| FORMAT TO USE: | |
| SUBJECTIVE: | |
| [what the patient reports] | |
| OBJECTIVE: | |
| [observable findings from the speech/statement] | |
| ASSESSMENT: | |
| [clinical interpretation, possible diagnoses] | |
| PLAN: | |
| [recommended next steps for the treating doctor] | |
| NOTE: This report is AI-generated and must be reviewed by a qualified health professional before any clinical decision is made.""" | |
| try: | |
| response = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[{"role": "user", "content": prompt}], | |
| temperature=0.2 | |
| ) | |
| return response.choices[0].message.content.strip() | |
| except Exception as e: | |
| return f"Report generation error: {str(e)}" | |
| # ========================================= | |
| # FULL PIPELINE | |
| # ========================================= | |
| def run_full_pipeline(text: str, lang: str) -> dict: | |
| timings = {} | |
| t0 = time.time() | |
| # Translation | |
| t = time.time() | |
| english_text = translate_if_needed(text, lang) | |
| timings["translation_ms"] = round((time.time() - t) * 1000) | |
| # Classification | |
| t = time.time() | |
| category = classify_query(english_text) | |
| timings["classification_ms"] = round((time.time() - t) * 1000) | |
| lang_label = "Urdu" if lang == "ur" else ("English" if lang == "en" else "Unknown") | |
| if category == "crisis": | |
| return { | |
| "status": "crisis", | |
| "english_text": english_text, | |
| "lang": lang, | |
| "lang_label": lang_label, | |
| "timings": timings, | |
| "total_ms": round((time.time() - t0) * 1000) | |
| } | |
| if category == "non_medical": | |
| return { | |
| "status": "non_medical", | |
| "english_text": english_text, | |
| "lang": lang, | |
| "lang_label": lang_label, | |
| "timings": timings, | |
| "total_ms": round((time.time() - t0) * 1000) | |
| } | |
| # Symptom extraction | |
| t = time.time() | |
| analysis = extract_symptoms(english_text) | |
| timings["extraction_ms"] = round((time.time() - t) * 1000) | |
| # Risk scoring | |
| t = time.time() | |
| risk_score = calculate_risk_score(analysis) | |
| risk_level = get_risk_level(risk_score) | |
| timings["risk_scoring_ms"] = round((time.time() - t) * 1000) | |
| # Disease lookup | |
| t = time.time() | |
| symptoms_list = analysis.get("symptoms", []) | |
| dataset_matches = lookup_diseases(symptoms_list, top_n=5) | |
| timings["disease_lookup_ms"] = round((time.time() - t) * 1000) | |
| if dataset_matches: | |
| analysis["possible_conditions"] = [d["disease"] for d in dataset_matches] | |
| analysis["dataset_match_detail"] = [ | |
| f"{d['disease']} ({d['matched']}/{d['total']} symptoms matched)" | |
| for d in dataset_matches | |
| ] | |
| else: | |
| analysis["dataset_match_detail"] = ["Dataset lookup returned no matches"] | |
| # RAG retrieval | |
| t = time.time() | |
| rag_query = english_text + " " + " ".join(s.get("name", "") for s in symptoms_list) | |
| retrieved = retrieve_knowledge(rag_query, n=3) | |
| timings["rag_retrieval_ms"] = round((time.time() - t) * 1000) | |
| retrieved_context = "\n\n".join( | |
| f"[{r['title']}]\n{r['text']}" for r in retrieved | |
| ) if retrieved else "" | |
| # SOAP report | |
| t = time.time() | |
| soap_report = generate_soap_report(analysis, english_text, risk_score, retrieved_context) | |
| timings["soap_generation_ms"] = round((time.time() - t) * 1000) | |
| timings["total_ms"] = round((time.time() - t0) * 1000) | |
| return { | |
| "status": "medical", | |
| "lang": lang, | |
| "lang_label": lang_label, | |
| "english_text": english_text, | |
| "analysis": analysis, | |
| "risk_score": risk_score, | |
| "risk_level": risk_level, | |
| "dataset_matches": dataset_matches, | |
| "retrieved_chunks": retrieved, | |
| "soap_report": soap_report, | |
| "models_used": { | |
| "transcription": "whisper-large-v3-turbo", | |
| "classification": "llama-3.1-8b-instant", | |
| "extraction": "llama-3.3-70b-versatile", | |
| "soap": "llama-3.3-70b-versatile", | |
| "embeddings": "all-MiniLM-L6-v2" | |
| }, | |
| "timings": timings | |
| } | |
| # ========================================= | |
| # DIRECT PIPELINE ENTRYPOINTS | |
| # (replaces HTTP API calls) | |
| # ========================================= | |
| def call_text_pipeline(text: str) -> dict: | |
| if not client: | |
| return {"status": "error", "message": "GROQ_API_KEY not configured"} | |
| if not text or not text.strip(): | |
| return {"status": "error", "message": "Text cannot be empty"} | |
| lang = detect_language(text) | |
| if lang == "other": | |
| return {"status": "error", "message": "Unsupported language. Only Urdu and English are supported."} | |
| try: | |
| return run_full_pipeline(text, lang) | |
| except Exception as e: | |
| return {"status": "error", "message": str(e)} | |
| def call_audio_pipeline(audio_bytes: bytes) -> dict: | |
| if not client: | |
| return {"status": "error", "message": "GROQ_API_KEY not configured"} | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: | |
| tmp.write(audio_bytes) | |
| tmp_path = tmp.name | |
| t0 = time.time() | |
| text, lang = transcribe_audio_file(tmp_path) | |
| transcription_ms = round((time.time() - t0) * 1000) | |
| os.unlink(tmp_path) | |
| if lang == "unknown": | |
| return {"status": "error", "message": text} | |
| result = run_full_pipeline(text, lang) | |
| result["original_transcript"] = text | |
| result["timings"]["transcription_ms"] = transcription_ms | |
| return result | |
| except Exception as e: | |
| return {"status": "error", "message": str(e)} | |
| def get_system_health() -> dict: | |
| return { | |
| "status": "healthy", | |
| "groq_configured": bool(api_key), | |
| "rag_ready": _rag_model is not None, | |
| "disease_db_ready": disease_df is not None, | |
| "disease_count": len(disease_df) if disease_df is not None else 0, | |
| "rag_doc_count": len(_rag_docs) | |
| } | |
| # ========================================= | |
| # STREAMLIT PAGE CONFIG | |
| # ========================================= | |
| st.set_page_config( | |
| page_title="TabeebAI — Clinical Triage", | |
| page_icon="assets/favicon.ico" if os.path.exists("assets/favicon.ico") else "🏥", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| _logo_b64 = "" | |
| _logo_path = os.path.join(os.path.dirname(__file__), "assets", "logo.png") | |
| if os.path.exists(_logo_path): | |
| with open(_logo_path, "rb") as _f: | |
| _logo_b64 = base64.b64encode(_f.read()).decode() | |
| _logo_img = f'<img src="data:image/png;base64,{_logo_b64}" style="width:100%;height:100%;object-fit:contain;border-radius:6px">' if _logo_b64 else "+" | |
| _agahi_b64 = "" | |
| _agahi_path = os.path.join(os.path.dirname(__file__), "assets", "agahi_logo.png") | |
| if os.path.exists(_agahi_path): | |
| with open(_agahi_path, "rb") as _f: | |
| _agahi_b64 = base64.b64encode(_f.read()).decode() | |
| _agahi_img = f'<img src="data:image/png;base64,{_agahi_b64}" style="width:100%;height:auto;object-fit:contain">' if _agahi_b64 else "Aagahi Labs" | |
| # ========================================= | |
| # GLOBAL CSS | |
| # ========================================= | |
| st.markdown(""" | |
| <style> | |
| /* ── Fonts ── */ | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300&family=JetBrains+Mono:wght@400;500&family=Noto+Nastaliq+Urdu:wght@400;600;700&display=swap'); | |
| @font-face { | |
| font-family: 'UrduNastaliq'; | |
| src: local('Noto Nastaliq Urdu'), | |
| url('https://fonts.gstatic.com/s/notonastaliqurdu/v22/LhWNMUPbL95F4oFGQEbPSGJgYtN1g_U.woff2') format('woff2'); | |
| unicode-range: U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF; | |
| } | |
| /* ── Design tokens ── */ | |
| :root { | |
| --bg-primary: #f5f7fa; | |
| --bg-secondary: #fafbfd; | |
| --bg-card: #ffffff; | |
| --bg-glass: rgba(255, 255, 255, 0.9); | |
| --border-subtle: rgba(15, 23, 42, 0.08); | |
| --border-accent: rgba(15, 23, 42, 0.18); | |
| --cyan: #0d7494; | |
| --cyan-dim: rgba(13, 116, 148, 0.10); | |
| --cyan-glow: rgba(13, 116, 148, 0.12); | |
| --red: #c0392b; | |
| --red-dim: rgba(192, 57, 43, 0.08); | |
| --yellow: #c97f0a; | |
| --yellow-dim: rgba(201, 127, 10, 0.10); | |
| --green: #0f7a5a; | |
| --green-dim: rgba(15, 122, 90, 0.08); | |
| --text-primary: #0f1a2e; | |
| --text-secondary:#3d4a63; | |
| --text-muted: #6b7a91; | |
| --font-main: 'Inter', 'DM Sans', 'UrduNastaliq', 'Noto Nastaliq Urdu', system-ui, sans-serif; | |
| --font-mono: 'JetBrains Mono', 'DM Mono', ui-monospace, monospace; | |
| --font-urdu: 'Noto Nastaliq Urdu', 'UrduNastaliq', serif; | |
| --radius: 10px; | |
| --radius-lg: 16px; | |
| --track: rgba(15, 23, 42, 0.06); | |
| } | |
| /* ── Audio input widget ── */ | |
| [data-testid="stAudioInput"] { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stAudioInput"] > div { background: var(--bg-card) !important; } | |
| [data-testid="stAudioInput"] * { background-color: transparent !important; } | |
| [data-testid="stAudioInput"] button { | |
| background: var(--bg-secondary) !important; | |
| border: 1px solid var(--border-accent) !important; | |
| } | |
| [data-testid="stAudioInput"] svg { | |
| fill: var(--cyan) !important; | |
| color: var(--cyan) !important; | |
| stroke: var(--cyan) !important; | |
| } | |
| [data-testid="stAudioInput"] p, | |
| [data-testid="stAudioInput"] span, | |
| [data-testid="stAudioInput"] small, | |
| [data-testid="stAudioInput"] label { color: var(--text-secondary) !important; } | |
| /* ── Audio player ── */ | |
| [data-testid="stAudio"] { | |
| background: var(--bg-card) !important; | |
| border-radius: var(--radius) !important; | |
| padding: 0.5rem !important; | |
| } | |
| [data-testid="stAudio"] audio { | |
| background: var(--bg-card) !important; | |
| border-radius: var(--radius) !important; | |
| width: 100% !important; | |
| } | |
| /* ── File uploader ── */ | |
| [data-testid="stFileUploader"] { | |
| background: var(--bg-card) !important; | |
| border: 1px dashed var(--border-accent) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stFileUploader"] *:not(svg):not(path):not(button) { | |
| background-color: transparent !important; | |
| color: var(--text-secondary) !important; | |
| } | |
| [data-testid="stFileUploader"] button { | |
| background: var(--bg-secondary) !important; | |
| border: 1px solid var(--border-accent) !important; | |
| color: var(--text-primary) !important; | |
| border-radius: var(--radius) !important; | |
| box-shadow: none !important; | |
| } | |
| [data-testid="stFileUploader"] button:hover { | |
| border-color: var(--cyan) !important; | |
| color: var(--cyan) !important; | |
| } | |
| [data-testid="stFileUploader"] button span { color: inherit !important; } | |
| [data-testid="stFileUploader"] svg, | |
| [data-testid="stFileUploader"] path { | |
| fill: var(--text-secondary) !important; | |
| stroke: none !important; | |
| } | |
| /* ── Textarea placeholder ── */ | |
| .stTextArea textarea::placeholder, | |
| .stTextInput input::placeholder { | |
| color: var(--text-muted) !important; | |
| opacity: 1 !important; | |
| } | |
| /* ── Base reset ── */ | |
| html, body, [class*="css"] { | |
| font-family: var(--font-main) !important; | |
| background-color: var(--bg-primary) !important; | |
| color: var(--text-primary) !important; | |
| } | |
| [data-testid="stAppViewContainer"] { background-color: var(--bg-primary) !important; } | |
| .main { background-color: var(--bg-primary) !important; } | |
| .block-container { background-color: transparent !important; } | |
| .main .block-container { | |
| padding: 1.5rem 2rem 3rem 2rem; | |
| max-width: 1400px; | |
| background: transparent; | |
| } | |
| /* ── Sidebar ── */ | |
| section[data-testid="stSidebar"] { | |
| background: var(--bg-card) !important; | |
| border-right: 1px solid var(--border-subtle) !important; | |
| } | |
| section[data-testid="stSidebar"] .block-container { | |
| padding: 1rem 1.25rem 1.5rem 1.25rem !important; | |
| } | |
| [data-testid="stSidebarHeader"] { | |
| margin-bottom: 0rem !important; | |
| height: 2.75rem !important; | |
| } | |
| [data-testid="stMainBlockContainer"] { | |
| padding: 1rem 5rem 5rem !important; | |
| } | |
| /* ── Remove default Streamlit chrome ── */ | |
| #MainMenu, footer, header { visibility: hidden; } | |
| [data-testid="stExpandSidebarButton"], | |
| [data-testid="stSidebarCollapsedControl"] { visibility: visible !important; } | |
| .stDeployButton { display: none; } | |
| /* ── Radio buttons ── */ | |
| [data-testid="stRadio"] label p { | |
| color: var(--text-secondary) !important; | |
| font-size: 0.875rem !important; | |
| } | |
| [data-testid="stRadio"] label:hover p { color: var(--text-primary) !important; } | |
| [data-testid="stRadio"] [aria-checked="true"] ~ div p, | |
| [data-testid="stRadio"] input:checked ~ div p { | |
| color: var(--cyan) !important; | |
| font-weight: 600 !important; | |
| } | |
| /* ── Buttons — solid accent ── */ | |
| .stButton > button, | |
| [data-testid="stDownloadButton"] > button { | |
| background: var(--cyan) !important; | |
| color: #ffffff !important; | |
| border: none !important; | |
| border-radius: var(--radius) !important; | |
| font-family: var(--font-main) !important; | |
| font-weight: 600 !important; | |
| font-size: 0.9rem !important; | |
| letter-spacing: 0.02em !important; | |
| padding: 0.55rem 1.4rem !important; | |
| transition: background 0.15s ease !important; | |
| box-shadow: none !important; | |
| } | |
| .stButton > button:hover, | |
| [data-testid="stDownloadButton"] > button:hover { | |
| background: #075568 !important; | |
| box-shadow: none !important; | |
| } | |
| .stButton > button:active, | |
| [data-testid="stDownloadButton"] > button:active { background: #075568 !important; } | |
| /* ── Text inputs ── */ | |
| .stTextArea textarea, .stTextInput input { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| color: var(--text-primary) !important; | |
| font-family: var(--font-main) !important; | |
| font-size: 0.9rem !important; | |
| transition: border-color 0.15s ease !important; | |
| caret-color: var(--cyan) !important; | |
| } | |
| .stTextArea textarea:focus, .stTextInput input:focus { | |
| border-color: var(--cyan) !important; | |
| box-shadow: 0 0 0 3px var(--cyan-dim) !important; | |
| outline: none !important; | |
| } | |
| /* ── Tabs ── */ | |
| .stTabs [data-baseweb="tab-list"] { | |
| background: var(--bg-secondary) !important; | |
| border-radius: var(--radius) !important; | |
| padding: 4px !important; | |
| gap: 2px !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| background: transparent !important; | |
| color: var(--text-muted) !important; | |
| font-family: var(--font-main) !important; | |
| font-weight: 500 !important; | |
| font-size: 0.875rem !important; | |
| border-radius: 8px !important; | |
| padding: 0.45rem 1.1rem !important; | |
| border: none !important; | |
| transition: color 0.15s ease !important; | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background: var(--bg-card) !important; | |
| color: var(--cyan) !important; | |
| box-shadow: none !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| } | |
| /* ── Metrics — all neutral ── */ | |
| [data-testid="stMetric"] { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| padding: 1rem 1.25rem !important; | |
| } | |
| [data-testid="stMetricLabel"] { | |
| color: var(--text-muted) !important; | |
| font-size: 0.75rem !important; | |
| letter-spacing: 0.06em !important; | |
| text-transform: uppercase !important; | |
| font-weight: 500 !important; | |
| } | |
| [data-testid="stMetricValue"] { | |
| color: var(--text-primary) !important; | |
| font-size: 1.6rem !important; | |
| font-weight: 700 !important; | |
| font-family: var(--font-mono) !important; | |
| } | |
| /* ── Progress bar ── */ | |
| .stProgress > div > div > div > div { | |
| background: var(--cyan) !important; | |
| border-radius: 999px !important; | |
| } | |
| .stProgress > div > div > div { | |
| background: var(--track) !important; | |
| border-radius: 999px !important; | |
| } | |
| /* ── Expander ── */ | |
| .streamlit-expanderHeader { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| color: var(--text-primary) !important; | |
| font-weight: 500 !important; | |
| } | |
| .streamlit-expanderContent { | |
| background: var(--bg-secondary) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-top: none !important; | |
| border-radius: 0 0 var(--radius) var(--radius) !important; | |
| color: var(--text-primary) !important; | |
| } | |
| [data-testid="stExpander"] { background: transparent !important; border: none !important; } | |
| [data-testid="stExpander"] details { | |
| background: transparent !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stExpander"] details summary { | |
| background: var(--bg-card) !important; | |
| color: var(--text-primary) !important; | |
| font-weight: 500 !important; | |
| border-radius: var(--radius) !important; | |
| padding: 0.75rem 1rem !important; | |
| list-style: none !important; | |
| } | |
| [data-testid="stExpander"] details[open] summary { | |
| border-radius: var(--radius) var(--radius) 0 0 !important; | |
| } | |
| [data-testid="stExpander"] details summary:hover, | |
| [data-testid="stExpander"] details summary:focus { | |
| background: var(--bg-secondary) !important; | |
| color: var(--cyan) !important; | |
| } | |
| [data-testid="stExpander"] details > div, | |
| [data-testid="stExpander"] details > div > div { | |
| background: var(--bg-secondary) !important; | |
| border-top: 1px solid var(--border-subtle) !important; | |
| border-radius: 0 0 var(--radius) var(--radius) !important; | |
| color: var(--text-primary) !important; | |
| } | |
| [data-testid="stExpander"] summary p, | |
| [data-testid="stExpander"] summary span { | |
| color: var(--text-primary) !important; | |
| font-weight: 500 !important; | |
| } | |
| /* ── Tabs content ── */ | |
| [data-testid="stTabsContent"] { color: var(--text-primary) !important; } | |
| [data-testid="stTabsContent"] p, | |
| [data-testid="stTabsContent"] span, | |
| [data-testid="stTabsContent"] div { color: inherit; } | |
| /* ── Code / monospace ── */ | |
| code, pre, .stCode { | |
| background: var(--bg-secondary) !important; | |
| color: var(--cyan) !important; | |
| font-family: var(--font-mono) !important; | |
| border-radius: 6px !important; | |
| } | |
| .stCode > div { | |
| background: var(--bg-secondary) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| /* ── Selectbox ── */ | |
| .stSelectbox > div > div { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border-subtle) !important; | |
| border-radius: var(--radius) !important; | |
| color: var(--text-primary) !important; | |
| } | |
| /* ── Spinner ── */ | |
| .stSpinner > div { border-top-color: var(--cyan) !important; } | |
| /* ── Scrollbar ── */ | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--bg-secondary); } | |
| ::-webkit-scrollbar-thumb { background: var(--border-accent); border-radius: 99px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--cyan); } | |
| /* ── Cards ── */ | |
| .card { | |
| background: var(--bg-card); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 0.5rem 0; | |
| transition: border-color 0.2s ease; | |
| } | |
| .card:hover { border-color: var(--border-accent); } | |
| .card-glass { | |
| background: var(--bg-glass); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 0.5rem 0; | |
| } | |
| /* ── Badges ── */ | |
| .badge { | |
| display: inline-block; | |
| padding: 0.2em 0.75em; | |
| border-radius: 999px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| } | |
| .badge-red { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); } | |
| .badge-yellow { background: var(--yellow-dim); color: var(--yellow); border: 1px solid var(--yellow); } | |
| .badge-green { background: var(--green-dim); color: var(--green); border: 1px solid var(--green); } | |
| .badge-cyan { background: var(--cyan-dim); color: var(--cyan); border: 1px solid var(--cyan); } | |
| /* ── Section title ── */ | |
| .section-title { | |
| font-size: 0.74rem; | |
| font-weight: 600; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: var(--text-muted); | |
| margin-bottom: 0.75rem; | |
| padding-bottom: 0.5rem; | |
| border-bottom: 1px solid var(--border-subtle); | |
| } | |
| /* ── Risk bar ── */ | |
| .risk-bar-container { | |
| background: var(--track); | |
| border-radius: 999px; | |
| height: 8px; | |
| overflow: hidden; | |
| margin: 0.5rem 0; | |
| } | |
| /* ── SOAP block ── */ | |
| .soap-block { | |
| background: var(--bg-secondary); | |
| border-left: 2px solid var(--cyan); | |
| border-radius: 0 var(--radius) var(--radius) 0; | |
| padding: 1rem 1.25rem; | |
| margin: 0.6rem 0; | |
| font-size: 0.88rem; | |
| line-height: 1.65; | |
| white-space: pre-wrap; | |
| color: var(--text-secondary); | |
| } | |
| /* ── Symptom chip ── */ | |
| .symptom-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| background: var(--cyan-dim); | |
| border: 1px solid var(--border-accent); | |
| border-radius: 999px; | |
| padding: 0.25em 0.85em; | |
| font-size: 0.78rem; | |
| font-weight: 500; | |
| color: var(--cyan); | |
| margin: 0.2rem; | |
| } | |
| /* ── Pipeline step ── */ | |
| .pipeline-step { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| padding: 0.6rem 1rem; | |
| border-radius: var(--radius); | |
| border: 1px solid var(--border-subtle); | |
| background: var(--bg-card); | |
| margin: 0.3rem 0; | |
| font-size: 0.85rem; | |
| } | |
| .pipeline-step .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } | |
| .dot-cyan { background: var(--cyan); } | |
| .dot-green { background: var(--green); } | |
| .dot-yellow { background: var(--yellow); } | |
| .dot-red { background: var(--red); } | |
| /* ── Timing pill ── */ | |
| .timing-pill { | |
| background: var(--bg-card); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: 8px; | |
| padding: 0.7rem 1rem; | |
| font-family: var(--font-mono); | |
| font-size: 0.82rem; | |
| text-align: center; | |
| } | |
| .timing-value { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| color: var(--text-primary); | |
| display: block; | |
| font-family: var(--font-mono); | |
| } | |
| .timing-label { | |
| font-size: 0.72rem; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| } | |
| /* ── RAG chunk ── */ | |
| .rag-chunk { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| padding: 1rem; | |
| margin: 0.5rem 0; | |
| font-size: 0.84rem; | |
| } | |
| .rag-title { | |
| color: var(--cyan); | |
| font-weight: 600; | |
| font-size: 0.82rem; | |
| margin-bottom: 0.4rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .rag-score { | |
| float: right; | |
| font-family: var(--font-mono); | |
| font-size: 0.75rem; | |
| color: var(--text-muted); | |
| } | |
| /* ── Alert blocks ── */ | |
| .alert-emergency { | |
| background: var(--red-dim); | |
| border: 1.5px solid var(--red); | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| } | |
| .alert-caution { | |
| background: var(--yellow-dim); | |
| border: 1.5px solid var(--yellow); | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| } | |
| .alert-safe { | |
| background: var(--green-dim); | |
| border: 1.5px solid var(--green); | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| } | |
| .alert-crisis { | |
| background: rgba(124, 58, 237, 0.07); | |
| border: 1.5px solid #7c3aed; | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| } | |
| .alert-info { | |
| background: var(--cyan-dim); | |
| border: 1.5px solid var(--cyan); | |
| border-radius: var(--radius-lg); | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| } | |
| .alert-title { font-size: 1rem; font-weight: 700; margin-bottom: 0.6rem; letter-spacing: 0.01em; } | |
| .alert-body { font-size: 0.875rem; line-height: 1.65; color: var(--text-primary); } | |
| .alert-list { margin: 0.6rem 0 0 1.1rem; padding: 0; font-size: 0.875rem; line-height: 1.75; color: var(--text-secondary); } | |
| /* ── Disclaimer bar ── */ | |
| .disclaimer-bar { | |
| background: rgba(201, 127, 10, 0.06); | |
| border: 1px solid rgba(201, 127, 10, 0.20); | |
| border-radius: var(--radius); | |
| padding: 0.9rem 1.25rem; | |
| font-size: 0.82rem; | |
| line-height: 1.6; | |
| color: var(--text-secondary); | |
| margin-bottom: 1.5rem; | |
| } | |
| .disclaimer-bar strong { color: var(--yellow); } | |
| .disclaimer-urdu { | |
| margin-top: 0.5rem; | |
| direction: rtl; | |
| text-align: right; | |
| font-family: var(--font-urdu); | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| } | |
| /* ── Header bar ── */ | |
| .header-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| padding: 0 0 1.5rem 0; | |
| border-bottom: 1px solid var(--border-subtle); | |
| margin-bottom: 1.5rem; | |
| } | |
| .header-logo { | |
| width: 64px; height: 64px; | |
| background: var(--cyan); | |
| border-radius: 14px; | |
| display: flex; align-items: center; justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .header-title { | |
| font-size: 2rem; | |
| font-weight: 800; | |
| letter-spacing: -0.03em; | |
| color: var(--text-primary); | |
| line-height: 1.15; | |
| } | |
| .header-subtitle { | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| font-weight: 400; | |
| letter-spacing: 0.02em; | |
| } | |
| .header-status { | |
| margin-left: auto; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| font-size: 0.78rem; | |
| color: var(--text-muted); | |
| } | |
| .status-dot { | |
| width: 7px; height: 7px; | |
| border-radius: 50%; | |
| background: var(--green); | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.45; } | |
| } | |
| .urgency-meter { | |
| position: relative; | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| height: 10px; | |
| background: var(--track); | |
| } | |
| .sidebar-nav-label { | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--text-muted); | |
| padding: 0.4rem 0 0.2rem 0; | |
| } | |
| .sidebar-sep { | |
| border: none; | |
| border-top: 1px solid var(--border-subtle); | |
| margin: 1em 0px; | |
| } | |
| hr.sidebar-sep { | |
| margin: 1em 0 !important; | |
| padding: 0 !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # Ctrl+Enter in the symptom textarea → click Analyze | |
| st.markdown(""" | |
| <script> | |
| (function() { | |
| document.addEventListener('keydown', function(e) { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| const active = document.activeElement; | |
| if (!active || active.tagName !== 'TEXTAREA') return; | |
| const btns = Array.from(document.querySelectorAll('button')); | |
| const analyzeBtn = btns.find(b => b.innerText.trim() === 'Analyze'); | |
| if (analyzeBtn && !analyzeBtn.disabled) { | |
| e.preventDefault(); | |
| analyzeBtn.click(); | |
| } | |
| } | |
| }); | |
| })(); | |
| </script> | |
| """, unsafe_allow_html=True) | |
| # ========================================= | |
| # SESSION STATE | |
| # ========================================= | |
| if "result" not in st.session_state: | |
| st.session_state.result = None | |
| if "processing" not in st.session_state: | |
| st.session_state.processing = False | |
| if "input_mode" not in st.session_state: | |
| st.session_state.input_mode = "text" | |
| # HITL review state | |
| if "soap_source" not in st.session_state: | |
| st.session_state.soap_source = None # fingerprint to detect new reports | |
| if "soap_edited" not in st.session_state: | |
| st.session_state.soap_edited = None # doctor-edited SOAP text | |
| if "soap_confirmed" not in st.session_state: | |
| st.session_state.soap_confirmed = False | |
| if "soap_reviewer_name" not in st.session_state: | |
| st.session_state.soap_reviewer_name = "" | |
| if "soap_reviewer_role" not in st.session_state: | |
| st.session_state.soap_reviewer_role = "" | |
| if "soap_confirmed_at" not in st.session_state: | |
| st.session_state.soap_confirmed_at = None | |
| # Accessibility | |
| FONT_SCALES = [0.85, 1.0, 1.15, 1.3, 1.5] | |
| FONT_LABELS = ["Small", "Normal", "Large", "X-Large", "XX-Large"] | |
| if "font_idx" not in st.session_state: | |
| st.session_state.font_idx = 2 # default: Large | |
| # ========================================= | |
| # COMPONENT HELPERS | |
| # ========================================= | |
| def render_risk_bar(score: int, risk_level: str): | |
| if risk_level == "RED": | |
| color, label = "#c0392b", "Emergency" | |
| elif risk_level == "YELLOW": | |
| color, label = "#c97f0a", "Moderate" | |
| else: | |
| color, label = "#0f7a5a", "Low Risk" | |
| pct = score | |
| st.markdown(f""" | |
| <div style="margin:0.25rem 0 0.5rem 0"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem"> | |
| <span style="font-size:0.78rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:600">Risk Level</span> | |
| <span style="font-size:0.75rem;font-weight:700;color:{color}">{label}</span> | |
| </div> | |
| <div style="background:var(--bg-secondary);border-radius:999px;height:8px;overflow:hidden"> | |
| <div style="width:{pct}%;height:100%;background:{color};border-radius:999px;transition:width 0.4s ease"></div> | |
| </div> | |
| <div style="text-align:right;font-family:var(--font-mono);font-size:0.72rem;color:var(--text-muted);margin-top:0.3rem">{score}/100</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_alert(status: str, result: dict): | |
| score = result.get("risk_score", 0) | |
| level = result.get("risk_level", "GREEN") | |
| analysis = result.get("analysis", {}) | |
| complaint = analysis.get("chief_complaint", "N/A") if analysis else "N/A" | |
| conditions = ", ".join(analysis.get("possible_conditions", [])) if analysis else "N/A" | |
| if status == "crisis": | |
| st.markdown(""" | |
| <div class="alert-crisis"> | |
| <div class="alert-title" style="color:#7c3aed">You Are Not Alone</div> | |
| <div class="alert-body"> | |
| It sounds like you or someone around you may be in emotional distress. | |
| TabeebAI cannot provide mental health support, but <strong>trained counsellors are available right now</strong>. | |
| </div> | |
| <ul class="alert-list"> | |
| <li><strong>Umang</strong> — 0317-4288665 (24/7)</li> | |
| <li><strong>Rozan Counselling</strong> — 051-2890505</li> | |
| <li><strong>Rescue</strong> — 1122</li> | |
| </ul> | |
| <div style="font-size:0.8rem;color:#7c3aed;opacity:0.75;margin-top:0.8rem;font-style:italic;direction:rtl;text-align:right;font-family:var(--font-urdu)"> | |
| یہ ایپ ذہنی صحت کی مدد کے لیے نہیں ہے۔ براہ کرم اوپر دیے گئے نمبروں پر کال کریں۔ | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| return | |
| if status == "non_medical": | |
| st.markdown(""" | |
| <div class="alert-info"> | |
| <div class="alert-title" style="color:var(--cyan)">Non-Medical Input Detected</div> | |
| <div class="alert-body"> | |
| TabeebAI is designed to assist with <strong>patient symptom triage</strong> only. | |
| Please describe the patient's medical symptoms or health concerns. | |
| </div> | |
| <div style="font-size:0.9rem;color:var(--text-muted);margin-top:0.8rem; | |
| font-family:var(--font-urdu);direction:rtl;line-height:2.2"> | |
| براہ کرم مریض کی علامات یا صحت کے مسائل بیان کریں۔ | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| return | |
| if level == "RED": | |
| st.markdown(f""" | |
| <div class="alert-emergency"> | |
| <div class="alert-title" style="color:var(--red)">EMERGENCY ALERT — Immediate Action Required</div> | |
| <div class="alert-body"> | |
| <strong>Chief Complaint:</strong> {complaint}<br> | |
| <strong>Risk Score:</strong> {score}/100 | |
| <strong>Possible Conditions:</strong> {conditions} | |
| </div> | |
| <ul class="alert-list"> | |
| <li>Call emergency services immediately — Rescue 1122 / Edhi 115</li> | |
| <li>Do not leave the patient alone</li> | |
| <li>Keep patient calm and still</li> | |
| <li>Prepare for immediate hospital transfer</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| elif level == "YELLOW": | |
| st.markdown(f""" | |
| <div class="alert-caution"> | |
| <div class="alert-title" style="color:var(--yellow)">CAUTION — Medical Attention Recommended</div> | |
| <div class="alert-body"> | |
| <strong>Chief Complaint:</strong> {complaint}<br> | |
| <strong>Risk Score:</strong> {score}/100 | |
| <strong>Possible Conditions:</strong> {conditions} | |
| </div> | |
| <ul class="alert-list"> | |
| <li>Schedule a doctor's appointment within 24 hours</li> | |
| <li>Monitor symptoms closely for worsening</li> | |
| <li>Seek urgent care if new symptoms develop</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.markdown(f""" | |
| <div class="alert-safe"> | |
| <div class="alert-title" style="color:var(--green)">LOW RISK — Home Care Advised</div> | |
| <div class="alert-body"> | |
| <strong>Chief Complaint:</strong> {complaint}<br> | |
| <strong>Risk Score:</strong> {score}/100 | |
| <strong>Possible Conditions:</strong> {conditions} | |
| </div> | |
| <ul class="alert-list"> | |
| <li>Rest and home care</li> | |
| <li>Stay hydrated and monitor temperature</li> | |
| <li>Visit a clinic if symptoms persist beyond 3 days</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_symptoms_chips(symptoms: list): | |
| _chip_styles = { | |
| "severe": {"color": "#c0392b", "bg": "rgba(192,57,43,0.08)", "border": "rgba(192,57,43,0.30)"}, | |
| "moderate": {"color": "#c97f0a", "bg": "rgba(201,127,10,0.10)", "border": "rgba(201,127,10,0.30)"}, | |
| } | |
| _chip_default = {"color": "#0d7494", "bg": "rgba(13,116,148,0.10)", "border": "rgba(13,116,148,0.25)"} | |
| chips_html = "" | |
| for s in symptoms: | |
| name = s.get("name", "") | |
| sev = s.get("severity", "mild").lower() | |
| dur = s.get("duration", "") | |
| cs = _chip_styles.get(sev, _chip_default) | |
| chips_html += f"""<span style="display:inline-flex;align-items:center;gap:0.35rem;background:{cs['bg']};border:1px solid {cs['border']};border-radius:999px;padding:0.25em 0.9em;font-size:0.78rem;font-weight:500;color:{cs['color']};margin:0.2rem">{name}<span style="opacity:0.35;font-size:0.72rem">|</span><span style="font-size:0.72rem;opacity:0.85">{sev}</span></span>""" | |
| st.markdown(f'<div style="line-height:2.2">{chips_html}</div>', unsafe_allow_html=True) | |
| def render_soap_report(soap: str): | |
| sections = {"SUBJECTIVE:": [], "OBJECTIVE:": [], "ASSESSMENT:": [], "PLAN:": []} | |
| current = None | |
| for line in soap.splitlines(): | |
| stripped = line.strip() | |
| if stripped.upper().startswith("SUBJECTIVE"): | |
| current = "SUBJECTIVE:" | |
| elif stripped.upper().startswith("OBJECTIVE"): | |
| current = "OBJECTIVE:" | |
| elif stripped.upper().startswith("ASSESSMENT"): | |
| current = "ASSESSMENT:" | |
| elif stripped.upper().startswith("PLAN"): | |
| current = "PLAN:" | |
| elif current: | |
| sections[current].append(line) | |
| icons = {"SUBJECTIVE:": "S", "OBJECTIVE:": "O", "ASSESSMENT:": "A", "PLAN:": "P"} | |
| colors = {"SUBJECTIVE:": "#0d7494", "OBJECTIVE:": "#0f7a5a", "ASSESSMENT:": "#c97f0a", "PLAN:": "#7c3aed"} | |
| color_bg = {"SUBJECTIVE:": "rgba(13,116,148,0.10)", "OBJECTIVE:": "rgba(15,122,90,0.08)", "ASSESSMENT:": "rgba(201,127,10,0.10)", "PLAN:": "rgba(124,58,237,0.08)"} | |
| color_border = {"SUBJECTIVE:": "rgba(13,116,148,0.25)", "OBJECTIVE:": "rgba(15,122,90,0.20)", "ASSESSMENT:": "rgba(201,127,10,0.25)", "PLAN:": "rgba(124,58,237,0.20)"} | |
| for key, lines in sections.items(): | |
| content = "\n".join(l for l in lines if l.strip()) | |
| if not content: | |
| continue | |
| c = colors[key] | |
| bg = color_bg[key] | |
| bd = color_border[key] | |
| st.markdown(f""" | |
| <div style="display:flex;gap:0.9rem;margin:0.6rem 0"> | |
| <div style="width:28px;height:28px;border-radius:6px;background:{bg};border:1px solid {bd};display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:800;color:{c};flex-shrink:0;margin-top:2px">{icons[key]}</div> | |
| <div style="flex:1"> | |
| <div style="font-size:0.72rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:{c};margin-bottom:0.35rem">{key.rstrip(':')}</div> | |
| <div style="background:var(--bg-secondary);border-left:2px solid {bd};border-radius:0 8px 8px 0;padding:0.75rem 1rem;font-size:0.85rem;line-height:1.7;color:var(--text-secondary);white-space:pre-wrap">{content}</div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ========================================= | |
| # SIDEBAR | |
| # ========================================= | |
| with st.sidebar: | |
| st.markdown(f""" | |
| <div style="margin-bottom:0.5rem"> | |
| <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.3rem"> | |
| <div style="width:36px;height:36px;border-radius:9px;overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center">{_agahi_img}</div> | |
| <div> | |
| <div style="font-size:1.05rem;font-weight:700;letter-spacing:-0.01em;color:var(--text-primary)">Aagahi Labs</div> | |
| <div style="font-size:0.72rem;color:var(--text-secondary);letter-spacing:0.04em">Presents</div> | |
| </div> | |
| </div> | |
| </div> | |
| <hr class="sidebar-sep"> | |
| """, unsafe_allow_html=True) | |
| health = get_system_health() | |
| if health: | |
| st.markdown(f""" | |
| <div style="background:var(--green-dim);border:1px solid var(--green);border-radius:var(--radius);padding:0.6rem 0.9rem;margin-bottom:1rem"> | |
| <div style="display:flex;align-items:center;gap:0.5rem"> | |
| <div class="status-dot"></div> | |
| <span style="font-size:0.78rem;font-weight:600;color:var(--green)">System Online</span> | |
| </div> | |
| <div style="font-size:0.74rem;color:var(--text-muted);margin-top:0.3rem;font-family:var(--font-mono)"> | |
| {health.get('disease_count', 0)} diseases · {health.get('rag_doc_count', 0)} RAG docs | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown('<div class="sidebar-nav-label">Navigation</div>', unsafe_allow_html=True) | |
| dashboard = st.radio( | |
| "Select View", | |
| ["Patient Dashboard", "Doctor Dashboard", "Developer Dashboard"], | |
| label_visibility="collapsed" | |
| ) | |
| st.markdown('<hr class="sidebar-sep">', unsafe_allow_html=True) | |
| st.markdown('<div class="sidebar-nav-label">Input Mode</div>', unsafe_allow_html=True) | |
| input_mode = st.radio( | |
| "Input", | |
| ["Text Input", "Audio Input"], | |
| label_visibility="collapsed", | |
| key="input_mode_radio" | |
| ) | |
| st.markdown('<hr class="sidebar-sep">', unsafe_allow_html=True) | |
| with st.expander("Accessibility", expanded=False): | |
| _fi = st.session_state.font_idx | |
| _fa_col, _fl_col, _fp_col = st.columns([1, 2, 1]) | |
| with _fa_col: | |
| if st.button("A−", key="font_dec", disabled=(_fi == 0)): | |
| st.session_state.font_idx -= 1 | |
| st.rerun() | |
| with _fl_col: | |
| st.markdown( | |
| f'<div style="text-align:center;font-size:0.78rem;color:var(--text-secondary);' | |
| f'font-weight:600;padding:0.45rem 0">{FONT_LABELS[_fi]}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| with _fp_col: | |
| if st.button("A+", key="font_inc", disabled=(_fi == len(FONT_SCALES) - 1)): | |
| st.session_state.font_idx += 1 | |
| st.rerun() | |
| st.markdown('<hr class="sidebar-sep">', unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div style="font-size:0.75rem;color:var(--text-secondary);line-height:1.6"> | |
| <strong style="color:var(--yellow)">Disclaimer</strong><br> | |
| TabeebAI is a clinical decision support tool. Not a substitute for professional medical judgement. | |
| All outputs must be reviewed by a licensed clinician. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.session_state.result: | |
| st.markdown('<hr class="sidebar-sep">', unsafe_allow_html=True) | |
| if st.button("Clear Results", use_container_width=True): | |
| st.session_state.result = None | |
| st.rerun() | |
| # ========================================= | |
| # MAIN CONTENT AREA | |
| # ========================================= | |
| # Dynamic font scale — injected fresh every render so sidebar controls take effect immediately | |
| _font_px = round(16 * FONT_SCALES[st.session_state.font_idx], 2) | |
| st.markdown( | |
| f"<style>html {{ font-size: {_font_px}px !important; }}</style>", | |
| unsafe_allow_html=True, | |
| ) | |
| # Header | |
| st.markdown(f""" | |
| <div class="header-bar"> | |
| <div class="header-logo" style="padding:4px">{_logo_img}</div> | |
| <div> | |
| <div class="header-title">TabeebAI</div> | |
| <div class="header-subtitle">AI-Powered Clinical Triage — Urdu / English</div> | |
| </div> | |
| <div class="header-status"> | |
| <div class="status-dot"></div> | |
| System Active | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Disclaimer | |
| st.markdown(""" | |
| <div class="disclaimer-bar"> | |
| <strong>Important:</strong> TabeebAI is a <strong>clinical decision support tool</strong> intended to assist qualified healthcare professionals. | |
| It is <strong>not a diagnostic tool</strong> and must not replace professional medical judgement. All AI-generated reports must be reviewed by a licensed medical professional before any action is taken. | |
| <div class="disclaimer-urdu">یہ ایپ صرف ڈاکٹروں کی مدد کے لیے ہے۔ کوئی بھی طبی فیصلہ کرنے سے پہلے ڈاکٹر سے مشورہ کریں۔</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ========================================= | |
| # INPUT SECTION | |
| # ========================================= | |
| def render_input_section(): | |
| st.markdown('<div class="section-title">Patient Input</div>', unsafe_allow_html=True) | |
| if input_mode == "Text Input": | |
| col_in, col_btn = st.columns([5, 1]) | |
| with col_in: | |
| user_text = st.text_area( | |
| "Enter symptoms in Urdu or English", | |
| placeholder=( | |
| "Example: I have severe chest pain and difficulty breathing since morning...\n" | |
| "مثال: مجھے سینے میں درد ہے اور سانس لینے میں دشواری ہو رہی ہے..." | |
| ), | |
| height=120, | |
| label_visibility="collapsed", | |
| key="text_input" | |
| ) | |
| with col_btn: | |
| st.markdown(""" | |
| <div style="font-size:0.68rem;color:var(--text-muted);text-align:center; | |
| margin-bottom:0.3rem;letter-spacing:0.03em"> | |
| Ctrl+Enter | |
| </div> | |
| """, unsafe_allow_html=True) | |
| analyze_clicked = st.button("Analyze", use_container_width=True, type="primary") | |
| if analyze_clicked: | |
| if not user_text or not user_text.strip(): | |
| st.warning("Please enter some text before analyzing.") | |
| return | |
| with st.spinner("Running clinical pipeline..."): | |
| result = call_text_pipeline(user_text) | |
| result["original_input"] = user_text | |
| st.session_state.result = result | |
| st.rerun() | |
| # ========================= | |
| # AUDIO INPUT MODE | |
| # ========================= | |
| else: | |
| st.markdown(""" | |
| <div style="background:rgba(201,127,10,0.06);border:1px solid rgba(201,127,10,0.20); | |
| border-radius:var(--radius);padding:0.75rem 1rem;margin-bottom:1rem; | |
| font-size:0.82rem;color:#c97f0a"> | |
| <strong>Microphone not working?</strong> | |
| Browsers block mic access inside embedded frames. | |
| <a href="?" target="_blank" | |
| style="color:#38bdf8;text-decoration:underline;margin-left:0.3rem"> | |
| Open the app in a new tab | |
| </a> | |
| to enable live recording, or use <strong>Upload File</strong> below. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| col_rec, col_up = st.columns(2) | |
| audio_bytes = None | |
| source_label = None | |
| # ── COLUMN 1: Live microphone recording ── | |
| with col_rec: | |
| st.markdown( | |
| '<div style="font-size:0.78rem;color:var(--text-muted);text-transform:uppercase;' | |
| 'letter-spacing:0.06em;margin-bottom:0.5rem">Live Recording</div>', | |
| unsafe_allow_html=True | |
| ) | |
| audio_value = st.audio_input( | |
| "Record patient voice", | |
| label_visibility="collapsed", | |
| key="live_recorder" | |
| ) | |
| if audio_value is not None: | |
| audio_bytes = audio_value.read() | |
| source_label = "live recording" | |
| # ── COLUMN 2: File upload fallback ── | |
| with col_up: | |
| st.markdown( | |
| '<div style="font-size:0.78rem;color:var(--text-muted);text-transform:uppercase;' | |
| 'letter-spacing:0.06em;margin-bottom:0.5rem">Upload File</div>', | |
| unsafe_allow_html=True | |
| ) | |
| uploaded = st.file_uploader( | |
| "Upload audio file", | |
| type=["wav", "mp3", "m4a", "ogg", "flac"], | |
| label_visibility="collapsed", | |
| key="audio_upload" | |
| ) | |
| if uploaded is not None and audio_bytes is None: | |
| audio_bytes = uploaded.read() | |
| source_label = f"uploaded — {uploaded.name}" | |
| # ── Playback + Analyze button ── | |
| if audio_bytes: | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.audio(audio_bytes, format="audio/wav") | |
| st.markdown( | |
| f'<div style="font-size:0.74rem;color:var(--text-muted);margin:0.3rem 0 0.7rem 0">' | |
| f'Source: {source_label}</div>', | |
| unsafe_allow_html=True | |
| ) | |
| else: | |
| st.markdown(""" | |
| <div style="background:var(--bg-card);border:1px dashed var(--border-accent); | |
| border-radius:var(--radius);padding:1.25rem;text-align:center; | |
| color:var(--text-muted);font-size:0.82rem;margin-top:0.5rem"> | |
| Record using the microphone above, or upload an audio file | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ── Submit button always visible ── | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| if st.button( | |
| "Transcribe & Analyze", | |
| use_container_width=True, | |
| type="primary", | |
| key="audio_analyze_btn", | |
| disabled=(audio_bytes is None) | |
| ): | |
| if audio_bytes is None: | |
| st.warning("Please record or upload audio first.") | |
| else: | |
| with st.spinner("Transcribing audio and running clinical pipeline..."): | |
| result = call_audio_pipeline(audio_bytes) | |
| st.session_state.result = result | |
| st.rerun() | |
| render_input_section() | |
| # ========================================= | |
| # NO RESULT STATE | |
| # ========================================= | |
| if st.session_state.result is None: | |
| st.markdown(""" | |
| <div style="text-align:center;padding:3.5rem 2rem;color:var(--text-muted)"> | |
| <div style="font-size:2.5rem;margin-bottom:1rem;opacity:0.4">+</div> | |
| <div style="font-size:1rem;font-weight:500;margin-bottom:0.4rem;color:var(--text-secondary)">No Analysis Yet</div> | |
| <div style="font-size:0.82rem">Enter patient symptoms above to begin clinical triage</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.stop() | |
| # ========================================= | |
| # RESULTS | |
| # ========================================= | |
| result = st.session_state.result | |
| status = result.get("status", "error") | |
| analysis = result.get("analysis", {}) or {} | |
| risk_score = result.get("risk_score", 0) | |
| risk_level = result.get("risk_level", "GREEN") | |
| soap = result.get("soap_report", "") | |
| retrieved = result.get("retrieved_chunks", []) | |
| timings = result.get("timings", {}) | |
| lang_label = result.get("lang_label", "Unknown") | |
| english_text = result.get("english_text", "") | |
| original_input = result.get("original_input", result.get("original_transcript", "")) | |
| dataset_matches = result.get("dataset_matches", []) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # ── Alert banner always shown ────────────────────────────── | |
| render_alert(status, result) | |
| if status in ("crisis", "non_medical", "error"): | |
| if status == "error": | |
| st.error(f"Pipeline Error: {result.get('message', 'Unknown error')}") | |
| st.stop() | |
| # ========================================= | |
| # DASHBOARD TABS | |
| # ========================================= | |
| if dashboard == "Patient Dashboard": | |
| st.markdown('<div class="section-title">Patient Overview</div>', unsafe_allow_html=True) | |
| # Key metrics row | |
| m1, m2, m3, m4 = st.columns(4) | |
| with m1: | |
| st.metric("Risk Score", f"{risk_score}/100") | |
| with m2: | |
| lvl_map = {"RED": "Emergency", "YELLOW": "Moderate", "GREEN": "Low Risk"} | |
| st.metric("Urgency", lvl_map.get(risk_level, risk_level)) | |
| with m3: | |
| st.metric("Language", lang_label) | |
| with m4: | |
| n_sym = len(analysis.get("symptoms", [])) | |
| st.metric("Symptoms Found", str(n_sym)) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # Urgency meter | |
| render_risk_bar(risk_score, risk_level) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # Two column layout | |
| col_left, col_right = st.columns([1, 1], gap="large") | |
| with col_left: | |
| st.markdown('<div class="section-title">Reported Symptoms</div>', unsafe_allow_html=True) | |
| symptoms = analysis.get("symptoms", []) | |
| if symptoms: | |
| render_symptoms_chips(symptoms) | |
| else: | |
| st.markdown('<div style="color:var(--text-muted);font-size:0.85rem">No specific symptoms extracted.</div>', unsafe_allow_html=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown('<div class="section-title">Chief Complaint</div>', unsafe_allow_html=True) | |
| complaint = analysis.get("chief_complaint", "Not specified") | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:1rem;font-size:0.88rem;color:var(--text-secondary);line-height:1.6"> | |
| {complaint} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with col_right: | |
| st.markdown('<div class="section-title">What You Should Do Next</div>', unsafe_allow_html=True) | |
| if risk_level == "RED": | |
| steps = [ | |
| ("Call 1122 / 115 immediately", "red"), | |
| ("Do not leave the patient alone", "red"), | |
| ("Keep patient calm and still", "yellow"), | |
| ("Prepare for hospital transfer", "yellow"), | |
| ] | |
| elif risk_level == "YELLOW": | |
| steps = [ | |
| ("See a doctor within 24 hours", "yellow"), | |
| ("Monitor symptoms for worsening", "yellow"), | |
| ("Seek urgent care if new symptoms develop", "cyan"), | |
| ("Keep track of symptom duration", "cyan"), | |
| ] | |
| else: | |
| steps = [ | |
| ("Rest and home care is appropriate", "green"), | |
| ("Stay hydrated and monitor temperature", "green"), | |
| ("Visit a clinic if symptoms persist > 3 days", "cyan"), | |
| ("Avoid strenuous activity until recovered", "cyan"), | |
| ] | |
| for step, color in steps: | |
| dot_class = f"dot-{color}" | |
| st.markdown(f""" | |
| <div class="pipeline-step"> | |
| <div class="dot {dot_class}"></div> | |
| <span style="font-size:0.85rem;color:var(--text-secondary)">{step}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if original_input and lang_label == "Urdu": | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown('<div class="section-title">Translation</div>', unsafe_allow_html=True) | |
| col_ur, col_en = st.columns(2) | |
| with col_ur: | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:0.75rem;font-size:0.82rem;color:var(--text-secondary)"> | |
| <div style="font-size:0.72rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.35rem">Original (Urdu)</div> | |
| {original_input} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with col_en: | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:0.75rem;font-size:0.82rem;color:var(--text-secondary)"> | |
| <div style="font-size:0.72rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.35rem">English</div> | |
| {english_text} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| elif dashboard == "Doctor Dashboard": | |
| st.markdown('<div class="section-title">Clinical Overview</div>', unsafe_allow_html=True) | |
| # Clinical metrics | |
| cm1, cm2, cm3, cm4 = st.columns(4) | |
| with cm1: | |
| st.metric("Risk Score", f"{risk_score}/100") | |
| with cm2: | |
| urgency = analysis.get("urgency", "N/A").upper() | |
| st.metric("Triage Urgency", urgency) | |
| with cm3: | |
| st.metric("Input Language", lang_label) | |
| with cm4: | |
| st.metric("Symptoms Extracted", len(analysis.get("symptoms", []))) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| render_risk_bar(risk_score, risk_level) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # Main clinical area | |
| tab_soap, tab_symptoms, tab_diseases, tab_rag = st.tabs([ | |
| "SOAP Report", "Symptom Analysis", "Disease Matching", "Retrieved Knowledge" | |
| ]) | |
| with tab_soap: | |
| if soap: | |
| # Reset HITL state whenever a brand-new report arrives | |
| if st.session_state.soap_source != soap: | |
| st.session_state.soap_source = soap | |
| st.session_state.soap_edited = soap | |
| st.session_state.soap_confirmed = False | |
| st.session_state.soap_reviewer_name = "" | |
| st.session_state.soap_reviewer_role = "" | |
| st.session_state.soap_confirmed_at = None | |
| # ── Confirmation banner ──────────────────────────────────── | |
| if st.session_state.soap_confirmed: | |
| st.markdown(f""" | |
| <div style="background:rgba(52,211,153,0.10);border:1.5px solid #34d399; | |
| border-radius:var(--radius-lg);padding:1rem 1.4rem; | |
| margin-bottom:1.25rem;display:flex;align-items:center;gap:0.9rem"> | |
| <div style="font-size:1.5rem;line-height:1">✅</div> | |
| <div> | |
| <div style="font-weight:700;color:#34d399;font-size:0.92rem; | |
| letter-spacing:0.01em">Report Confirmed</div> | |
| <div style="font-size:0.82rem;color:var(--text-secondary);margin-top:0.2rem"> | |
| Reviewed by | |
| <strong style="color:var(--text-primary)"> | |
| {st.session_state.soap_reviewer_name} | |
| </strong> | |
| · | |
| <span style="color:var(--text-muted)"> | |
| {st.session_state.soap_reviewer_role} | |
| </span> | |
| · | |
| <span style="font-family:var(--font-mono);font-size:0.79rem"> | |
| {st.session_state.soap_confirmed_at} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ── Attractive rendered SOAP view (always shown) ────────── | |
| section_label = ( | |
| "SOAP Report — confirmed" if st.session_state.soap_confirmed | |
| else "SOAP Report" | |
| ) | |
| st.markdown(f'<div class="section-title">{section_label}</div>', | |
| unsafe_allow_html=True) | |
| render_soap_report(st.session_state.soap_edited or soap) | |
| # ── Editable textarea in expander (only when not confirmed) ── | |
| if not st.session_state.soap_confirmed: | |
| with st.expander("✏️ Edit Report", expanded=False): | |
| edited_soap = st.text_area( | |
| "SOAP Report", | |
| value=st.session_state.soap_edited, | |
| height=300, | |
| label_visibility="collapsed", | |
| key="soap_edit_area", | |
| ) | |
| st.session_state.soap_edited = edited_soap | |
| # ── Doctor Review row ────────────────────────────────────── | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem"> | |
| <div style="width:3px;height:18px;background:var(--cyan);border-radius:2px"></div> | |
| <span class="section-title" style="margin:0;border:none;padding:0">Doctor Review</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| rev_c1, rev_c2, rev_c3 = st.columns([2, 2, 1]) | |
| if st.session_state.soap_confirmed: | |
| # Show confirmed values as readable text, not disabled inputs | |
| with rev_c1: | |
| st.markdown(f""" | |
| <div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase; | |
| letter-spacing:0.06em;margin-bottom:0.3rem">Doctor Name</div> | |
| <div style="font-size:0.92rem;font-weight:600; | |
| color:var(--text-primary)">{st.session_state.soap_reviewer_name}</div> | |
| """, unsafe_allow_html=True) | |
| with rev_c2: | |
| st.markdown(f""" | |
| <div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase; | |
| letter-spacing:0.06em;margin-bottom:0.3rem">Role / Specialisation</div> | |
| <div style="font-size:0.92rem;color:var(--text-secondary)"> | |
| {st.session_state.soap_reviewer_role}</div> | |
| """, unsafe_allow_html=True) | |
| reviewer_name = st.session_state.soap_reviewer_name | |
| reviewer_role = st.session_state.soap_reviewer_role | |
| else: | |
| with rev_c1: | |
| reviewer_name = st.text_input( | |
| "Doctor Name", | |
| placeholder="Dr. Ahmed Khan", | |
| key="reviewer_name_input", | |
| ) | |
| with rev_c2: | |
| reviewer_role = st.text_input( | |
| "Role / Specialisation", | |
| placeholder="e.g. General Practitioner", | |
| key="reviewer_role_input", | |
| ) | |
| with rev_c3: | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| if not st.session_state.soap_confirmed: | |
| if st.button("✔ Confirm Report", type="primary", | |
| use_container_width=True, key="confirm_btn"): | |
| if not reviewer_name.strip(): | |
| st.warning("Please enter the reviewing doctor's name.") | |
| else: | |
| st.session_state.soap_confirmed = True | |
| st.session_state.soap_reviewer_name = reviewer_name.strip() | |
| st.session_state.soap_reviewer_role = ( | |
| reviewer_role.strip() or "Physician" | |
| ) | |
| st.session_state.soap_confirmed_at = time.strftime( | |
| "%Y-%m-%d %H:%M:%S" | |
| ) | |
| st.rerun() | |
| else: | |
| if st.button("↩ Reopen for Edit", use_container_width=True, | |
| key="reopen_btn"): | |
| st.session_state.soap_confirmed = False | |
| st.rerun() | |
| # ── Download buttons (use doctor-edited text) ────────────── | |
| final_soap = st.session_state.soap_edited or soap | |
| reviewer_line = ( | |
| f"\n\nReviewed by: {st.session_state.soap_reviewer_name}" | |
| f" ({st.session_state.soap_reviewer_role})" | |
| f" — {st.session_state.soap_confirmed_at}" | |
| if st.session_state.soap_confirmed else | |
| "\n\n[Pending doctor review]" | |
| ) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| col_dl1, col_dl2, col_space = st.columns([1, 1, 3]) | |
| with col_dl1: | |
| st.download_button( | |
| "Download Report (.txt)", | |
| data=final_soap + reviewer_line, | |
| file_name="tabeebai_soap_report.txt", | |
| mime="text/plain", | |
| use_container_width=True, | |
| ) | |
| with col_dl2: | |
| md_report = f"""# TabeebAI Clinical Report | |
| **Date:** {time.strftime('%Y-%m-%d %H:%M')} | |
| **Risk Score:** {risk_score}/100 | |
| **Risk Level:** {risk_level} | |
| **Language:** {lang_label} | |
| --- | |
| ## Chief Complaint | |
| {analysis.get("chief_complaint", "N/A")} | |
| --- | |
| {final_soap} | |
| --- | |
| {reviewer_line} | |
| *AI-generated report — must be reviewed by a licensed medical professional.* | |
| """ | |
| st.download_button( | |
| "Download Report (.md)", | |
| data=md_report, | |
| file_name="tabeebai_report.md", | |
| mime="text/markdown", | |
| use_container_width=True, | |
| ) | |
| else: | |
| st.info("No SOAP report generated.") | |
| with tab_symptoms: | |
| symptoms = analysis.get("symptoms", []) | |
| if symptoms: | |
| st.markdown('<div class="section-title">Extracted Symptoms</div>', unsafe_allow_html=True) | |
| render_symptoms_chips(symptoms) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| for s in symptoms: | |
| sev = s.get("severity", "mild").lower() | |
| _sev_map = { | |
| "severe": {"color": "#c0392b", "bg": "rgba(192,57,43,0.08)", "border": "rgba(192,57,43,0.30)"}, | |
| "moderate": {"color": "#c97f0a", "bg": "rgba(201,127,10,0.10)", "border": "rgba(201,127,10,0.30)"}, | |
| "mild": {"color": "#0f7a5a", "bg": "rgba(15,122,90,0.08)", "border": "rgba(15,122,90,0.25)"}, | |
| } | |
| sc = _sev_map.get(sev, {"color": "#0d7494", "bg": "rgba(13,116,148,0.10)", "border": "rgba(13,116,148,0.25)"}) | |
| st.markdown(f""" | |
| <div style="display:flex;align-items:center;justify-content:space-between;background:var(--bg-secondary);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:0.75rem 1rem;margin:0.35rem 0"> | |
| <div style="font-weight:500;font-size:0.87rem;color:var(--text-primary)">{s.get("name", "—")}</div> | |
| <div style="display:flex;gap:1rem;align-items:center"> | |
| <span style="font-size:0.75rem;color:var(--text-secondary)">Duration: <strong style="color:var(--text-primary)">{s.get("duration", "N/A")}</strong></span> | |
| <span style="background:{sc['bg']};border:1px solid {sc['border']};border-radius:999px;padding:0.15em 0.7em;font-size:0.72rem;font-weight:600;color:{sc['color']};text-transform:uppercase">{sev}</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No symptoms extracted.") | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown('<div class="section-title">Transcript</div>', unsafe_allow_html=True) | |
| t1, t2 = st.columns(2) | |
| with t1: | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:1rem;font-size:0.85rem;color:var(--text-secondary);line-height:1.65"> | |
| <div style="font-size:0.72rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem">Original Input</div> | |
| {original_input or "—"} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with t2: | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:1rem;font-size:0.85rem;color:var(--text-secondary);line-height:1.65"> | |
| <div style="font-size:0.72rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem">English Translation</div> | |
| {english_text or "—"} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with tab_diseases: | |
| st.markdown('<div class="section-title">Dataset-Matched Diseases</div>', unsafe_allow_html=True) | |
| if dataset_matches: | |
| max_matched = max(d.get("matched", 0) for d in dataset_matches) or 1 | |
| for d in dataset_matches: | |
| matched = d.get("matched", 0) | |
| total = d.get("total", 1) | |
| pct = int((matched / max_matched) * 100) | |
| bar_color = "#c0392b" if pct > 70 else "#c97f0a" if pct > 40 else "#0d7494" | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:0.9rem 1.1rem;margin:0.35rem 0"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem"> | |
| <span style="font-weight:600;font-size:0.88rem;color:var(--text-primary)">{d.get("disease","—")}</span> | |
| <span style="font-family:var(--font-mono);font-size:0.78rem;color:var(--text-secondary)">{matched}/{total} symptoms</span> | |
| </div> | |
| <div style="background:var(--bg-card);border-radius:999px;height:6px;overflow:hidden"> | |
| <div style="width:{pct}%;height:100%;background:{bar_color};border-radius:999px"></div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No disease matches found. Ensure diseases_symptoms.csv is in the data/ folder.") | |
| detail = analysis.get("dataset_match_detail", []) | |
| if detail: | |
| with st.expander("Match Detail"): | |
| for item in detail: | |
| st.markdown(f'<div style="font-size:0.82rem;color:var(--text-secondary);padding:0.2rem 0">{item}</div>', unsafe_allow_html=True) | |
| with tab_rag: | |
| st.markdown('<div class="section-title">Retrieved Medical Knowledge</div>', unsafe_allow_html=True) | |
| if retrieved: | |
| for chunk in retrieved: | |
| score_pct = int(chunk.get("score", 0) * 100) | |
| score_color = "#0f7a5a" if score_pct > 70 else "#c97f0a" if score_pct > 50 else "#6b7a91" | |
| with st.expander(f"{chunk.get('title', 'Document')} — Relevance: {score_pct}%"): | |
| st.markdown(f""" | |
| <div style="font-size:0.85rem;color:var(--text-primary);line-height:1.7;padding:0.5rem 0"> | |
| {chunk.get("text", "")} | |
| </div> | |
| <div style="margin-top:0.75rem"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.3rem"> | |
| <span style="font-size:0.72rem;color:var(--text-secondary)">Cosine Similarity</span> | |
| <span style="font-family:var(--font-mono);font-size:0.72rem;color:{score_color}">{chunk.get('score',0):.4f}</span> | |
| </div> | |
| <div style="background:var(--bg-card);border-radius:999px;height:4px"> | |
| <div style="width:{score_pct}%;height:100%;background:{score_color};border-radius:999px"></div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No RAG chunks retrieved. Ensure medical_knowledge.json is in the data/ folder.") | |
| elif dashboard == "Developer Dashboard": | |
| st.markdown('<div class="section-title">Pipeline Observability</div>', unsafe_allow_html=True) | |
| dev_tab1, dev_tab2, dev_tab3, dev_tab4, dev_tab5 = st.tabs([ | |
| "Pipeline Status", "Timings", "Raw JSON", "RAG Debug", "Models" | |
| ]) | |
| with dev_tab1: | |
| st.markdown('<div class="section-title">Execution Pipeline</div>', unsafe_allow_html=True) | |
| pipeline_steps = [ | |
| ("Language Detection", lang_label, "green"), | |
| ("Query Classification", "medical", "green"), | |
| ("Translation", "Urdu → English" if lang_label == "Urdu" else "Skipped (English)", "cyan" if lang_label == "Urdu" else "yellow"), | |
| ("Symptom Extraction", f"{len(analysis.get('symptoms',[]))} symptoms extracted", "green"), | |
| ("Risk Scoring", f"{risk_score}/100 — {risk_level}", "green" if risk_level == "GREEN" else ("yellow" if risk_level == "YELLOW" else "red")), | |
| ("Disease Lookup", f"{len(dataset_matches)} diseases matched", "green" if dataset_matches else "yellow"), | |
| ("RAG Retrieval", f"{len(retrieved)} chunks retrieved", "green" if retrieved else "yellow"), | |
| ("SOAP Generation", "Complete", "green"), | |
| ] | |
| for step_name, step_detail, step_color in pipeline_steps: | |
| st.markdown(f""" | |
| <div class="pipeline-step"> | |
| <div class="dot dot-{step_color}"></div> | |
| <div style="flex:1"> | |
| <span style="font-weight:600;font-size:0.85rem;color:var(--text-primary)">{step_name}</span> | |
| <span style="color:var(--text-secondary);font-size:0.8rem;margin-left:0.5rem">— {step_detail}</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown('<div class="section-title">Input Summary</div>', unsafe_allow_html=True) | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:0.9rem;font-size:0.82rem;color:var(--text-secondary)"> | |
| <div style="font-size:0.72rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-secondary);margin-bottom:0.3rem">Original Input</div> | |
| {original_input or "—"} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with col_b: | |
| st.markdown(f""" | |
| <div style="background:var(--bg-secondary);border-radius:var(--radius);padding:0.9rem;font-size:0.82rem;color:var(--text-secondary)"> | |
| <div style="font-size:0.72rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-secondary);margin-bottom:0.3rem">English (post-translation)</div> | |
| {english_text or "—"} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with dev_tab2: | |
| st.markdown('<div class="section-title">Pipeline Timing Breakdown</div>', unsafe_allow_html=True) | |
| timing_labels = { | |
| "transcription_ms": "Transcription", | |
| "translation_ms": "Translation", | |
| "classification_ms": "Classification", | |
| "extraction_ms": "Extraction", | |
| "risk_scoring_ms": "Risk Scoring", | |
| "disease_lookup_ms": "Disease Lookup", | |
| "rag_retrieval_ms": "RAG Retrieval", | |
| "soap_generation_ms": "SOAP Report", | |
| "total_ms": "TOTAL" | |
| } | |
| if timings: | |
| cols = st.columns(4) | |
| i = 0 | |
| for key, label in timing_labels.items(): | |
| if key in timings: | |
| val = timings[key] | |
| with cols[i % 4]: | |
| border_color = "var(--cyan)" if key == "total_ms" else "var(--border-subtle)" | |
| st.markdown(f""" | |
| <div class="timing-pill" style="border:1px solid {border_color};margin-bottom:0.5rem"> | |
| <span class="timing-value" style="color:{'var(--cyan)' if key=='total_ms' else 'var(--text-primary)'}">{val}ms</span> | |
| <span class="timing-label">{label}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| i += 1 | |
| total = timings.get("total_ms", 0) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| if total > 0: | |
| st.markdown('<div class="section-title">Time Distribution</div>', unsafe_allow_html=True) | |
| render_keys = [k for k in timing_labels if k != "total_ms" and k in timings] | |
| for key in render_keys: | |
| label = timing_labels[key] | |
| val = timings[key] | |
| pct = min(int((val / total) * 100), 100) | |
| st.markdown(f""" | |
| <div style="margin:0.35rem 0"> | |
| <div style="display:flex;justify-content:space-between;font-size:0.75rem;color:var(--text-secondary);margin-bottom:0.2rem"> | |
| <span>{label}</span> | |
| <span style="font-family:var(--font-mono)">{val}ms ({pct}%)</span> | |
| </div> | |
| <div style="background:var(--bg-secondary);border-radius:999px;height:5px"> | |
| <div style="width:{pct}%;height:100%;background:var(--cyan);border-radius:999px;opacity:0.7"></div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No timing data available.") | |
| with dev_tab3: | |
| st.markdown('<div class="section-title">Raw Analysis JSON</div>', unsafe_allow_html=True) | |
| st.code(json.dumps(analysis, indent=2, ensure_ascii=False), language="json") | |
| st.markdown('<div class="section-title">Full Pipeline Response</div>', unsafe_allow_html=True) | |
| display_result = {k: v for k, v in result.items() if k != "soap_report"} | |
| st.code(json.dumps(display_result, indent=2, ensure_ascii=False), language="json") | |
| st.download_button( | |
| "Download Full JSON", | |
| data=json.dumps(result, indent=2, ensure_ascii=False), | |
| file_name="tabeebai_debug.json", | |
| mime="application/json" | |
| ) | |
| with dev_tab4: | |
| st.markdown('<div class="section-title">RAG Retrieval Debug</div>', unsafe_allow_html=True) | |
| if retrieved: | |
| for i, chunk in enumerate(retrieved): | |
| st.markdown(f""" | |
| <div class="rag-chunk"> | |
| <div class="rag-title"> | |
| Chunk {i+1}: {chunk.get("title", "Untitled")} | |
| <span class="rag-score">score: {chunk.get("score", 0):.4f}</span> | |
| </div> | |
| <div style="font-size:0.83rem;color:var(--text-secondary);line-height:1.65;margin-top:0.4rem"> | |
| {chunk.get("text", "")} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("No RAG chunks retrieved.") | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown('<div class="section-title">Disease Lookup Debug</div>', unsafe_allow_html=True) | |
| if dataset_matches: | |
| st.code(json.dumps(dataset_matches, indent=2), language="json") | |
| else: | |
| st.info("No disease matches.") | |
| detail = analysis.get("dataset_match_detail", []) | |
| if detail: | |
| st.markdown('<div class="section-title">Match Detail Strings</div>', unsafe_allow_html=True) | |
| for item in detail: | |
| st.markdown(f'<div style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-muted);padding:0.15rem 0">{item}</div>', unsafe_allow_html=True) | |
| with dev_tab5: | |
| st.markdown('<div class="section-title">Models Used</div>', unsafe_allow_html=True) | |
| models = result.get("models_used", {}) | |
| if models: | |
| for role, model_name in models.items(): | |
| st.markdown(f""" | |
| <div style="display:flex;justify-content:space-between;align-items:center;background:var(--bg-secondary);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:0.7rem 1rem;margin:0.3rem 0"> | |
| <span style="font-size:0.85rem;color:var(--text-secondary);text-transform:capitalize">{role.replace("_"," ")}</span> | |
| <span style="font-family:var(--font-mono);font-size:0.78rem;color:var(--cyan);background:var(--cyan-dim);padding:0.2em 0.7em;border-radius:6px">{model_name}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| st.info("Model info not available.") | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown('<div class="section-title">System Health</div>', unsafe_allow_html=True) | |
| st.code(json.dumps(get_system_health(), indent=2), language="json") |