Spaces:
Running on Zero
Running on Zero
| """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) | |
| class PipelineResult: | |
| transcript: str | |
| soap_note: str | |
| transcription_seconds: float | |
| generation_seconds: float | |
| 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, | |
| ) | |