sugatobagchi's picture
Upload 2 files
cce75df verified
Raw
History Blame Contribute Delete
5.12 kB
"""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,
)