"""ZeroGPU Gradio Space — crisis triage student model demo, with voice in/out and a model switcher between trained adapters. ZeroGPU only attaches a real GPU for the duration of a function decorated with @spaces.GPU -- at module/import time there is no GPU visible, so every model must load on CPU first and move to "cuda" only inside that function. Models are LAZY-LOADED: nothing but the dropdown's default entry loads at startup. Switching the dropdown loads that model on CPU (cached after first use) and frees the previous one, so you never hold a 1.5B and a 7B model in CPU RAM at the same time. STT/Whisper and TTS/VITS are always-on and shared across model choices. Pipeline per click (all inside ONE @spaces.GPU call, to use a single GPU borrow instead of three): voice in -> Whisper STT -> transcript transcript -> triage adapter (selected model) -> structured JSON JSON["plain_language_summary"] -> TTS -> spoken response EDIT this dict to add/remove selectable models: MODEL_REGISTRY """ import spaces # must be imported before torch/transformers/peft on ZeroGPU import gc import html import json import re import gradio as gr import numpy as np import torch from peft import PeftModel from transformers import ( AutoModelForCausalLM, AutoTokenizer, VitsModel, WhisperForConditionalGeneration, WhisperProcessor, ) MODEL_REGISTRY = { "Qwen2.5-1.5B (fast)": { "base": "Qwen/Qwen2.5-1.5B-Instruct", "adapter": "NathanPereira/crisis-triage-adapter", }, "Qwen3-4B (balanced)": { "base": "Qwen/Qwen3-4B", "adapter": "NathanPereira/crisis-triage-adapter-qwen3-4b", }, "DeepSeek-R1-Distill-Qwen-7B (stronger)": { "base": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", "adapter": "NathanPereira/crisis-triage-adapter-r1-distill-7b", }, } DEFAULT_MODEL_NAME = "Qwen2.5-1.5B (fast)" ASR_MODEL_ID = "openai/whisper-small" TTS_MODEL_ID = "facebook/mms-tts-eng" SYSTEM_PROMPT = ( "You are a research-only crisis triage assistant. " "Return only valid JSON matching the triage schema. " "Use exact evidence spans copied from the input text. " "Do not diagnose, prescribe medication, or provide therapy instructions." ) # name -> (tokenizer, model) on CPU; populated lazily, at most one entry at a time _loaded = {} def _get_triage_model(name: str): if name in _loaded: return _loaded[name] # evict whatever was loaded before so we never hold two student models in RAM if _loaded: old_name = next(iter(_loaded)) del _loaded[old_name] gc.collect() cfg = MODEL_REGISTRY[name] print(f"loading triage model '{name}' (CPU) ...") tok = AutoTokenizer.from_pretrained(cfg["base"]) base = AutoModelForCausalLM.from_pretrained(cfg["base"], torch_dtype=torch.float16) model = PeftModel.from_pretrained(base, cfg["adapter"], torch_device="cpu").eval() _loaded[name] = (tok, model) return tok, model print("loading Whisper STT (CPU) ...") asr_processor = WhisperProcessor.from_pretrained(ASR_MODEL_ID) asr_model = WhisperForConditionalGeneration.from_pretrained(ASR_MODEL_ID, torch_dtype=torch.float16).eval() print("loading TTS (CPU) ...") tts_tok = AutoTokenizer.from_pretrained(TTS_MODEL_ID) tts_model = VitsModel.from_pretrained(TTS_MODEL_ID).eval() print(f"warming default triage model: {DEFAULT_MODEL_NAME} ...") _get_triage_model(DEFAULT_MODEL_NAME) print("all models ready") def _salvage_tier(raw: str) -> int | None: m = re.search(r'"risk_tier"\s*:\s*([0-3])', raw) return int(m.group(1)) if m else None def _extract_json(raw: str) -> dict: if "" in raw: raw = raw.split("", 1)[1] s, e = raw.find("{"), raw.rfind("}") if s < 0 or e <= s: raise ValueError("no JSON object") return json.loads(raw[s : e + 1]) def _transcribe(audio) -> str: # gradio Audio(type="numpy") gives (sample_rate, np.ndarray); no file I/O, no librosa. import torchaudio sr, data = audio wav = torch.as_tensor(data).float() if wav.ndim == 2: # stereo -> mono wav = wav.mean(dim=1) if data.dtype.kind in "iu": # int PCM -> [-1, 1] wav = wav / float(np.iinfo(data.dtype).max) if sr != 16000: wav = torchaudio.functional.resample(wav, sr, 16000) inputs = asr_processor(wav.numpy(), sampling_rate=16000, return_tensors="pt") input_features = inputs.input_features.to("cuda", dtype=torch.float16) with torch.no_grad(): pred_ids = asr_model.generate(input_features, language="en", task="transcribe") return asr_processor.batch_decode(pred_ids, skip_special_tokens=True)[0].strip() def _normalize_tier(tier): """Collapse the many risk_tier shapes the model emits into an int 0..3. Handles ints, floats, numeric strings ("2"), and free-text labels ("High Risk", "imminent", "low") so downstream phrasing is consistent. Returns None if nothing recognizable. """ if isinstance(tier, bool) or tier is None: return None if isinstance(tier, (int, float)): t = int(tier) return t if 0 <= t <= 3 else None if isinstance(tier, str): s = tier.strip().lower() m = re.search(r"[0-3]", s) if m: return int(m.group()) if any(w in s for w in ("imminent", "critical", "emergency", "severe", "tier 3")): return 3 if "high" in s: return 2 if any(w in s for w in ("moderate", "medium", "elevated")): return 1 if any(w in s for w in ("low", "minimal", "none", "no risk")): return 0 return None def _first_text(value) -> str: """Pull a usable sentence out of a str / list / dict field.""" if isinstance(value, str): return value.strip() if isinstance(value, (list, tuple)): for v in value: t = _first_text(v) if t: return t if isinstance(value, dict): for v in value.values(): t = _first_text(v) if t: return t return "" # tier-aware openers, written to be spoken aloud like a person talking, not a report. # each has a connector for folding a next-step in naturally instead of a labeled clause. _TIER_VOICE = { 0: dict( opener="Thanks for sharing that with me. From what you've described, things " "don't sound like they're at a crisis point right now.", connector="That said, it can still help to", ), 1: dict( opener="I can hear that you're going through a tough time. What you're " "describing sounds like real distress, and it's worth taking seriously.", connector="One thing that might help is to", ), 2: dict( opener="I'm genuinely concerned about what you've shared. This sounds serious, " "and I want you to know your safety matters right now.", connector="Please", ), 3: dict( opener="What you've told me sounds like an emergency, and I'm worried about " "your safety right now.", connector="Right now, please", ), } _DEFAULT_VOICE = dict( opener="Thank you for trusting me with this. Whatever you're going through, " "you don't have to face it alone.", connector="It could help to", ) # always-on safety line for higher tiers, regardless of what JSON fields exist _SAFETY_LINE = ("And if you're ever in immediate danger, please call your local " "emergency number, or reach out to a crisis line right away.") def _lowercase_lead(s: str) -> str: """Lowercase a sentence's first letter so it reads naturally mid-sentence.""" return s[:1].lower() + s[1:] if s else s def _strip_trailing_period(s: str) -> str: return s[:-1] if s.endswith(".") else s def _build_summary(parsed: dict) -> str: """Compose a warm, conversational, tier-aware spoken triage summary. Written for a synthetic chain-of-thought triage adapter: it talks to the person the way a caring listener would, folds in one concrete next step as part of the same flowing sentence rather than a labeled clause, and always closes high-risk tiers with a safety line -- even when the model drifts off-schema. Prefers the trained `plain_language_summary` verbatim when present. """ if not isinstance(parsed, dict): return "" direct = parsed.get("plain_language_summary") if isinstance(direct, str) and direct.strip(): return direct.strip() tier = _normalize_tier(parsed.get("risk_tier")) voice = _TIER_VOICE.get(tier, _DEFAULT_VOICE) sentence = voice["opener"] # fold one concrete next step into the same breath as the opener, instead of # a separate labeled sentence for key in ("recommended_next_step", "action_steps", "resources", "clinical_rationale", "risk_factors"): action = _first_text(parsed.get(key)) if action: action = _strip_trailing_period(_lowercase_lead(action.strip())) sentence = f"{sentence} {voice['connector']} {action}." break escalate = (parsed.get("escalation_required") in (True, "true", "True", "yes", 1) or (tier is not None and tier >= 2)) if escalate: sentence = f"{sentence} {_SAFETY_LINE}" return sentence.strip() # badge color/label per normalized tier, used to render the report card _TIER_META = { 0: dict(color="#2e7d32", bg="#e8f5e9", label="Tier 0 · Minimal"), 1: dict(color="#a16207", bg="#fef9c3", label="Tier 1 · Moderate"), 2: dict(color="#c2410c", bg="#ffedd5", label="Tier 2 · High"), 3: dict(color="#b91c1c", bg="#fee2e2", label="Tier 3 · Imminent"), } _TIER_META_UNKNOWN = dict(color="#374151", bg="#f3f4f6", label="Tier unclear") # keys rendered up front, in this order, with friendly labels + how to render them _REPORT_FIELDS = [ ("evidence_spans", "Evidence from the text", "quotes"), ("risk_factors", "Risk factors", "list"), ("protective_factors", "Protective factors", "list"), ("cssrs_axes", "C-SSRS axes", "table"), ("clinical_rationale", "Clinical rationale", "text"), ("recommended_next_step", "Recommended next step", "highlight"), ("uncertainty_flags", "Uncertainty flags", "list"), ] # fields shown elsewhere (tier badge, escalation banner) or internal -- skip in the body _REPORT_SKIP_KEYS = { "risk_tier", "plain_language_summary", "escalation_required", "_parse_error", "_raw", "confidence", } def _esc(value) -> str: return html.escape(str(value), quote=True) def _render_inline(value) -> str: """Render a str/list/dict value as a flowing HTML fragment (no own heading).""" if isinstance(value, str): return _esc(value) if isinstance(value, (list, tuple)): items = [_render_inline(v) for v in value if _render_inline(v)] if not items: return "" return "
“{_esc(v)}”" for v in value if str(v).strip()) elif kind == "highlight": body = f"
The model's raw output didn't contain valid JSON -- see the raw output box below.
" "