Spaces:
Running on Zero
Running on Zero
File size: 5,115 Bytes
ecfd585 cce75df ecfd585 0ce4340 ecfd585 0ce4340 cce75df 0ce4340 ecfd585 0ce4340 ecfd585 0ce4340 ecfd585 0ce4340 8759951 ecfd585 0ce4340 cce75df ecfd585 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """Audio -> MedASR transcript -> MedGemma 4B SOAP note pipeline."""
import os
import re
import time
from dataclasses import dataclass
import spaces
import torch
from transformers import AutoModelForImageTextToText, AutoProcessor, pipeline
ASR_MODEL_ID = os.environ.get("MEDASR_MODEL_ID", "google/medasr")
LLM_MODEL_ID = os.environ.get("MEDGEMMA_MODEL_ID", "google/medgemma-4b-it")
SYSTEM_PROMPT = (
"You are a clinical documentation engine. Convert the transcript into a "
"SOAP note (Subjective, Objective, Assessment, Plan). Infer which "
"statements come from the doctor versus the patient based on context "
"(questions, clinical observations vs. symptom descriptions) and "
"attribute them accordingly (e.g. 'Patient reports...', 'On "
"questioning by the physician...'). Do not fabricate any detail not "
"present in the transcript. If speaker attribution is unclear, mark it "
"as unclear rather than guessing confidently."
)
# Forced prefix for the assistant turn: with `continue_final_message=True`,
# generation resumes mid-turn from this exact text, so there is no token
# position left for a preamble or transcript restatement to occupy. Only
# the first header is guaranteed this way — the other three are generated
# freely and get normalized to match by _normalize_soap_note below.
SOAP_PREFIX = "S — Subjective:"
_SOAP_HEADERS = [
("S", "Subjective"),
("O", "Objective"),
("A", "Assessment"),
("P", "Plan"),
]
_SOAP_TITLE_RE = re.compile(r"(?im)^[ \t]*\**[ \t]*SOAP Note[ \t]*:?\**[ \t]*\n+")
_SOAP_HEADER_RES = [
(
re.compile(
rf"(?im)^[ \t]*\**[ \t]*(?:{letter}[ \t]*[-—][ \t]*)?{word}[ \t]*:\**"
),
f"{letter} — {word}:",
)
for letter, word in _SOAP_HEADERS
]
def _normalize_soap_note(text: str) -> str:
"""Force all four section headers to the same 'X — Word:' shape.
Only the first header is pinned via the forced assistant prefix; the
model is free to drift on the rest (e.g. writing 'Plan:' instead of
'P — Plan:'), so headers are normalized here rather than trusted.
"""
text = _SOAP_TITLE_RE.sub("", text.strip())
for pattern, canonical in _SOAP_HEADER_RES:
text = pattern.sub(canonical, text)
return text.strip()
_asr_pipe = None
_llm_model = None
_llm_processor = None
def _device_and_dtype():
if torch.cuda.is_available():
return "cuda", torch.bfloat16
if torch.backends.mps.is_available():
return "mps", torch.float16
return "cpu", torch.float32
def get_asr_pipeline():
global _asr_pipe
if _asr_pipe is None:
device, dtype = _device_and_dtype()
_asr_pipe = pipeline(
"automatic-speech-recognition",
model=ASR_MODEL_ID,
device=device,
dtype=dtype,
)
return _asr_pipe
def get_llm():
global _llm_model, _llm_processor
if _llm_model is None:
device, dtype = _device_and_dtype()
_llm_model = AutoModelForImageTextToText.from_pretrained(
LLM_MODEL_ID, dtype=dtype, device_map=device
)
_llm_processor = AutoProcessor.from_pretrained(LLM_MODEL_ID)
return _llm_model, _llm_processor
def transcribe(audio_path: str) -> str:
"""Transcribe an audio file to text using MedASR, chunked for long audio."""
pipe = get_asr_pipeline()
result = pipe(audio_path, chunk_length_s=20, stride_length_s=2)
return result["text"].strip()
def generate_soap_note(transcript: str) -> str:
model, processor = get_llm()
messages = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
{"role": "user", "content": [{"type": "text", "text": transcript}]},
{"role": "assistant", "content": [{"type": "text", "text": SOAP_PREFIX}]},
]
inputs = processor.apply_chat_template(
messages,
continue_final_message=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
).to(model.device)
input_len = inputs["input_ids"].shape[-1]
with torch.inference_mode():
generated_ids = model.generate(
**inputs,
max_new_tokens=768,
do_sample=True,
temperature=0.2,
top_p=0.9,
repetition_penalty=1.15,
no_repeat_ngram_size=3,
)
new_tokens = generated_ids[0][input_len:]
completion = processor.decode(new_tokens, skip_special_tokens=True)
return _normalize_soap_note(SOAP_PREFIX + completion)
@dataclass
class PipelineResult:
transcript: str
soap_note: str
transcription_seconds: float
generation_seconds: float
@spaces.GPU(duration=120)
def run_pipeline(audio_path: str) -> PipelineResult:
t0 = time.perf_counter()
transcript = transcribe(audio_path)
t1 = time.perf_counter()
soap_note = generate_soap_note(transcript)
t2 = time.perf_counter()
return PipelineResult(
transcript=transcript,
soap_note=soap_note,
transcription_seconds=t1 - t0,
generation_seconds=t2 - t1,
)
|