File size: 9,311 Bytes
02e1087
3a9499b
02e1087
3a9499b
 
 
 
 
 
 
02e1087
3a9499b
 
02e1087
3a9499b
 
cd1513c
02e1087
 
 
3a9499b
02e1087
 
 
 
5452df1
3a9499b
 
 
02e1087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29a0caf
1ae9811
 
 
 
 
 
3a9499b
02e1087
1ae9811
 
 
 
 
02e1087
29a0caf
 
 
 
 
1ae9811
 
 
29a0caf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2096cf1
3a9499b
 
 
02e1087
 
 
 
3a9499b
02e1087
 
3a9499b
 
 
 
 
02e1087
 
 
1ae9811
02e1087
 
 
 
3a9499b
 
 
 
 
 
 
 
 
02e1087
 
3a9499b
 
29a0caf
 
 
c368c95
3a9499b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02e1087
3a9499b
02e1087
 
3a9499b
02e1087
 
 
 
 
 
3a9499b
02e1087
 
 
 
 
 
3a9499b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02e1087
3a9499b
 
 
02e1087
 
3a9499b
02e1087
3a9499b
 
02e1087
 
 
3a9499b
02e1087
 
3a9499b
 
02e1087
 
 
 
 
 
 
3a9499b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296f2e3
3a9499b
 
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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""
PlainScript — Patient-Friendly Medical Rewriter (Hugging Face ZeroGPU).

Designed specifically for ZeroGPU Spaces:
  - The GPU-using function is decorated with @spaces.GPU (a GPU is attached
    only while it runs, then released).
  - The model is instantiated at module scope and moved to CUDA eagerly;
    ZeroGPU maps the device transparently.
  - requirements.txt does NOT pin torch / gradio / spaces — the ZeroGPU base
    image provides those, and pinning them causes dependency conflicts.

Before ("base") and after ("fine-tuned") outputs come from a single
PeftModel by toggling the LoRA adapter with disable_adapter().

