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,
    )