Spaces:
Running
Running
| import os | |
| import re | |
| import json | |
| import random | |
| import hashlib | |
| import hmac | |
| import uuid | |
| import shutil | |
| import tempfile | |
| import numpy as np | |
| from dataclasses import dataclass, field, asdict | |
| from typing import List, Dict, Any, Optional, Tuple, Union | |
| import gradio as gr | |
| from gradio_modal import Modal | |
| import pandas as pd | |
| from huggingface_hub import InferenceClient | |
| from huggingface_hub import CommitScheduler,HfApi,hf_hub_download | |
| from datetime import datetime | |
| #from google import genai | |
| import google.generativeai as genai | |
| from google.generativeai.types import HarmCategory, HarmBlockThreshold | |
| # LangChain imports for retrieval | |
| from langchain_community.vectorstores import FAISS | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| # ========================= | |
| # Config via env | |
| # ========================= | |
| HF_ENDPOINT_URL = os.environ.get("HF_ENDPOINT_URL", "").strip() | |
| HF_MODEL_ID = os.environ.get("HF_MODEL_ID", "google/gemma-2-2b-it").strip() | |
| HF_API_TOKEN = os.environ.get("HF_API_TOKEN", "").strip() | |
| GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", os.environ.get("GEMINI_API_KEY", "")).strip() | |
| HF_DATASET_ID = os.environ.get("HF_DATASET_ID", "").strip() | |
| ANON_ID_SECRET = os.environ.get("ANON_ID_SECRET", "").strip() | |
| DEMO_MODE = os.environ.get("DEMO_MODE", "1").strip().lower() not in {"0", "false", "no"} | |
| CONSENT_VERSION = os.environ.get("CONSENT_VERSION", "EPSIC-DEMO-CONSENT-v1").strip() | |
| DEMO_RECORD_PREFIX = os.environ.get("DEMO_RECORD_PREFIX", "demo_records").strip().strip("/") or "demo_records" | |
| if GOOGLE_API_KEY: | |
| genai.configure(api_key=GOOGLE_API_KEY) | |
| else: | |
| print("[WARN] GOOGLE_API_KEY/GEMINI_API_KEY is not set. Add it as a Space secret to enable simulated-patient responses.") | |
| if not HF_API_TOKEN: | |
| print("[WARN] HF_API_TOKEN is not set. Add it as a Space secret to enable demo feedback uploads.") | |
| if DEMO_MODE: | |
| print("[INFO] Running in DEMO_MODE. Login is not required and feedback saving is consent-gated.") | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| DISTRIBUTIONS_PATH = os.path.join(HERE, "distributions.json") | |
| EPSIC_PATH = os.path.join(HERE, "EPSIC.json") | |
| RULES_PATH = os.path.join(HERE, "rules.json") | |
| DSM5_MAPPING_PATH = os.path.join(HERE, "dsm5_mapping.json") | |
| # --- Retrieval Configuration --- | |
| FAISS_INDEX_BASE_PATH = os.path.join(HERE, "faiss_index") | |
| EMBEDDING_MODEL = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") | |
| KNOWLEDGE_BASE_MODES = { | |
| "None": None, | |
| "DSM-5": "dsm5_only", | |
| "ICD-11": "icd11_only", | |
| "DSM-5 + ICD-11": "dsm5_icd11", | |
| } | |
| # --- End Retrieval Configuration --- | |
| # Dataset folder + main file | |
| DATASET_DIR = os.path.join(HERE, "data") | |
| os.makedirs(DATASET_DIR, exist_ok=True) | |
| MAIN_DATASET_PATH = os.path.join(DATASET_DIR, "epsic_demo_feedback.csv" if DEMO_MODE else "epsic_dataset_main.csv") | |
| MAIN_JSON_PATH = os.path.join(DATASET_DIR, "epsic_demo_conversations.json" if DEMO_MODE else "epsic_conversations_main.json") | |
| DEFAULT_MISSING = "Information missing/screen question not asked" | |
| # ========================= | |
| # Load EPSIC & Rules | |
| # ========================= | |
| with open(DISTRIBUTIONS_PATH, "r", encoding="utf-8") as fh: | |
| DISTRIBUTIONS = json.load(fh) | |
| with open(EPSIC_PATH, "r", encoding="utf-8") as fh: | |
| EPSIC = json.load(fh) | |
| with open(RULES_PATH, "r", encoding="utf-8") as fh: | |
| DIAGNOSTIC_RULES = json.load(fh) | |
| with open(DSM5_MAPPING_PATH, "r", encoding="utf-8") as fh: | |
| DSM5_MAPPING = json.load(fh) | |
| ALLOWED_MODULES = [ | |
| "SEPARATION ANXIETY DISORDER", | |
| "SOCIAL ANXIETY DISORDER", | |
| "PANIC DISORDER", | |
| "AGORAPHOBIA", | |
| "GENERALISED ANXIETY DISORDER", | |
| "OBSESSIVE–COMPULSIVE DISORDER", | |
| "PTSD/COMPLEX PTSD", | |
| "MAJOR DEPRESSIVE EPISODE", | |
| "SCREEN FOR PSYCHOTIC DISORDERS", | |
| "ADHD", | |
| "AUTISM SPECTRUM DISORDER" | |
| ] | |
| GENDER_DISTRIBUTION = {"female": 0.686, "male": 0.314} | |
| AGE_DISTRIBUTION = {"mean":14.86, "sd":1.385, "min":13, "max":18} | |
| SEVERITY_LEVELS = ["none","moderate", "severe"] | |
| PPROF_DISORDERS = [ | |
| "SEPARATION ANXIETY DISORDER", "SOCIAL ANXIETY DISORDER", "PANIC DISORDER", | |
| "AGORAPHOBIA", "GENERALISED ANXIETY DISORDER", "OBSESSIVE–COMPULSIVE DISORDER", | |
| "PTSD/COMPLEX PTSD", "MAJOR DEPRESSIVE EPISODE", "SCREEN FOR PSYCHOTIC DISORDERS", | |
| "ADHD", "ADD", "AUTISM SPECTRUM DISORDER" | |
| ] | |
| PREGEN_PROFILES_PATH = os.path.join(HERE, "simulated_patient_profiles.npy") | |
| print("Loading pre-generated patient profiles...") | |
| PREGEN_PROFILES = np.load(PREGEN_PROFILES_PATH) | |
| print(f"Successfully loaded {len(PREGEN_PROFILES)} profiles.") | |
| # ========================= | |
| # Data structures | |
| # ========================= | |
| class SelectionRecord: | |
| item_number: Union[int, float, str, None] | |
| choice: Optional[str] = None | |
| choices: Optional[List[str]] = None | |
| other_choices: Optional[List[str]] = None | |
| # --- NEW FIELD --- | |
| realism_rating: Optional[int] = None | |
| class EPSICItem: | |
| item_number: Union[int, float, str, None] | |
| assessment: str | |
| probing_questions: List[str] | |
| choices: Optional[Dict[str, Any]] = None | |
| other_choices: Optional[Dict[str, Any]] = None | |
| is_screening: bool = False | |
| class SessionState: | |
| module_name: str | |
| severity: str | |
| age: int | |
| sex: str | |
| items: List[EPSICItem] = field(default_factory=list) | |
| idx: int = 0 | |
| agent: Optional["HFInferencePatient"] = None | |
| selections: Dict[str, SelectionRecord] = field(default_factory=dict) | |
| transcript: List[Tuple[str, str]] = field(default_factory=list) | |
| asked_probes: set = field(default_factory=set) | |
| module_comments: str = "" | |
| module_rating: int = 0 # <-- NEW: Stores the overall module realism score | |
| class DatasetState: | |
| columns: List[str] | |
| df: pd.DataFrame | |
| # --- MODIFIED: Added User State Fields --- | |
| clinician_id: str = "Unknown" | |
| profession: str = "Not specified" | |
| yrs_diag_exp: str = "Not specified" | |
| yrs_prof_exp: str = "Not specified" | |
| consent_given: bool = False | |
| consent_timestamp_utc: str = "" | |
| consent_version: str = CONSENT_VERSION | |
| feedback_saving_enabled: bool = False | |
| demo_session_id: str = "" | |
| # ----------------------------------------- | |
| current_patient_data: Dict[str, Any] = field(default_factory=dict) | |
| patient_id: Optional[str] = None | |
| patient_age: Optional[int] = None | |
| patient_sex: Optional[str] = None | |
| patient_profile: Dict[str, str] = field(default_factory=dict) | |
| patient_adhd_subtype: str = "none" | |
| completed_sessions: Dict[str, SessionState] = field(default_factory=dict) | |
| current_module_index: int = -1 | |
| diag_yes_modules: set = field(default_factory=set) | |
| last_saved_path: Optional[str] = None | |
| # --- HF Hub persistence helpers --- | |
| def upload_to_hub(file_path: str, path_in_repo: Optional[str] = None): | |
| """Uploads a file to the configured HF Dataset repository.""" | |
| if not HF_API_TOKEN or not HF_DATASET_ID: | |
| print("[WARN] Cannot upload to Hub: Token or Dataset ID missing.") | |
| return | |
| api = HfApi(token=HF_API_TOKEN) | |
| repo_path = path_in_repo or os.path.basename(file_path) | |
| print(f"Uploading {file_path} to {HF_DATASET_ID}:{repo_path}...") | |
| try: | |
| api.upload_file( | |
| path_or_fileobj=file_path, | |
| path_in_repo=repo_path, | |
| repo_id=HF_DATASET_ID, | |
| repo_type="dataset" | |
| ) | |
| print(f"Successfully uploaded to {repo_path}") | |
| except Exception as e: | |
| print(f"[ERROR] Upload failed for {repo_path}: {e}") | |
| def upload_json_to_hub(data: Dict[str, Any], path_in_repo: str): | |
| if not HF_API_TOKEN or not HF_DATASET_ID: | |
| print("[WARN] Cannot upload JSON to Hub: Token or Dataset ID missing.") | |
| return | |
| tmp_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as tmp: | |
| json.dump(data, tmp, ensure_ascii=False, indent=2) | |
| tmp_path = tmp.name | |
| upload_to_hub(tmp_path, path_in_repo=path_in_repo) | |
| finally: | |
| if tmp_path and os.path.exists(tmp_path): | |
| try: | |
| os.remove(tmp_path) | |
| except OSError: | |
| pass | |
| def download_from_hub(path_in_repo: str, local_path: Optional[str] = None, force_download: bool = True) -> Optional[str]: | |
| if not HF_API_TOKEN or not HF_DATASET_ID: | |
| return None | |
| try: | |
| downloaded = hf_hub_download( | |
| repo_id=HF_DATASET_ID, | |
| repo_type="dataset", | |
| filename=path_in_repo, | |
| token=HF_API_TOKEN, | |
| force_download=force_download, | |
| ) | |
| if local_path: | |
| os.makedirs(os.path.dirname(local_path), exist_ok=True) | |
| shutil.copyfile(downloaded, local_path) | |
| return local_path | |
| return downloaded | |
| except Exception as e: | |
| print(f"[INFO] Could not download {path_in_repo} from Hub: {e}") | |
| return None | |
| def load_json_from_hub(path_in_repo: str) -> Optional[Dict[str, Any]]: | |
| local_path = download_from_hub(path_in_repo, force_download=True) | |
| if not local_path or not os.path.exists(local_path): | |
| return None | |
| try: | |
| with open(local_path, "r", encoding="utf-8") as fh: | |
| return json.load(fh) | |
| except Exception as e: | |
| print(f"[WARN] Failed to load JSON from Hub path {path_in_repo}: {e}") | |
| return None | |
| def sync_main_artifacts_from_hub(): | |
| for repo_name, local_path in [ | |
| (os.path.basename(MAIN_DATASET_PATH), MAIN_DATASET_PATH), | |
| (os.path.basename(MAIN_JSON_PATH), MAIN_JSON_PATH), | |
| (os.path.basename(SUS_DATASET_PATH), SUS_DATASET_PATH), | |
| ]: | |
| download_from_hub(repo_name, local_path=local_path, force_download=True) | |
| def resolve_space_username(profile: Optional[gr.OAuthProfile] = None, request: Optional[gr.Request] = None) -> Optional[str]: | |
| username = None | |
| if profile is not None: | |
| username = getattr(profile, "username", None) or getattr(profile, "preferred_username", None) | |
| if not username and request is not None: | |
| username = getattr(request, "username", None) | |
| if not username: | |
| return None | |
| return str(username).strip().lower() | |
| def make_anonymous_clinician_id( | |
| profile: Optional[gr.OAuthProfile] = None, | |
| request: Optional[gr.Request] = None, | |
| fallback_identifier: Optional[str] = None, | |
| ) -> Optional[str]: | |
| base_identifier = resolve_space_username(profile, request) or (fallback_identifier.strip().lower() if fallback_identifier else None) | |
| if not base_identifier: | |
| return None | |
| secret = ANON_ID_SECRET or f"insecure::{HF_DATASET_ID or 'local-space'}" | |
| digest = hmac.new(secret.encode("utf-8"), base_identifier.encode("utf-8"), hashlib.sha256).hexdigest() | |
| return f"CLN-{digest[:12].upper()}" | |
| def make_demo_session_id() -> str: | |
| """Create a non-login, session-scoped identifier for symposium demo feedback.""" | |
| timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S") | |
| suffix = uuid.uuid4().hex[:8].upper() | |
| return f"DEMO-{timestamp}-{suffix}" | |
| def normalize_optional_background(value: Any, default: str = "Not specified") -> str: | |
| if value is None: | |
| return default | |
| text = str(value).strip() | |
| if text in {"", "None", "nan"}: | |
| return default | |
| try: | |
| if isinstance(value, float) and np.isnan(value): | |
| return default | |
| except Exception: | |
| pass | |
| return text | |
| def optional_number_value(value: Any) -> Optional[int]: | |
| try: | |
| text = str(value).strip() | |
| if text in {"", "None", "Not specified", "nan"}: | |
| return None | |
| return int(float(text)) | |
| except Exception: | |
| return None | |
| def apply_demo_feedback_choice(ds: DatasetState, feedback_choice: Optional[str]): | |
| """Set consent/saving flags from the attendee's choice on the landing page.""" | |
| wants_to_save = str(feedback_choice or "").startswith("I consent") | |
| ds.feedback_saving_enabled = bool(wants_to_save) | |
| ds.consent_given = bool(wants_to_save) | |
| ds.consent_version = CONSENT_VERSION | |
| if wants_to_save and not ds.consent_timestamp_utc: | |
| ds.consent_timestamp_utc = datetime.utcnow().isoformat() | |
| if not wants_to_save: | |
| ds.consent_timestamp_utc = "" | |
| if not ds.demo_session_id: | |
| ds.demo_session_id = make_demo_session_id() | |
| ds.clinician_id = ds.demo_session_id | |
| return ds | |
| def feedback_not_saved_message() -> str: | |
| return "No feedback was saved because this session was started without consent to contribute demo feedback." | |
| def checkpoint_repo_path(anon_id: str, patient_id: str) -> str: | |
| return f"{DEMO_RECORD_PREFIX}/active_sessions/{anon_id}/{patient_id}.json" if DEMO_MODE else f"active_sessions/{anon_id}/{patient_id}.json" | |
| def active_pointer_repo_path(anon_id: str) -> str: | |
| return f"{DEMO_RECORD_PREFIX}/active_sessions/{anon_id}/active.json" if DEMO_MODE else f"active_sessions/{anon_id}/active.json" | |
| def patient_csv_repo_path(patient_id: str) -> str: | |
| return f"{DEMO_RECORD_PREFIX}/records/{patient_id}.csv" if DEMO_MODE else f"records/{patient_id}.csv" | |
| def patient_json_repo_path(patient_id: str) -> str: | |
| return f"{DEMO_RECORD_PREFIX}/records/{patient_id}.json" if DEMO_MODE else f"records/{patient_id}.json" | |
| # ========================= | |
| # Retrieval Logic | |
| # ========================= | |
| def load_retriever(mode: str) -> Optional[Any]: | |
| """Load the appropriate FAISS vector store retriever for the given mode.""" | |
| index_name = KNOWLEDGE_BASE_MODES.get(mode) | |
| if not index_name: | |
| print("No knowledge base selected.") | |
| return None | |
| index_path = os.path.join(FAISS_INDEX_BASE_PATH, index_name) | |
| if not os.path.exists(index_path): | |
| print(f"[WARN] Knowledge base not found at path: {index_path}") | |
| return None | |
| try: | |
| faiss_store = FAISS.load_local( | |
| index_path, | |
| EMBEDDING_MODEL, | |
| allow_dangerous_deserialization=True | |
| ) | |
| print(f"Successfully loaded knowledge base: {mode}") | |
| return faiss_store.as_retriever(search_kwargs={'k': 2}) | |
| except Exception as e: | |
| print(f"[ERROR] Could not load knowledge base '{mode}': {e}") | |
| return None | |
| # ========================= | |
| # EPSIC utilities | |
| # ========================= | |
| def _collect_items(module_obj: Dict[str, Any]) -> List[EPSICItem]: | |
| items: List[EPSICItem] = [] | |
| scr = module_obj.get("screening") | |
| if scr: | |
| items.append(EPSICItem( | |
| item_number=scr.get("item_number", "S1"), | |
| assessment=scr.get("assessment", "") or "", | |
| probing_questions=scr.get("probing questions", []) or [], | |
| choices=scr.get("choices"), | |
| other_choices=scr.get("Other_choices"), | |
| is_screening=True, | |
| )) | |
| assessments = module_obj.get("assessments", []) or [] | |
| for blk in assessments: | |
| if isinstance(blk, dict) and "section" in blk and "items" in blk: | |
| for it in blk.get("items", []) or []: | |
| items.append(EPSICItem( | |
| item_number=it.get("item_number"), | |
| assessment=it.get("assessment", "") or "", | |
| probing_questions=it.get("probing questions", []) or [], | |
| choices=it.get("choices"), | |
| other_choices=None, | |
| is_screening=False, | |
| )) | |
| else: | |
| items.append(EPSICItem( | |
| item_number=blk.get("item_number"), | |
| assessment=blk.get("assessment", "") or "", | |
| probing_questions=blk.get("probing questions", []) or [], | |
| choices=blk.get("choices"), | |
| other_choices=None, | |
| is_screening=False, | |
| )) | |
| return items | |
| def get_module_data(module_name: str) -> Dict[str, Any]: | |
| for m in EPSIC: | |
| if m["module"] == module_name: | |
| return m | |
| raise ValueError(f"Module not found: {module_name}") | |
| def get_items_for_module(module_name: str) -> List[EPSICItem]: | |
| return _collect_items(get_module_data(module_name)) | |
| def module_prefix(module_name: str) -> str: | |
| words = [w for w in re.split(r'[\s\–/]+', module_name) if w.strip()] | |
| if words and words[-1].upper().startswith("DISORDER"): | |
| words = words[:-1] | |
| segs = [w[:3].upper() for w in words] | |
| return "_".join(segs) | |
| def item_code(mod_prefix: str, item: EPSICItem) -> str: | |
| num = str(item.item_number) if item.item_number is not None else "NA" | |
| num = num.replace(".", "_").replace("-", "_").upper() | |
| code = f"{mod_prefix}_{num}" | |
| if item.is_screening: | |
| code = f"{code}_SCREEN" | |
| return code | |
| # Precompute schema | |
| MODULE_PREFIX = {m: module_prefix(m) for m in ALLOWED_MODULES} | |
| MODULE_ITEMS: Dict[str, List[EPSICItem]] = {m: get_items_for_module(m) for m in ALLOWED_MODULES} | |
| MODULE_ITEM_CODES: Dict[str, List[str]] = {m: [item_code(MODULE_PREFIX[m], it) for it in MODULE_ITEMS[m]] for m in ALLOWED_MODULES} | |
| ALL_ITEM_COLUMNS: List[str] = [code for m in ALLOWED_MODULES for code in MODULE_ITEM_CODES[m]] | |
| RATING_COLUMNS: List[str] = [f"RAT_{code}" for code in ALL_ITEM_COLUMNS] | |
| MODULE_RATING_COLUMNS: List[str] = [f"MOD_RAT_{MODULE_PREFIX[m]}" for m in ALLOWED_MODULES] | |
| COMMENT_COLUMNS: List[str] = [f"COM_{MODULE_PREFIX[m]}" for m in ALLOWED_MODULES] | |
| DIAG_COLUMNS: List[str] = [f"DIA_{MODULE_PREFIX[m]}" for m in ALLOWED_MODULES] | |
| DEMO_COLUMNS = [ | |
| "DemoSessionID", "ClinicianID", "ConsentGiven", "ConsentTimestampUTC", "ConsentVersion", | |
| "Profession", "YrsDiagExp", "YrsProfExp" | |
| ] | |
| MASTER_COLUMNS: List[str] = DEMO_COLUMNS + ["PatientID", "Sex", "Age"] + ALL_ITEM_COLUMNS + RATING_COLUMNS + MODULE_RATING_COLUMNS + COMMENT_COLUMNS + DIAG_COLUMNS | |
| # Path for SUS Validation Data | |
| SUS_DATASET_PATH = os.path.join(DATASET_DIR, "epsic_demo_sus_results.csv" if DEMO_MODE else "validation_sus_results.csv") | |
| #severity display mapping | |
| SEVERITY_DISPLAY_MAP = { | |
| "none": "No Symptoms", | |
| "moderate": "Subclinical Symptoms", | |
| "severe": "Clinical Symptoms" | |
| } | |
| # ========================= | |
| # Dataset I/O + ID helpers | |
| # ========================= | |
| def load_or_init_main_dataset() -> pd.DataFrame: | |
| sync_main_artifacts_from_hub() | |
| if os.path.exists(MAIN_DATASET_PATH): | |
| try: | |
| df = pd.read_csv(MAIN_DATASET_PATH) | |
| for col in MASTER_COLUMNS: | |
| if col not in df.columns: | |
| if col.startswith("DIA_"): | |
| df[col] = "No" | |
| else: | |
| df[col] = "" | |
| df = df[MASTER_COLUMNS] | |
| except Exception as e: | |
| print(f"Error loading main dataset: {e}. Re-initializing.") | |
| df = pd.DataFrame(columns=MASTER_COLUMNS) | |
| else: | |
| df = pd.DataFrame(columns=MASTER_COLUMNS) | |
| return df | |
| def scan_all_patient_ids() -> set: | |
| ids = set() | |
| for fn in os.listdir(DATASET_DIR): | |
| if fn.lower().endswith(".csv"): | |
| try: | |
| path = os.path.join(DATASET_DIR, fn) | |
| df = pd.read_csv(path, usecols=["PatientID"]) | |
| ids.update(set(str(x) for x in df["PatientID"].dropna().astype(str))) | |
| except Exception: | |
| continue | |
| return ids | |
| def sanitize_filename(name: str) -> str: | |
| s = re.sub(r"[^A-Za-z0-9_\-]", "_", name.strip()) | |
| return s or "dataset" | |
| def unique_dataset_path(preferred_name: Optional[str] = None) -> str: | |
| if preferred_name: | |
| base = sanitize_filename(preferred_name) | |
| else: | |
| base = f"epsic_dataset_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}" | |
| path = os.path.join(DATASET_DIR, f"{base}.csv") | |
| if not os.path.exists(path): | |
| return path | |
| k = 1 | |
| while True: | |
| path_try = os.path.join(DATASET_DIR, f"{base}_{k}.csv") | |
| if not os.path.exists(path_try): | |
| return path_try | |
| k += 1 | |
| # ========================= | |
| # HF Inference patient (chat only) | |
| # ========================= | |
| class HFInferencePatient: | |
| def __init__(self, module_data: Dict[str, Any], severity: str, age: int, sex: str, | |
| retriever: Optional[Any] = None, diagnostic_rule: Optional[Dict] = None, | |
| patient_profile: Dict[str, str] = {}, | |
| profile_adhd_subtype: Optional[str] = None, # <-- This is the profile's subtype | |
| current_module_adhd_subtype: Optional[str] = None): # <-- This is for the current module | |
| self.module_name = module_data["module"] | |
| self.info = module_data.get("information", "") | |
| self.severity = severity | |
| self.age = age | |
| self.sex = sex | |
| self.retriever = retriever | |
| self.diagnostic_rule = diagnostic_rule | |
| self.patient_profile = patient_profile | |
| self.profile_adhd_subtype = profile_adhd_subtype # <-- Store profile subtype | |
| self.current_module_adhd_subtype = current_module_adhd_subtype # <-- Store current module subtype | |
| self.persona = self._generate_persona(module_data, severity) | |
| # --- 1. DEFINE SAFETY SETTINGS --- | |
| safety_settings = { | |
| HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, | |
| HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, | |
| HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, | |
| HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, | |
| } | |
| # --- 2. DEFINE GENERATION CONFIG --- | |
| generation_config = { | |
| "temperature": 0.9, | |
| "top_p": 0.95, | |
| "top_k": 40, | |
| } | |
| # --- 3. INITIALIZE MODEL & CHAT --- | |
| try: | |
| # --- MODIFICATION --- | |
| # generation_config is passed HERE, in the constructor | |
| self.model = genai.GenerativeModel( | |
| "gemini-2.5-flash", | |
| safety_settings=safety_settings, | |
| generation_config=generation_config # <-- MOVED TO HERE | |
| ) | |
| # --- END MODIFICATION --- | |
| except Exception as e: | |
| print(f"[ERROR] Could not initialize GenerativeModel: {e}") | |
| self.model = None | |
| static_system_prompt = self._get_static_system_prompt() | |
| if self.model: | |
| # --- MODIFICATION --- | |
| # generation_config is REMOVED from here | |
| self.chat_session = self.model.start_chat( | |
| history=[ | |
| {'role': 'user', 'parts': [static_system_prompt]}, | |
| {'role': 'model', 'parts': ["Okay, I understand. I am ready to answer as the patient. I will be consistent with my *full* diagnostic profile, but I will focus my answers on the module I am currently being assessed for. I will wait for your first question."]} | |
| ] | |
| ) | |
| # --- END MODIFICATION --- | |
| else: | |
| self.chat_session = None | |
| def _generate_persona(self, module_data: Dict[str, Any], severity: str) -> str: | |
| # (This method is unchanged) | |
| persona_parts = [] | |
| if severity == "none": | |
| return "This patient is generally healthy and does not present with any symptoms for this module." | |
| if self.module_name == "SPECIFIC PHOBIA" and severity != "none": | |
| try: | |
| phobia_choices = module_data["screening"]["Other_choices"]["choices"]["names"] | |
| chosen_phobia = random.choice(phobia_choices) | |
| persona_parts.append(f"The patient has a specific phobia of: {chosen_phobia}.") | |
| except (KeyError, IndexError): | |
| pass | |
| response_style = random.choice([ | |
| "tends to be very brief and to the point (1-2 sentences, sometimes just one word)", | |
| "is hesitant and thoughtful in their responses (might be brief or pause)", | |
| "is a bit more talkative and will share some examples (but keeps answers to 3-4 sentences at most)" | |
| ]) | |
| persona_parts.append(f"Their response style is that they {response_style}.") | |
| return " ".join(persona_parts) | |
| def _retrieve_context(self) -> str: | |
| # (This method is unchanged) | |
| if not self.retriever: | |
| return "" | |
| try: | |
| query = f"{self.module_name} symptoms and diagnostic criteria for a {self.age}-year-old {self.sex}" | |
| docs = self.retriever.invoke(query) | |
| if not docs: | |
| return "" | |
| context = "\n".join([doc.page_content for doc in docs]) | |
| return f"Retrieved Knowledge Base Context:\n{context}\n\n" | |
| except Exception as e: | |
| print(f"[WARN] Could not retrieve context from knowledge base: {e}") | |
| return "" | |
| def _get_static_system_prompt(self) -> str: | |
| """Generates the static system prompt, fixed for the entire module session.""" | |
| sev_style = { | |
| "moderate": ( | |
| "Your symptoms are moderate. You have noticeable symptoms, but they are *not* severe or frequent enough to meet the full diagnostic criteria. You must present as a 'subclinical' case." | |
| " You MUST provide examples of symptoms when asked." | |
| ), | |
| "severe": "Your symptoms are severe, causing significant distress and impairment. They substantially interfere with your daily functioning and quality of life. You meet the full diagnostic criteria.", | |
| "none": "You have NO symptoms of this disorder. You are generally healthy and functioning well in this area." | |
| }.get(self.severity) | |
| # --- This part uses current_module_adhd_subtype --- | |
| special_instructions = "" | |
| if self.severity == "moderate": | |
| special_instructions = ( | |
| "IMPORTANT: As a 'moderate' case, you must *not* meet the full diagnostic criteria. " | |
| "When asked about symptoms, you should confirm some, but deny or minimize others, especially those related to duration, frequency, or severe impairment." | |
| ) | |
| # Use the subtype specific to the *current module* | |
| if self.module_name == "ADHD" and self.current_module_adhd_subtype and self.current_module_adhd_subtype != "none": | |
| subtype_text = self.current_module_adhd_subtype.replace("_", " ").title() | |
| special_instructions += f"\nFor this ADHD module, you are specifically simulating the **{subtype_text} Presentation**." | |
| rule_text = "" | |
| if self.diagnostic_rule: | |
| rule_text = f"The overall diagnostic rule for {self.module_name} is: \"{self.diagnostic_rule.get('rule', 'Not specified.')}\"\n" | |
| # --- This part uses profile_adhd_subtype --- | |
| profile_summary_parts = [ | |
| "--- Your Full Diagnostic Profile (Background Context) ---", | |
| "This is your complete psychological profile. Your answers should be consistent with this full picture. For example, if you have severe depression, it should color your answers even when being asked about anxiety." | |
| ] | |
| if not self.patient_profile: | |
| profile_summary_parts.append("- No profile provided.") | |
| else: | |
| for mod, sev in self.patient_profile.items(): | |
| sev_text = sev.capitalize() | |
| if mod == self.module_name: | |
| sev_text = f"**{sev_text} (This is the module you are being assessed for *now*)**" | |
| # Use the subtype from the *overall profile* | |
| if mod == "ADHD" and self.patient_profile.get("ADHD", "none") != "none" and self.profile_adhd_subtype and self.profile_adhd_subtype != "none": | |
| sev_text += f" ({self.profile_adhd_subtype.replace('_', ' ')} type)" | |
| profile_summary_parts.append(f"- {mod}: {sev_text}") | |
| full_profile_text = "\n".join(profile_summary_parts) | |
| # --- Assemble the final prompt --- | |
| system_prompt = ( | |
| f"You are simulating a {self.age}-year-old {self.sex} patient during a clinician-led EPSIC interview.\n" | |
| f"Your response style is that you {self.persona}\n\n" | |
| f"{full_profile_text}\n\n" | |
| f"--- Instructions for the CURRENT Module ({self.module_name}) ---\n" | |
| f"You are now being assessed for **{self.module_name}**.\n" | |
| f"Your simulated severity level for *this* module is: **{self.severity.capitalize()}**.\n" | |
| f"Severity Definition: {sev_style}\n" | |
| f"{special_instructions}\n" | |
| f"{rule_text}" | |
| f"You MUST adhere to your full profile and *especially* the instructions for the current module. Answer ONLY as the patient, in the first person. Keep your response natural and conversational." | |
| f"Your memory of this conversation is preserved. Be consistent with your previous answers." | |
| ) | |
| return system_prompt | |
| def _build_user_turn(self, question: str, item: EPSICItem) -> str: | |
| """Builds the content for the user's turn, including dynamic context.""" | |
| dynamic_instructions = [] | |
| if self.severity == "moderate" and item.is_screening: | |
| dynamic_instructions.append( | |
| "Note for this item: This is a screening question. You MUST answer affirmatively (e.g., start with 'Yes, sometimes I do...', 'I guess, ...') to proceed with the assessment. You should give the impression that the symptoms exist, but not to a sufficient extent for diagnosis." | |
| ) | |
| dsm_code = DSM5_MAPPING.get(self.module_name, {}).get(str(item.item_number)) | |
| if dsm_code: | |
| dynamic_instructions.append(f"This question relates to DSM-5 Criterion {dsm_code} for this disorder.") | |
| instruction_text = "" | |
| if dynamic_instructions: | |
| instruction_text = "--- Context for this specific item ---\n" + "\n".join(dynamic_instructions) + "\n\n" | |
| epsic_context = f"Clinical context on {self.module_name}:\n{self.info}\n\n" if self.info else "" | |
| retrieved_context = self._retrieve_context() | |
| item_context = ( | |
| f"Background for Clinician (do not repeat):\n{epsic_context}{retrieved_context}" | |
| f"Current EPSIC Item Being Assessed:\n{item.assessment}\n\n" | |
| ) | |
| user_turn_content = ( | |
| f"{instruction_text}" | |
| f"{item_context}" | |
| f"The clinician now asks you: \"{question}\"" | |
| ) | |
| print(f"[DEBUG] User turn content:\n{user_turn_content}") | |
| return user_turn_content | |
| def respond(self, question: str, item: EPSICItem) -> str: | |
| user_turn_content = self._build_user_turn(question, item) | |
| if not GOOGLE_API_KEY: | |
| return "[Agent Response Disabled: GOOGLE_API_KEY/GEMINI_API_KEY not set]" | |
| if not self.chat_session: | |
| return "[Agent Error: Chat session not initialized]" | |
| try: | |
| response = self.chat_session.send_message(user_turn_content) | |
| response_text = response.text.strip() | |
| return response_text | |
| except Exception as e: | |
| print(f"[ERROR] LLM generation failed: {e}") | |
| try: | |
| print("[DEBUG] Chat History on Error:") | |
| for msg in self.chat_session.history: | |
| role = msg.role | |
| text = msg.parts[0].text if msg.parts else "[No Text]" | |
| print(f" [{role}] {text[:150]}...") | |
| except Exception as de: | |
| print(f"[ERROR] Could not print debug history: {de}") | |
| return "[Agent Error: Could not generate a response]" | |
| """ | |
| if not GOOGLE_API_KEY: | |
| return "[Agent Response Disabled: GOOGLE_API_KEY/GEMINI_API_KEY not set]" | |
| try: | |
| result = self.client.chat.completions.create( | |
| model=os.getenv("HF_MODEL_ID", None), | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=256, | |
| temperature=0.8, | |
| top_p=0.95, | |
| ) | |
| msg = result.choices[0].message | |
| content = msg.get("content", "") | |
| if isinstance(content, list): | |
| text = "".join(part.get("text", "") for part in content) | |
| else: | |
| text = content or "" | |
| response_text = text.strip() | |
| self.memory[question] = response_text | |
| return response_text | |
| except Exception as e: | |
| print(f"[ERROR] LLM generation failed: {e}") | |
| return "[Agent Error: Could not generate a response]" | |
| """ | |
| # ========================= | |
| # Module/session callbacks | |
| # ========================= | |
| def _choices_for_item(st: SessionState, item: Optional[EPSICItem]): | |
| single_opts, multi_opts, other_opts = [], [], [] | |
| selected_single, selected_multi, selected_other = None, [], [] | |
| if not item: | |
| return single_opts, multi_opts, other_opts, selected_single, selected_multi, selected_other | |
| def process_choices(choices_obj): | |
| s_opts, m_opts = [], [] | |
| if isinstance(choices_obj, list): | |
| for choice_block in choices_obj: | |
| names = choice_block.get("names", []) | |
| if choice_block.get("type", "").startswith("single"): | |
| s_opts.extend(names) | |
| else: | |
| m_opts.extend(names) | |
| elif isinstance(choices_obj, dict): | |
| names = choices_obj.get("names", []) | |
| if choices_obj.get("type", "").startswith("single"): | |
| s_opts = names | |
| else: | |
| m_opts = names | |
| return s_opts, m_opts | |
| if item.choices: | |
| single_opts, multi_opts = process_choices(item.choices) | |
| if item.other_choices and "choices" in item.other_choices: | |
| _, other_opts = process_choices(item.other_choices["choices"]) | |
| key = str(item.item_number) | |
| if key in st.selections: | |
| rec = st.selections[key] | |
| selected_single = rec.choice | |
| selected_multi = rec.choices or [] | |
| selected_other = rec.other_choices or [] | |
| return single_opts, multi_opts, other_opts, selected_single, selected_multi, selected_other | |
| def _current_item(st: SessionState) -> Optional[EPSICItem]: | |
| if not st or not st.items: | |
| return None | |
| return st.items[st.idx] | |
| def _format_item_header(item: EPSICItem, module_name: str) -> str: | |
| if not item: | |
| return "" | |
| item_num_str = str(item.item_number) | |
| desc_parts = [] | |
| if item.is_screening: | |
| desc_parts.append("Screening Item") | |
| dsm_code = DSM5_MAPPING.get(module_name, {}).get(item_num_str) | |
| if not dsm_code and '.' in item_num_str: | |
| dsm_code = DSM5_MAPPING.get(module_name, {}).get(item_num_str.replace('.', '')) | |
| if dsm_code: | |
| desc_parts.append(f"DSM-5 Criterion {dsm_code}") | |
| description = " / ".join(desc_parts) | |
| header = f"### Current EPSI-C Item: {item_num_str}" | |
| if description: | |
| header += f" ({description})" | |
| return header + f"\n\n**Assessment:** {item.assessment}" | |
| def _create_module_session(module_name: str, age: int, sex: str, severity: str, | |
| knowledge_base_mode: str, patient_profile: Dict[str, str], | |
| profile_adhd_subtype: Optional[str] = None, # <-- ADD THIS | |
| current_module_adhd_subtype: Optional[str] = None) -> SessionState: # <-- RENAME THIS | |
| st = SessionState(module_name=module_name, severity=severity, age=age, sex=sex) | |
| st.items = get_items_for_module(module_name) | |
| st.idx = 0 | |
| module_data = get_module_data(module_name) | |
| # Fetch the diagnostic rule for the current module | |
| diagnostic_rule = DIAGNOSTIC_RULES.get(module_name) | |
| retriever = load_retriever(knowledge_base_mode) | |
| # Pass the rule, profile, and subtypes to the agent's constructor | |
| st.agent = HFInferencePatient( | |
| module_data, | |
| severity, | |
| st.age, | |
| st.sex, | |
| retriever, | |
| diagnostic_rule, | |
| patient_profile=patient_profile, | |
| profile_adhd_subtype=profile_adhd_subtype, # <-- PASS IT | |
| current_module_adhd_subtype=current_module_adhd_subtype # <-- PASS IT | |
| ) | |
| st.transcript = [] | |
| return st | |
| def rehydrate_session_for_interview(ds: DatasetState, stored_session: SessionState) -> SessionState: | |
| current_module_adhd_subtype = ds.patient_adhd_subtype if stored_session.module_name == "ADHD" else None | |
| profile_adhd_subtype = ds.patient_adhd_subtype | |
| st = _create_module_session( | |
| module_name=stored_session.module_name, | |
| age=ds.patient_age, | |
| sex=ds.patient_sex, | |
| severity=stored_session.severity, | |
| knowledge_base_mode="DSM-5 + ICD-11", | |
| patient_profile=ds.patient_profile, | |
| profile_adhd_subtype=profile_adhd_subtype, | |
| current_module_adhd_subtype=current_module_adhd_subtype, | |
| ) | |
| st.idx = min(max(int(stored_session.idx), 0), max(len(st.items) - 1, 0)) if st.items else 0 | |
| st.selections = { | |
| k: (SelectionRecord(**asdict(v)) if isinstance(v, SelectionRecord) else SelectionRecord(**v)) | |
| for k, v in stored_session.selections.items() | |
| } | |
| st.transcript = list(stored_session.transcript) | |
| st.asked_probes = set(stored_session.asked_probes) | |
| st.module_comments = stored_session.module_comments | |
| st.module_rating = stored_session.module_rating | |
| saved_history = getattr(stored_session, "saved_chat_history", None) | |
| if saved_history and st.agent and st.agent.model: | |
| try: | |
| st.agent.chat_session = st.agent.model.start_chat(history=saved_history) | |
| except Exception as e: | |
| print(f"[WARN] Could not restore saved chat history for {stored_session.module_name}: {e}") | |
| return st | |
| def dataset_state_from_checkpoint(payload: Dict[str, Any]) -> Tuple[DatasetState, Optional[SessionState]]: | |
| ds = init_dataset_state() | |
| ds.clinician_id = payload.get("clinician_id", ds.clinician_id) | |
| ds.profession = payload.get("profession", ds.profession) | |
| ds.yrs_diag_exp = str(payload.get("yrs_diag_exp", ds.yrs_diag_exp)) | |
| ds.yrs_prof_exp = str(payload.get("yrs_prof_exp", ds.yrs_prof_exp)) | |
| ds.consent_given = bool(payload.get("consent_given", ds.consent_given)) | |
| ds.consent_timestamp_utc = payload.get("consent_timestamp_utc", ds.consent_timestamp_utc) | |
| ds.consent_version = payload.get("consent_version", ds.consent_version) | |
| ds.feedback_saving_enabled = bool(payload.get("feedback_saving_enabled", ds.feedback_saving_enabled)) | |
| ds.demo_session_id = payload.get("demo_session_id", ds.demo_session_id) or ds.clinician_id | |
| ds.patient_id = payload.get("patient_id") | |
| ds.patient_age = payload.get("patient_age") | |
| ds.patient_sex = payload.get("patient_sex") | |
| ds.patient_profile = payload.get("patient_profile", {}) or {} | |
| ds.patient_adhd_subtype = payload.get("patient_adhd_subtype", "none") or "none" | |
| ds.current_patient_data = payload.get("current_patient_data", {}) or {} | |
| ds.current_module_index = int(payload.get("current_module_index", -1)) | |
| ds.diag_yes_modules = set(payload.get("diag_yes_modules", []) or []) | |
| ds.completed_sessions = {} | |
| for module_name, sess_payload in (payload.get("completed_sessions") or {}).items(): | |
| st = simple_session_from_dict(sess_payload) | |
| st.saved_chat_history = sess_payload.get("chat_history", []) | |
| ds.completed_sessions[module_name] = st | |
| current_session_payload = payload.get("current_session") | |
| current_session = None | |
| if current_session_payload: | |
| current_stored = simple_session_from_dict(current_session_payload) | |
| current_stored.saved_chat_history = current_session_payload.get("chat_history", []) | |
| current_session = rehydrate_session_for_interview(ds, current_stored) | |
| return ds, current_session | |
| def maybe_restore_checkpoint(profile: Optional[gr.OAuthProfile] = None, request: Optional[gr.Request] = None) -> Tuple[DatasetState, Optional[SessionState], Optional[str]]: | |
| if DEMO_MODE: | |
| ds = init_dataset_state() | |
| return ds, None, ds.clinician_id | |
| anon_id = make_anonymous_clinician_id(profile, request) | |
| if not anon_id: | |
| ds = init_dataset_state() | |
| return ds, None, None | |
| pointer = load_json_from_hub(active_pointer_repo_path(anon_id)) | |
| if not pointer or pointer.get("status") != "active" or not pointer.get("patient_id"): | |
| ds = init_dataset_state() | |
| ds.clinician_id = anon_id | |
| return ds, None, anon_id | |
| payload = load_json_from_hub(checkpoint_repo_path(anon_id, pointer["patient_id"])) | |
| if not payload: | |
| ds = init_dataset_state() | |
| ds.clinician_id = anon_id | |
| return ds, None, anon_id | |
| ds, current_session = dataset_state_from_checkpoint(payload) | |
| ds.clinician_id = anon_id | |
| return ds, current_session, anon_id | |
| def persist_module_session_into_dataset(ds: DatasetState, st: Optional[SessionState], comments: str = "", mod_rating_val: int = 0): | |
| if not ds or not st: | |
| return | |
| st.module_comments = (comments or "").strip() | |
| st.module_rating = int(mod_rating_val or 0) | |
| ds.completed_sessions[st.module_name] = st | |
| mp = MODULE_PREFIX[st.module_name] | |
| for it in st.items: | |
| key = str(it.item_number) | |
| code = item_code(mp, it) | |
| rating_col = f"RAT_{code}" | |
| cell = "" | |
| rec = st.selections.get(key) | |
| if rec: | |
| if rec.choice: | |
| cell = rec.choice | |
| elif rec.choices: | |
| cell = "; ".join(rec.choices) | |
| if rec.other_choices: | |
| other = "; ".join(rec.other_choices) | |
| cell = f"{cell} | Other: {other}" if cell else f"Other: {other}" | |
| ds.current_patient_data[rating_col] = rec.realism_rating if rec.realism_rating is not None else "" | |
| else: | |
| ds.current_patient_data.setdefault(rating_col, "") | |
| ds.current_patient_data[code] = cell | |
| ds.current_patient_data[f"COM_{mp}"] = st.module_comments | |
| ds.current_patient_data[f"MOD_RAT_{mp}"] = st.module_rating | |
| def change_module(dataset_state: DatasetState, current_session: Optional[SessionState], comments: str, mod_rating_val: int, nav_type: str, module_name: Optional[str] = None): | |
| if not dataset_state.patient_id: | |
| return [dataset_state, None] + [gr.update()] * 23 | |
| if nav_type == "direct" and not module_name: | |
| return [dataset_state, current_session] + [gr.update()] * 23 | |
| if current_session: | |
| persist_module_session_into_dataset(dataset_state, current_session, comments, mod_rating_val) | |
| save_checkpoint(dataset_state, current_session) | |
| if nav_type == "next": | |
| dataset_state.current_module_index += 1 | |
| elif nav_type == "prev": | |
| dataset_state.current_module_index -= 1 | |
| elif nav_type == "direct" and module_name in ALLOWED_MODULES: | |
| dataset_state.current_module_index = ALLOWED_MODULES.index(module_name) | |
| dataset_state.current_module_index = int(np.clip(dataset_state.current_module_index, 0, len(ALLOWED_MODULES) - 1)) | |
| target_module_name = ALLOWED_MODULES[dataset_state.current_module_index] | |
| severity = dataset_state.patient_profile[target_module_name] | |
| if target_module_name in dataset_state.completed_sessions: | |
| new_session = rehydrate_session_for_interview(dataset_state, dataset_state.completed_sessions[target_module_name]) | |
| else: | |
| current_module_adhd_subtype = dataset_state.patient_adhd_subtype if target_module_name == "ADHD" else None | |
| profile_adhd_subtype = dataset_state.patient_adhd_subtype | |
| new_session = _create_module_session( | |
| module_name=target_module_name, | |
| age=dataset_state.patient_age, | |
| sex=dataset_state.patient_sex, | |
| severity=severity, | |
| knowledge_base_mode="DSM-5 + ICD-11", | |
| patient_profile=dataset_state.patient_profile, | |
| profile_adhd_subtype=profile_adhd_subtype, | |
| current_module_adhd_subtype=current_module_adhd_subtype, | |
| ) | |
| assessed_count = len(dataset_state.completed_sessions) | |
| display_severity = SEVERITY_DISPLAY_MAP.get(severity, severity) | |
| summary_md = ( | |
| f"**Now Assessing:** {target_module_name} ({dataset_state.current_module_index + 1}/{len(ALLOWED_MODULES)})\n" | |
| f"**Simulated Severity:** {display_severity}\n" | |
| f"**Completed Modules:** {assessed_count}" | |
| ) | |
| header, probes_update, single_upd, multi_upd, other_upd, item_rating_upd, saved_md, progress_md = _prepare_item_view(new_session) | |
| module_data = get_module_data(target_module_name) | |
| chat_display = _build_chat_display(new_session) | |
| sev_text_lower = SEVERITY_DISPLAY_MAP.get(severity, severity).lower() | |
| new_slider_label = f"This patient emulates {sev_text_lower} for the current module. How realistic is the portrayal? (0-5)" | |
| mod_rating_upd = gr.update(value=new_session.module_rating, label=new_slider_label) | |
| is_last_module = dataset_state.current_module_index >= len(ALLOWED_MODULES) - 1 | |
| next_btn_text = "Finish Assessment" if is_last_module else "Save and Continue ➡️" | |
| save_checkpoint(dataset_state, new_session) | |
| return ( | |
| dataset_state, new_session, summary_md, | |
| gr.update(value=new_session.module_comments), mod_rating_upd, | |
| gr.update(value=chat_display), | |
| header, probes_update, single_upd, multi_upd, other_upd, item_rating_upd, | |
| saved_md, progress_md, | |
| f"### Conducting Interview: {target_module_name}", module_data.get("information", "No info."), | |
| gr.update(interactive=dataset_state.current_module_index > 0), | |
| gr.update(value=next_btn_text, interactive=True), | |
| gr.update(value=target_module_name), | |
| gr.update(variant="secondary", interactive=True), | |
| gr.update(variant="secondary", interactive=True), | |
| gr.update(interactive=True), gr.update(interactive=True), gr.update(selected=2), | |
| gr.update(visible=False) | |
| ) | |
| def nav_item(ds: DatasetState, st: SessionState, direction: str): | |
| if not st or not st.items: | |
| return ds, st, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| st.asked_probes.clear() | |
| if direction == "next": | |
| st.idx = min(st.idx + 1, len(st.items) - 1) | |
| elif direction == "prev": | |
| st.idx = max(st.idx - 1, 0) | |
| header, probes_update, single_upd, multi_upd, other_upd, item_rating_upd, saved_md, progress_md = _prepare_item_view(st) | |
| save_checkpoint(ds, st) | |
| return ds, st, header, probes_update, single_upd, multi_upd, other_upd, item_rating_upd, saved_md, progress_md | |
| def ask_selected(ds: DatasetState, st: SessionState, selected_probes: List[str]): | |
| item = _current_item(st) | |
| if not st or not st.agent or not item or not selected_probes: | |
| return ds, st, _build_chat_display(st), gr.update() | |
| questions_to_ask = [q.lstrip("✔ ").strip() for q in selected_probes] | |
| for q in questions_to_ask: | |
| reply = st.agent.respond(q, item) | |
| st.transcript.append((q, reply)) | |
| st.asked_probes.add(q) | |
| probes_list = item.probing_questions or [] | |
| formatted_probes = [f"✔ {q}" if q in st.asked_probes else q for q in probes_list] | |
| save_checkpoint(ds, st) | |
| return ds, st, _build_chat_display(st), gr.update(choices=formatted_probes, value=[]) | |
| def ask_custom(ds: DatasetState, st: SessionState, custom_q: str): | |
| item = _current_item(st) | |
| if not st or not st.agent or not item or not custom_q.strip(): | |
| return ds, st, _build_chat_display(st), "" | |
| reply = st.agent.respond(custom_q, item) | |
| st.transcript.append((custom_q, reply)) | |
| save_checkpoint(ds, st) | |
| return ds, st, _build_chat_display(st), "" | |
| def auto_save_selection(ds: DatasetState, st: SessionState, single_sel: Optional[str], multi_sel: List[str], other_sel: List[str], rating: int): | |
| item = _current_item(st) | |
| if not st or not item: | |
| return ds, st, _saved_status_text(st), gr.update(visible=False) | |
| key = str(item.item_number) | |
| rec = st.selections.get(key, SelectionRecord(item_number=item.item_number)) | |
| def get_base_choice(selection, options): | |
| if not selection or not options: | |
| return selection | |
| for opt in options: | |
| if opt.endswith(selection): | |
| return opt | |
| return selection | |
| rec.choice = single_sel if single_sel else None | |
| rec.choices = [] | |
| rec.other_choices = [] | |
| if multi_sel: | |
| all_multi_opts = _choices_for_item(st, item)[1] | |
| rec.choices = [get_base_choice(s, all_multi_opts) for s in multi_sel] | |
| if other_sel: | |
| all_other_opts = _choices_for_item(st, item)[2] | |
| rec.other_choices = [get_base_choice(s, all_other_opts) for s in other_sel] | |
| if rating is not None: | |
| rec.realism_rating = int(rating) | |
| st.selections[key] = rec | |
| modal_update = gr.update(visible=False) | |
| if item.is_screening and single_sel and str(single_sel).startswith("NO"): | |
| print("[Debug] Screening 'NO' detected. Triggering modal.") | |
| modal_update = gr.update(visible=True) | |
| save_checkpoint(ds, st) | |
| return ds, st, _saved_status_text(st), modal_update | |
| def _saved_status_text(st: SessionState) -> str: | |
| if not st or not st.selections: | |
| return "Saved selections for **0/--** items." | |
| n = len(st.selections) | |
| total = len(st.items) if st and st.items else 0 | |
| return f"Saved selections for **{n}/{total}** items." | |
| def _build_chat_display(st: Optional[SessionState]) -> List[Dict[str, str]]: | |
| chat_display: List[Dict[str, str]] = [] | |
| if not st: | |
| return chat_display | |
| for user_msg, assistant_msg in st.transcript: | |
| chat_display.append({"role": "user", "content": user_msg}) | |
| chat_display.append({"role": "assistant", "content": assistant_msg}) | |
| return chat_display | |
| def _current_item_rating_value(st: Optional[SessionState], item: Optional[EPSICItem]) -> int: | |
| if not st or not item: | |
| return 0 | |
| rec = st.selections.get(str(item.item_number)) | |
| if rec and rec.realism_rating is not None: | |
| return int(rec.realism_rating) | |
| return 0 | |
| def _prepare_item_view(st: Optional[SessionState]): | |
| if not st or not st.items: | |
| return ( | |
| gr.update(value="No active item."), | |
| gr.update(choices=[], value=[]), | |
| gr.update(choices=[], value=None, visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(value=0), | |
| _saved_status_text(st), | |
| "", | |
| ) | |
| cur = _current_item(st) | |
| header = _format_item_header(cur, st.module_name) | |
| single_opts, multi_opts, other_opts, selected_single, selected_multi, selected_other = _choices_for_item(st, cur) | |
| asked = st.asked_probes or set() | |
| probes_list = cur.probing_questions or [] | |
| formatted_probes = [f"✔ {q}" if q in asked else q for q in probes_list] | |
| progress_md = f"**Item {st.idx + 1} of {len(st.items)}**" | |
| return ( | |
| header, | |
| gr.update(choices=formatted_probes, value=[]), | |
| gr.update(choices=single_opts, value=selected_single, visible=bool(single_opts), interactive=True), | |
| gr.update(choices=multi_opts, value=selected_multi, visible=bool(multi_opts), interactive=True), | |
| gr.update(choices=other_opts, value=selected_other, visible=bool(other_opts), interactive=True), | |
| gr.update(value=_current_item_rating_value(st, cur)), | |
| _saved_status_text(st), | |
| progress_md, | |
| ) | |
| def serialize_chat_history(agent: Optional["HFInferencePatient"]) -> List[Dict[str, Any]]: | |
| history: List[Dict[str, Any]] = [] | |
| if not agent or not getattr(agent, "chat_session", None): | |
| return history | |
| for msg in getattr(agent.chat_session, "history", []) or []: | |
| parts_out: List[str] = [] | |
| for part in getattr(msg, "parts", []) or []: | |
| txt = getattr(part, "text", None) | |
| if txt is not None: | |
| parts_out.append(txt) | |
| history.append({"role": getattr(msg, "role", "user"), "parts": parts_out}) | |
| return history | |
| def session_state_to_dict(st: SessionState) -> Dict[str, Any]: | |
| return { | |
| "module_name": st.module_name, | |
| "severity": st.severity, | |
| "age": st.age, | |
| "sex": st.sex, | |
| "idx": st.idx, | |
| "selections": {k: asdict(v) for k, v in st.selections.items()}, | |
| "transcript": [[a, b] for a, b in st.transcript], | |
| "asked_probes": sorted(list(st.asked_probes)), | |
| "module_comments": st.module_comments, | |
| "module_rating": st.module_rating, | |
| "chat_history": serialize_chat_history(st.agent), | |
| } | |
| def dataset_state_to_checkpoint_dict(ds: DatasetState, current_session: Optional[SessionState]) -> Dict[str, Any]: | |
| return { | |
| "version": 1, | |
| "status": "active", | |
| "clinician_id": ds.clinician_id, | |
| "demo_session_id": ds.demo_session_id, | |
| "consent_given": ds.consent_given, | |
| "consent_timestamp_utc": ds.consent_timestamp_utc, | |
| "consent_version": ds.consent_version, | |
| "feedback_saving_enabled": ds.feedback_saving_enabled, | |
| "profession": ds.profession, | |
| "yrs_diag_exp": ds.yrs_diag_exp, | |
| "yrs_prof_exp": ds.yrs_prof_exp, | |
| "patient_id": ds.patient_id, | |
| "patient_age": ds.patient_age, | |
| "patient_sex": ds.patient_sex, | |
| "patient_profile": ds.patient_profile, | |
| "patient_adhd_subtype": ds.patient_adhd_subtype, | |
| "current_patient_data": ds.current_patient_data, | |
| "current_module_index": ds.current_module_index, | |
| "diag_yes_modules": sorted(list(ds.diag_yes_modules)), | |
| "completed_sessions": {name: session_state_to_dict(st) for name, st in ds.completed_sessions.items()}, | |
| "current_session": session_state_to_dict(current_session) if current_session else None, | |
| } | |
| def simple_session_from_dict(data: Dict[str, Any]) -> SessionState: | |
| st = SessionState( | |
| module_name=data["module_name"], | |
| severity=data["severity"], | |
| age=int(data.get("age", 0)), | |
| sex=str(data.get("sex", "")), | |
| ) | |
| st.items = get_items_for_module(st.module_name) | |
| st.idx = int(data.get("idx", 0)) | |
| st.selections = {k: SelectionRecord(**v) for k, v in (data.get("selections") or {}).items()} | |
| st.transcript = [tuple(x) for x in data.get("transcript", [])] | |
| st.asked_probes = set(data.get("asked_probes", [])) | |
| st.module_comments = data.get("module_comments", "") | |
| st.module_rating = int(data.get("module_rating", 0) or 0) | |
| return st | |
| def save_checkpoint(ds: DatasetState, current_session: Optional[SessionState]): | |
| if DEMO_MODE and not getattr(ds, "feedback_saving_enabled", False): | |
| return | |
| if not ds or not ds.patient_id or not ds.clinician_id: | |
| return | |
| payload = dataset_state_to_checkpoint_dict(ds, current_session) | |
| upload_json_to_hub(payload, checkpoint_repo_path(ds.clinician_id, ds.patient_id)) | |
| upload_json_to_hub({"patient_id": ds.patient_id, "status": "active"}, active_pointer_repo_path(ds.clinician_id)) | |
| def close_checkpoint(ds: DatasetState, status: str = "closed"): | |
| if DEMO_MODE and not getattr(ds, "feedback_saving_enabled", False): | |
| return | |
| if not ds or not ds.clinician_id: | |
| return | |
| payload = {"patient_id": ds.patient_id, "status": status, "closed_at": datetime.utcnow().isoformat()} | |
| upload_json_to_hub(payload, active_pointer_repo_path(ds.clinician_id)) | |
| def clear_patient_progress(ds: DatasetState): | |
| ds.current_patient_data = {} | |
| ds.patient_id = None | |
| ds.patient_age = None | |
| ds.patient_sex = None | |
| ds.patient_profile = {} | |
| ds.patient_adhd_subtype = "none" | |
| ds.completed_sessions = {} | |
| ds.current_module_index = -1 | |
| ds.diag_yes_modules = set() | |
| return ds | |
| # ========================= | |
| # Dataset and Summary Logic | |
| # ========================= | |
| def init_dataset_state() -> DatasetState: | |
| df = load_or_init_main_dataset() | |
| ds = DatasetState(columns=MASTER_COLUMNS, df=df) | |
| if DEMO_MODE: | |
| ds.demo_session_id = make_demo_session_id() | |
| ds.clinician_id = ds.demo_session_id | |
| ds.profession = "Not specified" | |
| ds.yrs_diag_exp = "Not specified" | |
| ds.yrs_prof_exp = "Not specified" | |
| return ds | |
| def check_active_session(dataset_state): | |
| """Checks if a patient is currently active.""" | |
| if dataset_state and dataset_state.patient_id: | |
| # If there is an ID, show the warning modal | |
| return gr.update(visible=True) | |
| else: | |
| # If no ID, don't show modal, just signal to proceed (handled via event logic below) | |
| return gr.update(visible=False) | |
| def new_patient( | |
| dataset_state: DatasetState, | |
| prof: str, | |
| y_diag: str, | |
| y_prof: str, | |
| feedback_choice: Optional[str] = None, | |
| profile: Optional[gr.OAuthProfile] = None, | |
| request: Optional[gr.Request] = None, | |
| ): | |
| if PREGEN_PROFILES is None: | |
| raise ValueError("Pre-generated profiles not loaded.") | |
| if DEMO_MODE: | |
| apply_demo_feedback_choice(dataset_state, feedback_choice) | |
| else: | |
| sync_main_artifacts_from_hub() | |
| if dataset_state.patient_id and dataset_state.clinician_id: | |
| close_checkpoint(dataset_state, status="discarded") | |
| clear_patient_progress(dataset_state) | |
| if DEMO_MODE: | |
| anon_id = dataset_state.demo_session_id or make_demo_session_id() | |
| dataset_state.demo_session_id = anon_id | |
| dataset_state.clinician_id = anon_id | |
| else: | |
| anon_id = make_anonymous_clinician_id(profile, request) | |
| if not anon_id: | |
| raise gr.Error("Please sign in with Hugging Face OAuth to start an assessment.") | |
| prof = normalize_optional_background(prof) | |
| y_diag = normalize_optional_background(y_diag) | |
| y_prof = normalize_optional_background(y_prof) | |
| total_profiles = len(PREGEN_PROFILES) | |
| idx = random.randint(0, total_profiles - 1) | |
| if DEMO_MODE: | |
| pid = f"{anon_id}-P{idx}" | |
| else: | |
| used_indices = set() | |
| if os.path.exists(MAIN_DATASET_PATH): | |
| try: | |
| df = pd.read_csv(MAIN_DATASET_PATH, usecols=["PatientID"]) | |
| for x in df["PatientID"].dropna().astype(str): | |
| if x.startswith("P") and x[1:].isdigit(): | |
| used_indices.add(int(x[1:])) | |
| except Exception: | |
| pass | |
| if os.path.exists(MAIN_JSON_PATH): | |
| try: | |
| with open(MAIN_JSON_PATH, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| if isinstance(data, list): | |
| for p in data: | |
| pid_old = p.get("patient_id", "") | |
| if isinstance(pid_old, str) and pid_old.startswith("P") and pid_old[1:].isdigit(): | |
| used_indices.add(int(pid_old[1:])) | |
| except Exception: | |
| pass | |
| if len(used_indices) < (total_profiles * 0.8): | |
| for _ in range(1000): | |
| candidate = random.randint(0, total_profiles - 1) | |
| if candidate not in used_indices: | |
| idx = candidate | |
| break | |
| else: | |
| available = list(set(range(total_profiles)) - used_indices) | |
| if available: | |
| idx = random.choice(available) | |
| pid = f"P{idx}" | |
| bin_map = dict(zip(PPROF_DISORDERS, PREGEN_PROFILES[idx])) | |
| age = int(np.clip(np.round(np.random.normal(AGE_DISTRIBUTION["mean"], AGE_DISTRIBUTION["sd"])), 13, 18)) | |
| sex = random.choices(["female", "male"], weights=[0.686, 0.314])[0] | |
| profile_map = {} | |
| adhd_sub = "none" | |
| for mod in ALLOWED_MODULES: | |
| is_sev = False | |
| sub_sev = "none" | |
| if mod == "ADHD": | |
| if bin_map.get("ADHD") == 1 or bin_map.get("ADD") == 1: | |
| is_sev = True | |
| sub_sev = "hyperactive_combined" if bin_map.get("ADHD") == 1 else "inattentive" | |
| elif bin_map.get(mod) == 1: | |
| is_sev = True | |
| if is_sev: | |
| profile_map[mod] = "severe" | |
| if mod == "ADHD": | |
| adhd_sub = sub_sev | |
| else: | |
| dist = DISTRIBUTIONS.get(mod, {}) | |
| p_mod = dist.get("2", 0) | |
| p_none = dist.get("0", 0) + dist.get("1", 0) + dist.get("system", 0) | |
| tot = p_mod + p_none | |
| profile_map[mod] = random.choices(["moderate", "none"], weights=[p_mod/tot, p_none/tot], k=1)[0] if tot > 0 else "none" | |
| if mod == "ADHD" and profile_map[mod] == "moderate": | |
| adhd_sub = random.choice(["inattentive", "hyperactive_combined"]) | |
| dataset_state.clinician_id = anon_id | |
| dataset_state.profession = prof | |
| dataset_state.yrs_diag_exp = y_diag | |
| dataset_state.yrs_prof_exp = y_prof | |
| dataset_state.patient_id = pid | |
| dataset_state.patient_age = age | |
| dataset_state.patient_sex = sex | |
| dataset_state.patient_profile = profile_map | |
| dataset_state.patient_adhd_subtype = adhd_sub | |
| dataset_state.current_patient_data = { | |
| "DemoSessionID": dataset_state.demo_session_id or anon_id, | |
| "ClinicianID": anon_id, | |
| "ConsentGiven": "Yes" if dataset_state.consent_given else "No", | |
| "ConsentTimestampUTC": dataset_state.consent_timestamp_utc, | |
| "ConsentVersion": dataset_state.consent_version, | |
| "Profession": prof, | |
| "YrsDiagExp": y_diag, | |
| "YrsProfExp": y_prof, | |
| "PatientID": pid, | |
| "Sex": sex, | |
| "Age": age, | |
| } | |
| dataset_state.completed_sessions = {} | |
| dataset_state.current_module_index = -1 | |
| dataset_state.diag_yes_modules = set() | |
| saving_status = "Feedback saving is enabled for this demo session." if dataset_state.feedback_saving_enabled else "Feedback saving is disabled for this demo session." | |
| status = ( | |
| f"🆕 New demo patient initialized: **{pid}** ({age}/{sex}).\n" | |
| f"Demo session ID: **{dataset_state.clinician_id}**\n" | |
| f"{saving_status}\n" | |
| f"**The 'Step 2: Conduct Interview' tab is now unlocked.**" | |
| ) | |
| save_checkpoint(dataset_state, None) | |
| return ( | |
| dataset_state, | |
| status, | |
| gr.update(value=[]), | |
| gr.update(value=pid), | |
| gr.update(value="Start First Module ➡️", variant="primary", interactive=True), | |
| gr.update(interactive=True), | |
| gr.update(value="_Patient ready. Click **Start First Module ➡️** to begin._"), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(interactive=True), | |
| gr.update(interactive=True), | |
| gr.update(selected=2), | |
| None, | |
| gr.update(value=[], choices=[]), | |
| gr.update(value=None, choices=[], visible=False), | |
| gr.update(value=[], choices=[], visible=False), | |
| gr.update(value=[], choices=[], visible=False), | |
| gr.update(value=0), | |
| gr.update(value=0), | |
| gr.update(value=anon_id), | |
| ) | |
| def click_next_or_finish(dataset_state, current_session, comments, mod_rating): | |
| is_finish_click = dataset_state.current_module_index >= len(ALLOWED_MODULES) - 1 | |
| if is_finish_click: | |
| print("Finish Assessment clicked. Saving final module.") | |
| if current_session: | |
| persist_module_session_into_dataset(dataset_state, current_session, comments, mod_rating) | |
| save_checkpoint(dataset_state, current_session) | |
| return ( | |
| dataset_state, current_session, gr.update(value="**Assessment Complete!**"), | |
| gr.update(value=comments), gr.update(value=mod_rating), gr.update(value=_build_chat_display(current_session)), | |
| gr.update(value="### ✅ Assessment Complete"), | |
| gr.update(choices=[], value=[], visible=True), | |
| gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value=0), | |
| gr.update(value="Go to 'Step 3 & 4: Review & Save' tab."), | |
| gr.update(), gr.update(value="### ✅ Assessment Complete"), gr.update(), | |
| gr.update(interactive=False), gr.update(value="Finish Assessment", variant="secondary", interactive=False), | |
| gr.update(interactive=False), gr.update(variant="primary", interactive=True), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(interactive=False), gr.update(interactive=True), gr.update(selected=3), | |
| gr.update(visible=False) | |
| ) | |
| return change_module(dataset_state, current_session, comments, mod_rating, "next", None) | |
| def _compose_row_for_patient(dataset_state: DatasetState, pid: str) -> Dict[str, Any]: | |
| row: Dict[str, Any] = {col: "" for col in dataset_state.columns} | |
| row.update(dataset_state.current_patient_data) | |
| # --- MODIFIED: Save Demographics --- | |
| row["DemoSessionID"] = dataset_state.demo_session_id | |
| row["ClinicianID"] = dataset_state.clinician_id | |
| row["ConsentGiven"] = "Yes" if dataset_state.consent_given else "No" | |
| row["ConsentTimestampUTC"] = dataset_state.consent_timestamp_utc | |
| row["ConsentVersion"] = dataset_state.consent_version | |
| row["Profession"] = dataset_state.profession | |
| row["YrsDiagExp"] = dataset_state.yrs_diag_exp | |
| row["YrsProfExp"] = dataset_state.yrs_prof_exp | |
| # ----------------------------------- | |
| assessed_modules = dataset_state.completed_sessions.keys() | |
| # 1. Fill in missing data for un-assessed modules | |
| for mod in ALLOWED_MODULES: | |
| if mod not in assessed_modules: | |
| mp = MODULE_PREFIX[mod] | |
| codes = MODULE_ITEM_CODES[mod] | |
| for code in codes: | |
| row[code] = DEFAULT_MISSING | |
| row[f"RAT_{code}"] = "" | |
| # 2. Fill in RATING & Selection data | |
| for module_name, st in dataset_state.completed_sessions.items(): | |
| mp = MODULE_PREFIX[module_name] | |
| for item in st.items: | |
| code = item_code(mp, item) | |
| rating_col = f"RAT_{code}" | |
| key = str(item.item_number) | |
| if key in st.selections: | |
| rec = st.selections[key] | |
| # Save choice/text (logic handled in current_patient_data updates usually, but ensuring here) | |
| if rec.realism_rating is not None: | |
| row[rating_col] = rec.realism_rating | |
| # 3. Diagnostic Columns | |
| for mod in ALLOWED_MODULES: | |
| diag_col = f"DIA_{MODULE_PREFIX[mod]}" | |
| row[diag_col] = "Yes" if mod in dataset_state.diag_yes_modules else "No" | |
| return row | |
| def finalize_patient(dataset_state): | |
| pid = dataset_state.patient_id | |
| if not pid: | |
| return dataset_state, None, "Error", *[gr.update()]*8 | |
| if DEMO_MODE and not getattr(dataset_state, "feedback_saving_enabled", False): | |
| return ( | |
| dataset_state, None, f"✅ Demo completed. {feedback_not_saved_message()}", | |
| gr.update(variant="primary", interactive=True), gr.update(interactive=True), | |
| gr.update(interactive=True), gr.update(variant="secondary", interactive=True), | |
| gr.update(variant="primary", interactive=True), gr.update(interactive=True), | |
| gr.update(interactive=True), gr.update(selected=4) | |
| ) | |
| final_diagnoses_met = set() | |
| for module_name, st in dataset_state.completed_sessions.items(): | |
| rule_data = DIAGNOSTIC_RULES.get(st.module_name) | |
| if not rule_data or "logic" not in rule_data: | |
| continue | |
| logic = rule_data["logic"] | |
| met = False | |
| if st.module_name == "ADHD": | |
| for subtype in ["combined", "inattentive", "hyperactive_impulsive"]: | |
| sub_met, _ = evaluate_diagnostic_rule(logic.get(subtype, {}), st.selections) | |
| if sub_met: | |
| met = True | |
| break | |
| else: | |
| met, _ = evaluate_diagnostic_rule(logic, st.selections) | |
| if met: | |
| final_diagnoses_met.add(module_name) | |
| dataset_state.diag_yes_modules = final_diagnoses_met | |
| row = _compose_row_for_patient(dataset_state, pid) | |
| row["DemoSessionID"] = dataset_state.demo_session_id | |
| row["ClinicianID"] = dataset_state.clinician_id | |
| row["ConsentGiven"] = "Yes" if dataset_state.consent_given else "No" | |
| row["ConsentTimestampUTC"] = dataset_state.consent_timestamp_utc | |
| row["ConsentVersion"] = dataset_state.consent_version | |
| row["Profession"] = dataset_state.profession | |
| row["YrsDiagExp"] = dataset_state.yrs_diag_exp | |
| row["YrsProfExp"] = dataset_state.yrs_prof_exp | |
| row["PatientID"] = pid | |
| row["Sex"] = dataset_state.patient_sex | |
| row["Age"] = dataset_state.patient_age | |
| for mod, st in dataset_state.completed_sessions.items(): | |
| mp = MODULE_PREFIX[mod] | |
| row[f"MOD_RAT_{mp}"] = st.module_rating | |
| for it in st.items: | |
| code = item_code(mp, it) | |
| key = str(it.item_number) | |
| if key in st.selections and st.selections[key].realism_rating is not None: | |
| row[f"RAT_{code}"] = st.selections[key].realism_rating | |
| for mod in ALLOWED_MODULES: | |
| row[f"DIA_{MODULE_PREFIX[mod]}"] = "Yes" if mod in final_diagnoses_met else "No" | |
| row_df = pd.DataFrame([row]) | |
| for c in MASTER_COLUMNS: | |
| if c not in row_df.columns: | |
| row_df[c] = "" | |
| row_df = row_df[MASTER_COLUMNS] | |
| patient_csv_local = os.path.join(DATASET_DIR, f"{pid}.csv") | |
| row_df.to_csv(patient_csv_local, index=False) | |
| upload_to_hub(patient_csv_local, path_in_repo=patient_csv_repo_path(pid)) | |
| sync_main_artifacts_from_hub() | |
| main_df = load_or_init_main_dataset() | |
| if "PatientID" in main_df.columns: | |
| main_df = main_df[main_df["PatientID"].astype(str) != str(pid)] | |
| main_df = pd.concat([main_df, row_df], ignore_index=True) | |
| main_df.to_csv(MAIN_DATASET_PATH, index=False) | |
| dataset_state.df = main_df | |
| upload_to_hub(MAIN_DATASET_PATH, path_in_repo=os.path.basename(MAIN_DATASET_PATH)) | |
| json_file, patient_json_local = export_conversation_json(dataset_state, pid) | |
| if json_file: | |
| upload_to_hub(json_file, path_in_repo=os.path.basename(json_file)) | |
| if patient_json_local: | |
| upload_to_hub(patient_json_local, path_in_repo=patient_json_repo_path(pid)) | |
| dataset_state.last_saved_path = MAIN_DATASET_PATH | |
| return ( | |
| dataset_state, MAIN_DATASET_PATH, "✅ Record saved and uploaded. You can continue the assessment or start a new patient from Tab 1.", | |
| gr.update(variant="primary", interactive=True), gr.update(interactive=True), | |
| gr.update(interactive=True), gr.update(variant="secondary", interactive=True), | |
| gr.update(variant="primary", interactive=True), gr.update(interactive=True), | |
| gr.update(interactive=True), gr.update(selected=2) | |
| ) | |
| def download_dataset(dataset_state: DatasetState): | |
| sync_main_artifacts_from_hub() | |
| if not os.path.exists(MAIN_DATASET_PATH): | |
| dataset_state.df.to_csv(MAIN_DATASET_PATH, index=False) | |
| return MAIN_DATASET_PATH | |
| def export_conversation_json(dataset_state: DatasetState, pid: str): | |
| """Appends conversation to main JSON list and saves a per-patient JSON artifact.""" | |
| if not pid or not dataset_state.completed_sessions: | |
| print("[WARN] No data to export.") | |
| return None, None | |
| data = { | |
| "patient_id": pid, | |
| "clinician_id": dataset_state.clinician_id, | |
| "demo_session_id": dataset_state.demo_session_id, | |
| "consent_given": dataset_state.consent_given, | |
| "consent_timestamp_utc": dataset_state.consent_timestamp_utc, | |
| "consent_version": dataset_state.consent_version, | |
| "timestamp": datetime.utcnow().isoformat(), | |
| "patient_age": dataset_state.patient_age, | |
| "patient_sex": dataset_state.patient_sex, | |
| "patient_profile_simulated_severity": dataset_state.patient_profile, | |
| "assessed_modules": [] | |
| } | |
| for module_name, st in dataset_state.completed_sessions.items(): | |
| module_data = { | |
| "module": st.module_name, | |
| "simulated_severity": st.severity, | |
| "clinician_comments": st.module_comments, | |
| "module_realism_rating": st.module_rating, | |
| "turns": [{"clinician": c, "patient": p} for c, p in st.transcript], | |
| "selections": [ | |
| { | |
| "item_number": rec.item_number, | |
| "choice": rec.choice, | |
| "choices": rec.choices, | |
| "other_choices": rec.other_choices, | |
| "realism_rating": rec.realism_rating, | |
| } | |
| for rec in st.selections.values() | |
| ], | |
| } | |
| data["assessed_modules"].append(module_data) | |
| patient_json_local = os.path.join(DATASET_DIR, f"{pid}.json") | |
| with open(patient_json_local, "w", encoding="utf-8") as fh: | |
| json.dump(data, fh, indent=2, ensure_ascii=False) | |
| sync_main_artifacts_from_hub() | |
| all_conversations = [] | |
| if os.path.exists(MAIN_JSON_PATH): | |
| try: | |
| with open(MAIN_JSON_PATH, "r", encoding="utf-8") as fh: | |
| all_conversations = json.load(fh) | |
| if not isinstance(all_conversations, list): | |
| all_conversations = [] | |
| except Exception: | |
| all_conversations = [] | |
| all_conversations = [entry for entry in all_conversations if str(entry.get("patient_id")) != str(pid)] | |
| all_conversations.append(data) | |
| with open(MAIN_JSON_PATH, "w", encoding="utf-8") as fh: | |
| json.dump(all_conversations, fh, indent=2, ensure_ascii=False) | |
| return MAIN_JSON_PATH, patient_json_local | |
| # ========================= | |
| # Diagnostic Summary Logic | |
| # ========================= | |
| def evaluate_diagnostic_rule(logic: Dict[str, Any], selections: Dict[str, SelectionRecord]) -> Tuple[bool, str]: | |
| def check(item_num_str: str, condition: str, value=None) -> bool: | |
| rec = selections.get(item_num_str) | |
| if not rec: return False | |
| if condition == "equals_yes": | |
| return rec.choice and rec.choice.strip().startswith("YES") | |
| elif condition == "min_checked": | |
| count = len(rec.choices or []) + len(rec.other_choices or []) | |
| return count >= value | |
| elif condition == "min_yes": | |
| count = 0 | |
| for item in value: | |
| rec_inner = selections.get(str(item)) | |
| if rec_inner and rec_inner.choice == "YES": | |
| count += 1 | |
| return count >= item_num_str | |
| return False | |
| if "all" in logic: | |
| for i, sub_logic in enumerate(logic["all"]): | |
| res, reason = evaluate_diagnostic_rule(sub_logic, selections) | |
| if not res: | |
| return False, f"A required condition was not met: {reason}" | |
| return True, "All conditions met." | |
| if "any" in logic: | |
| any_met = False | |
| reasons = [] | |
| for sub_logic in logic["any"]: | |
| res, reason = evaluate_diagnostic_rule(sub_logic, selections) | |
| reasons.append(reason) | |
| if res: | |
| any_met = True | |
| break | |
| if any_met: | |
| return True, f"One of the optional conditions was met: {reason}" | |
| else: | |
| return False, "None of the optional conditions were met. " + " ".join(reasons) | |
| if "item" in logic and "equals" in logic: | |
| item, val = str(logic["item"]), logic["equals"] | |
| if val == "YES": | |
| is_met = check(item, "equals_yes") | |
| reason = f"Item {item} was {'YES' if is_met else 'not YES'}." | |
| return is_met, reason | |
| if "min_checked" in logic and "item" in logic: | |
| count, item = logic["min_checked"], str(logic["item"]) | |
| is_met = check(item, "min_checked", count) | |
| num_checked = len((selections.get(item) or SelectionRecord(item_number=None)).choices or []) | |
| reason = f"Item {item} required at least {count} selections (had {num_checked})." | |
| return is_met, reason | |
| if "min_yes" in logic and "items" in logic: | |
| count, items = logic["min_yes"], logic["items"] | |
| is_met = check(count, "min_yes", items) | |
| num_yes = sum(1 for i in items if (selections.get(str(i)) or SelectionRecord(item_number=None)).choice == "YES") | |
| reason = f"At least {count} of items {', '.join(map(str, items))} needed to be YES (had {num_yes})." | |
| return is_met, reason | |
| if "symptom_block" in logic: | |
| block = logic["symptom_block"] | |
| items, min_count, must_haves = block["items"], block["count_yes_at_least"], block["must_include_any_of"] | |
| yes_items = {str(i) for i in items if (selections.get(str(i)) or SelectionRecord(item_number=None)).choice == "YES"} | |
| total_yes = len(yes_items) | |
| has_must_have = any(str(i) in yes_items for i in must_haves) | |
| if total_yes >= min_count and has_must_have: | |
| return True, f"Met criteria with {total_yes}/{min_count} symptoms, including a core symptom." | |
| else: | |
| reason = f"Required ≥{min_count} symptoms including one of {', '.join(map(str, must_haves))}. " | |
| reason += f"Found {total_yes} YES answers. Core symptom present: {has_must_have}." | |
| return False, reason | |
| return False, "Unknown rule format." | |
| def generate_full_summary(dataset_state: DatasetState): | |
| if not dataset_state or not dataset_state.completed_sessions: | |
| return ( | |
| dataset_state, | |
| "No modules have been completed yet. Finish at least one module to generate a summary.", | |
| gr.update(), # generate_summary_btn | |
| gr.update() # finalize_btn | |
| ) | |
| summary = f"# 📋 Full Diagnostic Summary for Patient {dataset_state.patient_id}\n" | |
| summary += f"**Patient Profile:** {dataset_state.patient_age}-year-old {dataset_state.patient_sex}\n\n" | |
| final_diagnoses_met = set() | |
| for module_name, st in sorted(dataset_state.completed_sessions.items()): | |
| summary += "__________________________________________________________________\n" | |
| summary += f"### Module: {module_name}\n" | |
| summary += f"**Simulated Severity:** {st.severity.capitalize()}\n\n" | |
| # --- 1. Overall Diagnostic Status --- | |
| rule_data = DIAGNOSTIC_RULES.get(st.module_name) | |
| if not rule_data or "logic" not in rule_data: | |
| summary += "- *No diagnostic algorithm found for this module.*\n" | |
| continue | |
| logic = rule_data["logic"] | |
| rule_text = rule_data.get("rule", "No rule description available.") | |
| met = False | |
| reason = "" | |
| # Calculate Logic | |
| if st.module_name == "ADHD": | |
| adhd_results = [] | |
| for subtype in ["combined", "inattentive", "hyperactive_impulsive"]: | |
| sub_met, sub_reason = evaluate_diagnostic_rule(logic.get(subtype, {}), st.selections) | |
| if sub_met: | |
| adhd_results.append(f"✅ **Criteria MET for ADHD, {subtype.replace('_', ' ').title()} Type.**\n> *Evaluation:* {sub_reason}") | |
| met = True | |
| if not adhd_results: | |
| _, reason = evaluate_diagnostic_rule(logic.get('combined',{}), st.selections) | |
| summary += f"❌ **Diagnostic Criteria NOT MET.**\n> *Rule Checked:* `{rule_text}`\n> *Evaluation:* {reason}\n\n" | |
| else: | |
| summary += "\n".join(adhd_results) + "\n\n" | |
| else: | |
| met, reason = evaluate_diagnostic_rule(logic, st.selections) | |
| summary += ( | |
| f"✅ **Diagnostic Criteria MET.**\n> *Rule Checked:* `{rule_text}`\n> *Evaluation:* {reason}\n\n" | |
| if met | |
| else f"❌ **Diagnostic Criteria NOT MET.**\n> *Rule Checked:* `{rule_text}`\n> *Evaluation:* {reason}\n\n" | |
| ) | |
| if met: | |
| final_diagnoses_met.add(module_name) | |
| # --- 2. Item-by-Item Breakdown Table --- | |
| summary += "| Status | Item | DSM-5 | Clinician Selection |\n" | |
| summary += "| :---: | :--- | :--- | :--- |\n" | |
| # Get mapping for this module | |
| dsm_map = DSM5_MAPPING.get(module_name, {}) | |
| for item in st.items: | |
| item_num = str(item.item_number) | |
| dsm_code = dsm_map.get(item_num, "-") | |
| # Get Selection | |
| rec = st.selections.get(item_num) | |
| display_val = "_(Not answered)_" | |
| icon = "⚪" # Neutral | |
| if rec: | |
| # Handle Radio Buttons (YES/NO) | |
| if rec.choice: | |
| display_val = f"**{rec.choice}**" | |
| if rec.choice.strip().upper().startswith("YES"): | |
| icon = "✅" | |
| elif rec.choice.strip().upper().startswith("NO"): | |
| icon = "❌" | |
| else: | |
| icon = "➖" | |
| # Handle Checkboxes | |
| elif rec.choices: | |
| count = len(rec.choices) | |
| display_val = f"Checked {count} symptoms" | |
| if count > 0: | |
| icon = "✅" # Assuming checking anything is a 'positive' finding for that item context | |
| else: | |
| icon = "❌" | |
| # Special handling for Screening Item | |
| if item.is_screening: | |
| dsm_code = "Screen" | |
| summary += f"| {icon} | Item {item_num} | {dsm_code} | {display_val} |\n" | |
| if st.module_comments: | |
| summary += f"\n**Clinician Comments:**\n> {st.module_comments}\n" | |
| summary += "\n" | |
| dataset_state.diag_yes_modules = final_diagnoses_met | |
| return ( | |
| dataset_state, | |
| summary, | |
| gr.update(variant="secondary", interactive=True), # generate_summary_btn | |
| gr.update(variant="primary", interactive=True) # finalize_btn | |
| ) | |
| def build_load_view(profile: Optional[gr.OAuthProfile] = None, request: Optional[gr.Request] = None): | |
| ds, current_session, anon_id = maybe_restore_checkpoint(profile, request) | |
| anon_upd = gr.update(value=anon_id or "") | |
| if not anon_id: | |
| return ( | |
| ds, None, anon_upd, | |
| gr.update(value="_No demo session ID was created. Reload the page to start a new session._"), | |
| gr.update(value=""), | |
| gr.update(value="Start First Module ➡️", variant="secondary", interactive=False), | |
| gr.update(interactive=False), | |
| gr.update(value="_Start a new patient and module to see assessment status._"), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(interactive=False), gr.update(interactive=False), gr.update(selected=1), | |
| gr.update(choices=[], value=[]), | |
| gr.update(choices=[], value=None, visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(value=0), gr.update(value=0), | |
| gr.update(value="..."), gr.update(value="_No selections saved yet._"), gr.update(value=""), | |
| gr.update(value="### Conduct Diagnostic Interview"), gr.update(value="Start a session to see module information."), | |
| gr.update(interactive=False), gr.update(value=None), gr.update(value=[]), gr.update(interactive=False), gr.update(value=""), | |
| gr.update(), gr.update(), gr.update() | |
| ) | |
| if ds.patient_id and current_session is None: | |
| _prof = ds.profession if ds.profession not in ("Unknown", "", None) else None | |
| return ( | |
| ds, current_session, anon_upd, | |
| gr.update(value=f"🔄 Restored active patient **{ds.patient_id}**.\nAnonymous evaluator ID: **{ds.clinician_id}**\nClick **Start First Module ➡️** to continue."), | |
| gr.update(value=ds.patient_id), | |
| gr.update(value="Start First Module ➡️", variant="primary", interactive=True), | |
| gr.update(interactive=True), | |
| gr.update(value="_Patient ready. Click **Start First Module ➡️** to begin._"), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(interactive=True), gr.update(interactive=True), gr.update(selected=2), | |
| gr.update(choices=[], value=[]), | |
| gr.update(choices=[], value=None, visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(value=0), gr.update(value=0), | |
| gr.update(value="..."), gr.update(value="_No selections saved yet._"), gr.update(value=""), | |
| gr.update(value="### Conduct Diagnostic Interview"), gr.update(value="Start a session to see module information."), | |
| gr.update(interactive=False), gr.update(value=None), gr.update(value=[]), gr.update(interactive=False), gr.update(value=""), | |
| gr.update(value=_prof), gr.update(value=optional_number_value(ds.yrs_diag_exp)), gr.update(value=optional_number_value(ds.yrs_prof_exp)) | |
| ) | |
| if not ds.patient_id or current_session is None: | |
| _prof = ds.profession if ds.profession not in ("Unknown", "", None) else None | |
| return ( | |
| ds, current_session, anon_upd, | |
| gr.update(value="_No active demo patient yet. Click 'Start Demo Patient' to begin._"), | |
| gr.update(value=""), | |
| gr.update(value="Start First Module ➡️", variant="secondary", interactive=False), | |
| gr.update(interactive=False), | |
| gr.update(value="_Start a new patient and module to see assessment status._"), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(variant="secondary", interactive=False), | |
| gr.update(interactive=False), gr.update(interactive=False), gr.update(selected=1), | |
| gr.update(choices=[], value=[]), | |
| gr.update(choices=[], value=None, visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(choices=[], value=[], visible=False), | |
| gr.update(value=0), gr.update(value=0), | |
| gr.update(value="..."), gr.update(value="_No selections saved yet._"), gr.update(value=""), | |
| gr.update(value="### Conduct Diagnostic Interview"), gr.update(value="Start a session to see module information."), | |
| gr.update(interactive=False), gr.update(value=None), gr.update(value=[]), gr.update(interactive=False), gr.update(value=""), | |
| gr.update(value=_prof), gr.update(value=optional_number_value(ds.yrs_diag_exp)), gr.update(value=optional_number_value(ds.yrs_prof_exp)) | |
| ) | |
| severity = current_session.severity | |
| display_severity = SEVERITY_DISPLAY_MAP.get(severity, severity) | |
| module_position = ds.current_module_index + 1 if ds.current_module_index >= 0 else 1 | |
| summary_md = ( | |
| f"**Now Assessing:** {current_session.module_name} ({module_position}/{len(ALLOWED_MODULES)})\n" | |
| f"**Simulated Severity:** {display_severity}\n" | |
| f"**Completed Modules:** {len(ds.completed_sessions)}" | |
| ) | |
| header, probes_update, single_upd, multi_upd, other_upd, item_rating_upd, saved_md, progress_md = _prepare_item_view(current_session) | |
| module_data = get_module_data(current_session.module_name) | |
| sev_text_lower = display_severity.lower() | |
| new_slider_label = f"This patient emulates {sev_text_lower} for the current module. How realistic is the portrayal? (0-5)" | |
| is_last_module = ds.current_module_index >= len(ALLOWED_MODULES) - 1 | |
| next_btn_text = "Finish Assessment" if is_last_module else "Save and Continue ➡️" | |
| _prof = ds.profession if ds.profession not in ("Unknown", "", None) else None | |
| return ( | |
| ds, current_session, anon_upd, | |
| gr.update(value=f"🔄 Resumed active patient **{ds.patient_id}**.\nAnonymous evaluator ID: **{ds.clinician_id}**"), | |
| gr.update(value=ds.patient_id), | |
| gr.update(value=next_btn_text, variant="primary", interactive=True), | |
| gr.update(interactive=True), | |
| gr.update(value=summary_md), | |
| gr.update(variant="secondary", interactive=True), | |
| gr.update(variant="secondary", interactive=True), | |
| gr.update(interactive=True), gr.update(interactive=True), gr.update(selected=2), | |
| probes_update, single_upd, multi_upd, other_upd, item_rating_upd, | |
| gr.update(value=current_session.module_rating, label=new_slider_label), | |
| gr.update(value=header), gr.update(value=saved_md), gr.update(value=progress_md), | |
| gr.update(value=f"### Conducting Interview: {current_session.module_name}"), gr.update(value=module_data.get("information", "No info.")), | |
| gr.update(interactive=ds.current_module_index > 0), gr.update(value=current_session.module_name), gr.update(value=_build_chat_display(current_session)), gr.update(interactive=False), gr.update(value=current_session.module_comments), | |
| gr.update(value=_prof), gr.update(value=optional_number_value(ds.yrs_diag_exp)), gr.update(value=optional_number_value(ds.yrs_prof_exp)) | |
| ) | |
| # ========================= | |
| # UI Definition | |
| # ========================= | |
| # General Home Instructions | |
| INSTRUCTIONS_MD = """ | |
| ### EPSI-C Patient Simulator — Symposium Demo | |
| This demo lets you try the **Electronic Psychiatric Semi-Structured Interview for Children and Adolescents (EPSI-C)** with an AI-simulated adolescent patient. The simulated patient has a psychiatric profile, including possible comorbidities, and responds to your questions based on that profile. | |
| You may try the app without contributing feedback. If you consent to provide feedback, your ratings, comments, SUS responses, and simulated-patient interactions may be saved. | |
| Please do **not** enter names, contact details, or any other personally identifiable information. | |
| **Suggested path:** | |
| 1. **Start Demo Patient** — Choose whether you consent to contribute feedback. Optional background information can be left blank. | |
| 2. **Conduct Interview** — Work through one or more modules, ask questions, make clinical selections, and rate realism. | |
| 3. **Review Demo Record** — Review the diagnostic summary and save only if you consented to feedback collection. | |
| 4. **System Evaluation** — Optionally complete SUS usability feedback. | |
| """ | |
| TAB_1_INSTRUCTIONS = """ | |
| **Instructions:** | |
| 1. Choose whether you consent to contribute demo feedback. You can still use the demo without saving feedback. | |
| 2. Optional background information can help interpret aggregate feedback, but it is not required. | |
| 3. Click **Start Demo Patient** to generate an AI patient and unlock the interview tab. | |
| > Please do not enter identifiable information in free-text fields. | |
| """ | |
| TAB_2_INSTRUCTIONS = """ | |
| **Instructions:** | |
| 1. Click **Start First Module ➡️** to begin. The system can take you through all 11 disorder modules in sequence. | |
| 2. Select suggested **Probing Questions** or expand **Custom Question** to type your own. | |
| 3. Use the **Clinician's Decision** section to record whether each diagnostic criterion is met. | |
| 4. Use the realism sliders to rate the AI patient responses and module-level realism. | |
| 5. Click **Save and Continue ➡️** when finished with a module. | |
| """ | |
| TAB_3_INSTRUCTIONS = """ | |
| **Instructions:** | |
| 1. Review the diagnostic summary generated from your clinical selections. | |
| 2. If you consented to contribute demo feedback, click **Save Demo Feedback Record** to upload the record to the separate demo feedback dataset. | |
| 3. If you chose not to contribute feedback, you may still review the summary. | |
| """ | |
| # ======================================================================== | |
| # --- START OF MODIFIED UI SECTION (gr.Blocks) --- | |
| # ======================================================================== | |
| # ======================================================================== | |
| # --- START OF MODIFIED UI SECTION (gr.Blocks) --- | |
| # ======================================================================== | |
| css = """ | |
| .custom-modal { | |
| position: fixed !important; | |
| top: 50% !important; | |
| left: 50% !important; | |
| transform: translate(-50%, -50%) !important; | |
| z-index: 10000 !important; | |
| max-width: 600px !important; | |
| width: auto !important; | |
| margin: 0 !important; | |
| max-height: 90vh !important; | |
| overflow-y: auto !important; | |
| box-shadow: 0px 4px 20px rgba(0,0,0,0.5) !important; | |
| border-radius: 8px !important; | |
| background-color: #1f2937 !important; | |
| padding: 20px !important; | |
| } | |
| """ | |
| with gr.Blocks(title="AI-Patient Demo", fill_height=True, css=css) as demo: | |
| gr.Markdown("## AI-Patient Demo") | |
| #gr.Markdown("Feedback is saved only when you explicitly consent, and it is routed to the separate demo feedback dataset configured for this Space.") | |
| anon_user_display = gr.Textbox(label="Demo Session ID", interactive=False, info="Generated for this browser session.") | |
| dataset_state = gr.State(init_dataset_state) | |
| session = gr.State() | |
| with gr.Tabs() as main_tabs: | |
| with gr.Tab("Instructions", id=0): | |
| gr.Markdown(INSTRUCTIONS_MD) | |
| with gr.Tab("1. Start Patient Evaluation", id=1): | |
| gr.Markdown(TAB_1_INSTRUCTIONS) | |
| gr.Markdown("---") | |
| with gr.Group(): | |
| gr.Markdown("### Consent for Demo Feedback Collection") | |
| gr.Markdown( | |
| "This is a public demonstration version of the EPSI-C simulated patient app. " | |
| "If you consent, your ratings, comments, SUS responses, and simulated-patient interactions may be saved " | |
| "for research and development purposes in a separate demo feedback dataset. Participation is voluntary. " | |
| "Do not enter identifiable information or other sensitive personal data." | |
| ) | |
| feedback_choice = gr.Radio( | |
| choices=[ | |
| "I consent to save my demo feedback", | |
| "Continue without saving feedback", | |
| ], | |
| value="Continue without saving feedback", | |
| label="Feedback participation choice", | |
| interactive=True, | |
| ) | |
| with gr.Accordion("Optional background information", open=False): | |
| profession_input = gr.Dropdown( | |
| choices=["Psychiatrist", "Psychologist", "Resident/Trainee", "Medical Student", "Nurse", "Researcher"], | |
| label="Current profession (optional)", | |
| allow_custom_value=True, | |
| interactive=True, | |
| ) | |
| with gr.Row(): | |
| yrs_diag_input = gr.Number(label="Years of experience with clinical diagnoses (optional)", value=None, precision=0) | |
| yrs_prof_input = gr.Number(label="Years of professional experience (optional)", value=None, precision=0) | |
| gr.Markdown("---") | |
| with gr.Group(): | |
| gr.Markdown("### Start Demo Patient Case") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| new_patient_btn = gr.Button("Start Demo Patient", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| patient_id = gr.Textbox(label="Current Patient ID", interactive=False) | |
| patient_status = gr.Markdown("_No active demo patient yet. Click 'Start Demo Patient' to begin._") | |
| confirm_new_patient_modal = Modal("⚠️ Assessment in Progress", visible=False, elem_classes=["custom-modal"]) | |
| with confirm_new_patient_modal: | |
| gr.Markdown( | |
| "You have an active demo patient session. Starting a new demo patient now will **discard** unsaved progress for the current patient.\n\n" | |
| "What would you like to do?" | |
| ) | |
| with gr.Row(): | |
| cancel_new_patient_btn = gr.Button("Continue with Current") | |
| confirm_discard_btn = gr.Button("Start New Demo Patient", variant="stop") | |
| with gr.Tab("2. Conduct Interview", id=2, interactive=False) as interview_tab: | |
| gr.Markdown(TAB_2_INSTRUCTIONS) | |
| gr.Markdown("---") | |
| interview_header = gr.Markdown("### Conduct Diagnostic Interview") | |
| summary = gr.Markdown("_Start a new patient and module to see assessment status._") | |
| with gr.Row(): | |
| prev_module_btn = gr.Button("⬅️ Previous Module", interactive=False) | |
| next_module_btn = gr.Button("Start First Module ➡️", variant="secondary", interactive=False) | |
| with gr.Accordion("Advanced: Jump to a Specific Module", open=False): | |
| with gr.Row(): | |
| module_selector = gr.Dropdown(choices=ALLOWED_MODULES, label="Module", interactive=True) | |
| load_module_btn = gr.Button("Load Module", interactive=False) | |
| with gr.Accordion("Module Information (DSM-5 Criteria & Rules)", open=False): | |
| interviewer_info = gr.Markdown("Start a session to see module information.") | |
| progress_status = gr.Markdown("") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| assessment = gr.Markdown("...") | |
| with gr.Group(): | |
| probes = gr.CheckboxGroup(choices=[], label="Probing Questions (check to ask)") | |
| ask_probe = gr.Button("Ask Selected Question(s)", interactive=False) | |
| with gr.Accordion("Custom Question", open=False): | |
| gr.Markdown("Please do not enter names, contact details, or information about real identifiable patients.") | |
| custom_q = gr.Textbox(label="Enter a Custom Question", lines=2) | |
| ask_custom_btn = gr.Button("Ask Custom Question") | |
| gr.Markdown("#### Answer Realism Rating") | |
| item_realism_rating = gr.Slider( | |
| minimum=0, maximum=5, step=1, value=0, | |
| label="Rate the realism of the patient's response to this specific item (0=Not Rated, 1=Poor, 5=Excellent)", | |
| interactive=True, | |
| ) | |
| gr.Markdown("#### Clinician's Decision for this Item") | |
| single_choice = gr.Radio(choices=[], label="Is the diagnostic criterion met?", visible=False, interactive=True) | |
| multi_choice = gr.CheckboxGroup(choices=[], label="Multiple-selection", visible=False, interactive=True) | |
| other_choice = gr.CheckboxGroup(choices=[], label="Additional categories", visible=False, interactive=True) | |
| saved_status = gr.Markdown("_No selections saved yet._") | |
| with gr.Column(scale=1): | |
| chat = gr.Chatbot(label="Clinician ↔ Simulated Patient", height=700, type="messages") | |
| with gr.Row(): | |
| module_comments = gr.Textbox(label="Clinician Comments (for current module)", lines=2, scale=3) | |
| module_rating = gr.Slider(0, 5, 1, value=0, label="Overall Module Realism (0-5)", scale=1) | |
| with gr.Row(): | |
| prev_btn = gr.Button("← Previous Item", size="sm") | |
| next_btn = gr.Button("Next Item →", size="sm") | |
| screen_skip_modal = Modal("Screening Information", visible=False, elem_classes=["custom-modal"]) | |
| with screen_skip_modal: | |
| gr.Markdown( | |
| "**Note:** Clinically, a 'No' on the screening question indicates core symptoms are absent.\n\n" | |
| "• To **stop** now: click OK, finish your rating/comments, then click **'Save and Continue'**.\n" | |
| "• To **continue**: click OK and proceed to the next item." | |
| ) | |
| with gr.Row(): | |
| ok_btn = gr.Button("OK", variant="primary") | |
| with gr.Tab("3. Review & Save Record", id=3, interactive=False) as review_tab: | |
| gr.Markdown(TAB_3_INSTRUCTIONS) | |
| gr.Markdown("---") | |
| gr.Markdown("### Part A: Review Assessment") | |
| generate_summary_btn = gr.Button("Generate Full Diagnostic Summary", variant="secondary", interactive=False) | |
| session_summary_display = gr.Markdown("_Click the button above to generate the summary._") | |
| gr.Markdown("---") | |
| gr.Markdown("### Part B: Save Demo Feedback Record") | |
| finalize_btn = gr.Button("Save Demo Feedback Record", variant="primary", interactive=False) | |
| finalize_status = gr.Markdown("If you consented to feedback collection, this uploads to the separate demo feedback dataset. Otherwise, nothing is saved.") | |
| dataset_file = gr.File(label="Saved Demo File", interactive=False, visible=False) | |
| download_btn = gr.Button("Download CSV Dataset", visible=False) | |
| with gr.Tab("4. System Evaluation", id=4) as validation_tab: | |
| gr.Markdown("### Standardized Usability Scale (SUS)") | |
| gr.Markdown("Please rate the overall system after trying the demo (1 = Strongly Disagree, 5 = Strongly Agree). SUS responses are saved only if you consented to demo feedback collection on the first tab.") | |
| with gr.Row(): | |
| sus_1 = gr.Slider(1, 5, step=1, value=3, label="1. I think that I would like to use this system frequently.") | |
| sus_2 = gr.Slider(1, 5, step=1, value=3, label="2. I found the system unnecessarily complex.") | |
| with gr.Row(): | |
| sus_3 = gr.Slider(1, 5, step=1, value=3, label="3. I thought the system was easy to use.") | |
| sus_4 = gr.Slider(1, 5, step=1, value=3, label="4. I think that I would need the support of a technical person to use this system.") | |
| with gr.Row(): | |
| sus_5 = gr.Slider(1, 5, step=1, value=3, label="5. I found the various functions in this system were well integrated.") | |
| sus_6 = gr.Slider(1, 5, step=1, value=3, label="6. I thought there was too much inconsistency in this system.") | |
| with gr.Row(): | |
| sus_7 = gr.Slider(1, 5, step=1, value=3, label="7. I would imagine that most people would learn to use this system very quickly.") | |
| sus_8 = gr.Slider(1, 5, step=1, value=3, label="8. I found the system very cumbersome to use.") | |
| with gr.Row(): | |
| sus_9 = gr.Slider(1, 5, step=1, value=3, label="9. I felt very confident using the system.") | |
| sus_10 = gr.Slider(1, 5, step=1, value=3, label="10. I needed to learn a lot of things before I could get going with this system.") | |
| overall_feedback = gr.Textbox(label="Additional comments about the demo (optional)", lines=3) | |
| submit_validation_btn = gr.Button("Submit Evaluation", variant="primary") | |
| validation_status = gr.Markdown("") | |
| def save_validation(ds, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, comments): | |
| if not ds: | |
| return "Error: No session state." | |
| if DEMO_MODE and not getattr(ds, "feedback_saving_enabled", False): | |
| return feedback_not_saved_message() | |
| data = { | |
| "Timestamp": datetime.utcnow().isoformat(), | |
| "DemoSessionID": ds.demo_session_id, | |
| "ClinicianID": ds.clinician_id, | |
| "ConsentGiven": "Yes" if ds.consent_given else "No", | |
| "ConsentTimestampUTC": ds.consent_timestamp_utc, | |
| "ConsentVersion": ds.consent_version, | |
| "Profession": ds.profession, | |
| "YrsDiagExp": ds.yrs_diag_exp, | |
| "YrsProfExp": ds.yrs_prof_exp, | |
| "SUS_1": s1, "SUS_2": s2, "SUS_3": s3, "SUS_4": s4, "SUS_5": s5, | |
| "SUS_6": s6, "SUS_7": s7, "SUS_8": s8, "SUS_9": s9, "SUS_10": s10, | |
| "AdditionalComments": comments or "", | |
| } | |
| df = pd.DataFrame([data]) | |
| try: | |
| sync_main_artifacts_from_hub() | |
| if not os.path.exists(SUS_DATASET_PATH): | |
| df.to_csv(SUS_DATASET_PATH, index=False) | |
| else: | |
| df.to_csv(SUS_DATASET_PATH, mode='a', header=False, index=False) | |
| upload_to_hub(SUS_DATASET_PATH, path_in_repo=os.path.basename(SUS_DATASET_PATH)) | |
| return "✅ Demo usability feedback saved to the separate demo feedback dataset." | |
| except Exception as e: | |
| return f"❌ Error saving data: {str(e)}" | |
| submit_validation_btn.click( | |
| save_validation, | |
| inputs=[dataset_state, sus_1, sus_2, sus_3, sus_4, sus_5, sus_6, sus_7, sus_8, sus_9, sus_10, overall_feedback], | |
| outputs=[validation_status], | |
| ) | |
| load_outputs = [ | |
| dataset_state, session, anon_user_display, patient_status, patient_id, | |
| next_module_btn, load_module_btn, summary, generate_summary_btn, finalize_btn, | |
| interview_tab, review_tab, main_tabs, probes, single_choice, multi_choice, other_choice, | |
| item_realism_rating, module_rating, assessment, saved_status, progress_status, | |
| interview_header, interviewer_info, prev_module_btn, module_selector, chat, ask_probe, module_comments, | |
| profession_input, yrs_diag_input, yrs_prof_input, | |
| ] | |
| demo.load(build_load_view, outputs=load_outputs) | |
| def start_trigger(ds, prof, y_d, y_p, feedback, profile: Optional[gr.OAuthProfile] = None, request: Optional[gr.Request] = None): | |
| if ds.patient_id: | |
| return (gr.update(visible=True),) + tuple(gr.skip() for _ in range(20)) | |
| gen_results = new_patient(ds, prof, y_d, y_p, feedback, profile=profile, request=request) | |
| return (gr.update(visible=False),) + gen_results | |
| btn_outputs = [ | |
| confirm_new_patient_modal, dataset_state, patient_status, chat, patient_id, | |
| next_module_btn, load_module_btn, summary, generate_summary_btn, finalize_btn, | |
| interview_tab, review_tab, main_tabs, session, probes, single_choice, multi_choice, | |
| other_choice, item_realism_rating, module_rating, anon_user_display, | |
| ] | |
| new_patient_btn.click( | |
| start_trigger, | |
| inputs=[dataset_state, profession_input, yrs_diag_input, yrs_prof_input, feedback_choice], | |
| outputs=btn_outputs, | |
| ) | |
| def confirm_handler(ds, prof, y_d, y_p, feedback, profile: Optional[gr.OAuthProfile] = None, request: Optional[gr.Request] = None): | |
| return (gr.update(visible=False),) + new_patient(ds, prof, y_d, y_p, feedback, profile=profile, request=request) | |
| confirm_discard_btn.click( | |
| confirm_handler, | |
| inputs=[dataset_state, profession_input, yrs_diag_input, yrs_prof_input, feedback_choice], | |
| outputs=btn_outputs, | |
| ) | |
| cancel_new_patient_btn.click(lambda: gr.update(visible=False), outputs=[confirm_new_patient_modal]) | |
| change_module_outputs = [ | |
| dataset_state, session, summary, | |
| module_comments, module_rating, | |
| chat, assessment, probes, single_choice, multi_choice, other_choice, | |
| item_realism_rating, | |
| saved_status, progress_status, | |
| interview_header, interviewer_info, | |
| prev_module_btn, next_module_btn, module_selector, | |
| generate_summary_btn, finalize_btn, | |
| interview_tab, review_tab, main_tabs, | |
| screen_skip_modal, | |
| ] | |
| next_module_btn.click( | |
| click_next_or_finish, | |
| inputs=[dataset_state, session, module_comments, module_rating], | |
| outputs=change_module_outputs, | |
| ) | |
| prev_module_btn.click( | |
| lambda ds, cs, c, mr: change_module(ds, cs, c, mr, "prev", None), | |
| inputs=[dataset_state, session, module_comments, module_rating], | |
| outputs=change_module_outputs, | |
| ) | |
| load_module_btn.click( | |
| lambda ds, cs, c, mr, mn: change_module(ds, cs, c, mr, "direct", mn), | |
| inputs=[dataset_state, session, module_comments, module_rating, module_selector], | |
| outputs=change_module_outputs, | |
| ) | |
| nav_outputs = [dataset_state, session, assessment, probes, single_choice, multi_choice, other_choice, item_realism_rating, saved_status, progress_status] | |
| next_btn.click(lambda ds, st: nav_item(ds, st, "next"), inputs=[dataset_state, session], outputs=nav_outputs) | |
| prev_btn.click(lambda ds, st: nav_item(ds, st, "prev"), inputs=[dataset_state, session], outputs=nav_outputs) | |
| ask_probe.click(ask_selected, inputs=[dataset_state, session, probes], outputs=[dataset_state, session, chat, probes]) | |
| ask_custom_btn.click(ask_custom, inputs=[dataset_state, session, custom_q], outputs=[dataset_state, session, chat, custom_q]) | |
| probes.change(lambda x: gr.update(interactive=bool(x)), inputs=[probes], outputs=[ask_probe]) | |
| for inp in [single_choice, multi_choice, other_choice, item_realism_rating]: | |
| inp.change( | |
| auto_save_selection, | |
| inputs=[dataset_state, session, single_choice, multi_choice, other_choice, item_realism_rating], | |
| outputs=[dataset_state, session, saved_status, screen_skip_modal], | |
| ) | |
| generate_summary_btn.click( | |
| generate_full_summary, | |
| inputs=[dataset_state], | |
| outputs=[dataset_state, session_summary_display, generate_summary_btn, finalize_btn], | |
| ) | |
| review_tab.select( | |
| generate_full_summary, | |
| inputs=[dataset_state], | |
| outputs=[dataset_state, session_summary_display, generate_summary_btn, finalize_btn], | |
| ) | |
| finalize_btn.click( | |
| finalize_patient, | |
| inputs=[dataset_state], | |
| outputs=[ | |
| dataset_state, dataset_file, finalize_status, | |
| new_patient_btn, next_module_btn, load_module_btn, | |
| generate_summary_btn, finalize_btn, | |
| interview_tab, review_tab, main_tabs, | |
| ], | |
| ) | |
| download_btn.click(download_dataset, inputs=[dataset_state], outputs=[dataset_file]) | |
| ok_btn.click(lambda: gr.update(visible=False), outputs=[screen_skip_modal]) | |
| if __name__ == "__main__": | |
| demo.queue().launch() |