"""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 "" if isinstance(value, dict): rows = "".join( f"{_esc(k)}{_render_inline(v)}" for k, v in value.items() if _render_inline(v) ) return f"{rows}
" if rows else "" if value in (None, "", [], {}): return "" return _esc(value) def _render_section(label: str, value, kind: str) -> str: if value in (None, "", [], {}): return "" if kind == "quotes" and isinstance(value, (list, tuple)): body = "".join(f"
“{_esc(v)}”
" for v in value if str(v).strip()) elif kind == "highlight": body = f"
{_render_inline(value)}
" else: body = _render_inline(value) if not body: return "" return f"
{_esc(label)}
{body}
" def render_report(parsed: dict) -> str: """Render the triage JSON as a readable HTML report card instead of raw JSON. Walks the known schema fields first (evidence, rationale, next step, ...) in a fixed friendly order, then falls back to rendering any extra/unexpected keys the model produced (schema drift), so nothing the model says is silently dropped. """ if not isinstance(parsed, dict): return "
No structured output to show.
" if parsed.get("_parse_error"): return ( "
" "
Couldn't parse a structured response
" "

The model's raw output didn't contain valid JSON -- see the raw output box below.

" "
" ) tier = _normalize_tier(parsed.get("risk_tier")) meta = _TIER_META.get(tier, _TIER_META_UNKNOWN) confidence = parsed.get("confidence") conf_html = "" if isinstance(confidence, (int, float)): conf_html = f"confidence {confidence:.0%}" \ f"" if confidence <= 1 else f"confidence {confidence}" escalate = (parsed.get("escalation_required") in (True, "true", "True", "yes", 1) or (tier is not None and tier >= 2)) sections = "".join( _render_section(label, parsed.get(key), kind) for key, label, kind in _REPORT_FIELDS ) extra_keys = [k for k in parsed if k not in _REPORT_SKIP_KEYS and k not in {f[0] for f in _REPORT_FIELDS}] extras = "".join( _render_section(k.replace("_", " ").title(), parsed.get(k), "text") for k in extra_keys ) banner = ( "
⚠ This case may need urgent attention or escalation.
" if escalate else "" ) return f"""
{_esc(meta['label'])}{conf_html}
{banner} {sections} {extras}
""" def _synthesize(text: str) -> tuple[int, np.ndarray]: inputs = tts_tok(text, return_tensors="pt").to("cuda") with torch.no_grad(): waveform = tts_model(**inputs).waveform audio = waveform.squeeze().to(torch.float32).cpu().numpy() return tts_model.config.sampling_rate, audio @spaces.GPU # borrows a real GPU only for the duration of this call def analyze(model_name: str, text: str, audio) -> tuple[str, str, str, str, str, tuple]: tok, model = _get_triage_model(model_name) asr_model.to("cuda") tts_model.to("cuda") model.to("cuda") transcript = "" if audio is not None: try: transcript = _transcribe(audio) text = transcript or text except Exception as exc: transcript = f"(speech-to-text failed: {exc})" if not text or not text.strip(): placeholder = "
Enter text or record audio first.
" return placeholder, "", "", transcript, "", None messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": text}] prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tok(prompt, return_tensors="pt").to("cuda") with torch.no_grad(): out = model.generate( **inputs, max_new_tokens=400, do_sample=False, pad_token_id=tok.eos_token_id ) raw = tok.decode(out[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True) try: parsed = _extract_json(raw) except Exception: parsed = {"risk_tier": _salvage_tier(raw), "_parse_error": True, "_raw": raw} summary = _build_summary(parsed) or ( "I couldn't generate a clear summary -- please check the structured output " "or contact a crisis line directly." ) try: speech_audio = _synthesize(summary) except Exception as exc: summary = f"{summary}\n\n(text-to-speech failed: {exc})" speech_audio = None return ( render_report(parsed), json.dumps(parsed, indent=2, ensure_ascii=False), raw, transcript, summary, speech_audio, ) with gr.Blocks(title="Crisis Triage — Research Demo") as demo: gr.Markdown( "## Crisis Triage — Research Demo\n" "Running on **ZeroGPU**, with voice input (Whisper STT), voice output (TTS), " "and a switchable student model.\n\n" "⚠ Research prototype. Not a clinical tool. " "Outputs are not a substitute for professional judgment or emergency services. " "If you or someone else is in immediate danger, contact local emergency services " "or a crisis line directly." ) model_choice = gr.Dropdown( label="Model", choices=list(MODEL_REGISTRY.keys()), value=DEFAULT_MODEL_NAME, info="Switching loads the new adapter on first use (a few seconds) and evicts the previous one.", ) with gr.Row(): inp = gr.Textbox(label="Text to assess", lines=5, placeholder="Type a message or transcript…") mic = gr.Audio(label="...or record/upload voice", sources=["microphone", "upload"], type="numpy") btn = gr.Button("Analyze", variant="primary") transcript_box = gr.Textbox(label="Transcribed speech (if audio was used)", lines=2, interactive=False) out_report = gr.HTML(label="Triage report") with gr.Accordion("Raw model output (JSON / debug)", open=False): out_json = gr.Code(label="Structured JSON output", language="json") out_raw = gr.Textbox(label="Raw model output", lines=4) out_summary = gr.Textbox(label="Plain-language summary (spoken aloud)", lines=2, interactive=False) out_audio = gr.Audio(label="Spoken response", type="numpy") btn.click( analyze, inputs=[model_choice, inp, mic], outputs=[out_report, out_json, out_raw, transcript_box, out_summary, out_audio], ) demo.launch()