Portions of this file were developed with assistance from Claude (Anthropic).
"""

import os

import gradio as gr
import spaces
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel


BASE_MODEL = "Qwen/Qwen2-1.5B-Instruct"
ADAPTER_ID = os.environ.get("ADAPTER_ID", "zkmine/plainscript-adapter")
HF_TOKEN = os.environ.get("HF_TOKEN")  # optional; only needed if adapter is private

SYSTEM_PROMPT = (
    "You are a medical text simplifier. Rewrite the following medical text "
    "into plain language that a patient with no medical background can "
    "understand. Preserve all key findings and conclusions. Do not add "
    "information not present in the original text."
)

EXAMPLES = [
    "A meta-analysis of randomized controlled trials demonstrated a "
    "statistically significant reduction in glycated hemoglobin (HbA1c) "
    "levels (mean difference -0.5%, 95% CI -0.7 to -0.3, p<0.001) in "
    "patients receiving the intervention compared to placebo.",
    "The systematic review found moderate-certainty evidence that cognitive "
    "behavioural therapy reduces the severity of chronic insomnia symptoms "
    "compared with treatment as usual, measured by the Pittsburgh Sleep "
    "Quality Index at 8 weeks post-intervention.",
    "Prophylactic administration of low-molecular-weight heparin was "
    "associated with reduced incidence of venous thromboembolism in "
    "post-operative orthopaedic patients (RR 0.50, 95% CI 0.33 to 0.76), "
    "though with a concomitant increase in minor bleeding events.",
]

# --- On ZeroGPU, torch is patched at import time and there is NO GPU at
#     module scope, so we cannot load model weights onto CUDA here. But we CAN
#     pre-download the files to the local cache at startup, so the GPU call
#     only has to load from disk (fast) and stays within the 60s GPU budget. ---
from huggingface_hub import snapshot_download

print("Loading tokenizer and pre-downloading model files...")
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

# Warm the HF cache so weights are on local disk before any GPU call.
snapshot_download(BASE_MODEL)
snapshot_download(ADAPTER_ID, token=HF_TOKEN)
print("Tokenizer ready and weights cached; model loads on first request.")

_model = None  # lazily populated inside the @spaces.GPU function


def _load_model():
    """
    Load base + LoRA adapter from the local cache and place on CUDA. Called
    once, lazily, from inside the @spaces.GPU function where a GPU is
    attached. Because files are already cached, this is just a disk load.
    """
    global _model
    if _model is not None:
        return _model

    base_model = AutoModelForCausalLM.from_pretrained(
        BASE_MODEL,
        dtype=torch.float16,
        trust_remote_code=True,
    )
    peft_model = PeftModel.from_pretrained(base_model, ADAPTER_ID, token=HF_TOKEN)
    peft_model = peft_model.to("cuda")
    peft_model.eval()
    _model = peft_model
    return _model


def _build_prompt(text: str) -> str:
    """Render the chat prompt for a single user input."""
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": text},
    ]
    return tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )


def _decode(output_ids, input_len: int) -> str:
    """Decode only the newly generated tokens."""
    gen = output_ids[0][input_len:]
    return tokenizer.decode(gen, skip_special_tokens=True).strip()


@spaces.GPU(duration=60)
def rewrite(text: str):
    """
    Produce base ('before') and fine-tuned ('after') rewrites.

    Runs on a ZeroGPU-attached GPU. The base output is generated with the
    LoRA adapter disabled; the fine-tuned output with it enabled — one model
    in memory, toggled per call.

    Args:
        text: The medical text to rewrite.

    Returns:
        Tuple of (before_text, after_text).
    """
    if not text or not text.strip():
        return "", "Please paste some medical text above first."

    # Load the model on first use — we are inside @spaces.GPU here, so a GPU
    # is attached and CUDA is available (unlike at module scope).
    model = _load_model()

    prompt = _build_prompt(text)
    inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
    input_len = inputs["input_ids"].shape[1]

    gen_kwargs = dict(
        max_new_tokens=400,
        do_sample=True,
        temperature=0.7,
        top_p=0.9,
        repetition_penalty=1.1,
    )

    with torch.no_grad():
        with model.disable_adapter():
            before_ids = model.generate(**inputs, **gen_kwargs)
        after_ids = model.generate(**inputs, **gen_kwargs)

    return _decode(before_ids, input_len), _decode(after_ids, input_len)


# --- UI ---

CUSTOM_CSS = """
.gradio-container {
  background: linear-gradient(135deg, #F0FDFA 0%, #E0F2FE 50%, #FCE7F3 100%);
  background-size: 400% 400%;
  animation: bgshift 18s ease infinite;
  max-width: 1080px !important; margin: auto !important;
}
@keyframes bgshift {
  0% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
  100% { background-position: 0% 50%; }
}
#sticker-layer { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.sticker { position: absolute; font-size: 2.4rem; opacity: 0.13; animation: float 15s ease-in-out infinite; }
.sticker:nth-child(1){left:6%;top:18%;animation-delay:0s}
.sticker:nth-child(2){left:85%;top:12%;animation-delay:2s}
.sticker:nth-child(3){left:12%;top:73%;animation-delay:4s}
.sticker:nth-child(4){left:80%;top:68%;animation-delay:1s}
.sticker:nth-child(5){left:46%;top:8%;animation-delay:3s}
.sticker:nth-child(6){left:90%;top:44%;animation-delay:5s}
@keyframes float { 0%,100%{transform:translateY(0) rotate(-4deg)} 50%{transform:translateY(-24px) rotate(4deg)} }
.hero { position: relative; z-index: 1; text-align: center; padding: 24px 16px 4px; }
.hero h1 { font-size: 2.4rem; font-weight: 800; letter-spacing: -0.02em; color: #0F172A; margin: 0; }
.hero h1 .accent { color: #0B7C7B; }
.hero p { color: #475569; font-size: 1.02rem; margin: 8px auto 0; max-width: 600px; }
.ecg { width: 210px; height: 38px; margin: 12px auto 0; display: block; }
.ecg path { fill: none; stroke: #FB7185; stroke-width: 2.5; stroke-dasharray: 300; stroke-dashoffset: 300; animation: trace 2.2s linear infinite; }
@keyframes trace { to { stroke-dashoffset: -300; } }
#after_box textarea { background: rgba(240,253,250,0.9) !important; border-left: 4px solid #0EA5A4 !important; font-weight: 500; }
#before_box textarea { background: rgba(255,255,255,0.6) !important; border-left: 4px solid #94A3B8 !important; }
.disclaimer { text-align:center; color:#64748B; font-size:0.82rem; margin-top:16px; position:relative; z-index:1; }
"""

STICKERS = """
<div id="sticker-layer">
  <div class="sticker">🩺</div><div class="sticker">💊</div><div class="sticker">❤️</div>
  <div class="sticker">🩹</div><div class="sticker">💉</div><div class="sticker">🏥</div>
</div>
"""

HERO = """
<div class="hero">
  <h1>Plain<span class="accent">Script</span></h1>
  <p>Paste a dense medical abstract and watch it become language a patient can
     understand — the same model, before and after fine-tuning.</p>
  <svg class="ecg" viewBox="0 0 220 40">
    <path d="M0,20 L60,20 L70,20 L78,4 L88,36 L98,20 L120,20 L128,12 L136,28 L146,20 L220,20"/>
  </svg>
</div>
"""


def build_app() -> gr.Blocks:
    """Construct the Gradio Blocks UI."""
    with gr.Blocks(css=CUSTOM_CSS, title="PlainScript") as demo:
        gr.HTML(STICKERS)
        gr.HTML(HERO)

        inp = gr.Textbox(
            label="Medical text",
            placeholder="Paste a medical abstract or clinical summary here…",
            lines=6,
        )
        btn = gr.Button("Rewrite it", variant="primary")

        with gr.Row():
            before = gr.Textbox(label="Before · base Qwen2-1.5B", lines=9,
                                interactive=False, elem_id="before_box")
            after = gr.Textbox(label="After · fine-tuned on Cochrane", lines=9,
                               interactive=False, elem_id="after_box")

        gr.Examples(examples=EXAMPLES, inputs=inp)
        gr.HTML(
            '<div class="disclaimer">Drafts for review only — not medical '
            "advice. Outputs may contain errors and must be checked by a "
            "qualified professional.</div>"
        )

        btn.click(fn=rewrite, inputs=inp, outputs=[before, after])

    return demo


if __name__ == "__main__":
    build_app().launch()