Spaces:
Sleeping
Sleeping
Commit ·
874f913
1
Parent(s): e618ecb
Restore all features from origin/fresh-main
Browse files- backend/api.py +35 -15
- backend/audio.py +9 -5
- backend/models/doc_generator.py +114 -13
- backend/models/ehr_agent.py +102 -33
- backend/models/medasr.py +75 -23
- backend/orchestrator.py +15 -3
- backend/prompts/document_generation.j2 +4 -4
- backend/prompts/ward_round_generation.j2 +45 -0
- backend/schemas.py +2 -0
- frontend/.DS_Store +0 -0
- frontend/components.py +192 -1
- frontend/state.py +10 -0
- frontend/ui.py +253 -34
backend/api.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
|
|
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
from pathlib import Path
|
| 8 |
from shutil import copyfileobj
|
|
@@ -302,7 +303,7 @@ def upload_audio(
|
|
| 302 |
if extension not in {".wav", ".webm"}:
|
| 303 |
raise HTTPException(status_code=400, detail="Only WAV or WebM audio uploads are supported")
|
| 304 |
|
| 305 |
-
uploads_root = Path("
|
| 306 |
raw_path = uploads_root / f"raw{extension}"
|
| 307 |
original_stem = Path(audio_file.filename or "audio").stem or "audio"
|
| 308 |
wav_path = uploads_root / f"{original_stem}_16k.wav"
|
|
@@ -335,7 +336,7 @@ def upload_audio(
|
|
| 335 |
|
| 336 |
|
| 337 |
@app.post("/api/v1/consultations/{consultation_id}/end", status_code=202)
|
| 338 |
-
def end_consultation(consultation_id: str) -> dict[str, Any]:
|
| 339 |
"""End a consultation and execute full processing pipeline.
|
| 340 |
|
| 341 |
Args:
|
|
@@ -346,24 +347,43 @@ def end_consultation(consultation_id: str) -> dict[str, Any]:
|
|
| 346 |
"""
|
| 347 |
|
| 348 |
try:
|
| 349 |
-
consultation = orchestrator.
|
| 350 |
-
progress = orchestrator.get_progress(consultation_id)
|
| 351 |
except KeyError as exc:
|
| 352 |
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
| 362 |
return {
|
| 363 |
"consultation_id": consultation.id,
|
| 364 |
-
"status":
|
| 365 |
-
"pipeline_stage":
|
| 366 |
-
"message": "Pipeline
|
| 367 |
}
|
| 368 |
|
| 369 |
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
+
import threading
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
from shutil import copyfileobj
|
|
|
|
| 303 |
if extension not in {".wav", ".webm"}:
|
| 304 |
raise HTTPException(status_code=400, detail="Only WAV or WebM audio uploads are supported")
|
| 305 |
|
| 306 |
+
uploads_root = Path("/tmp/uploads") / consultation_id
|
| 307 |
raw_path = uploads_root / f"raw{extension}"
|
| 308 |
original_stem = Path(audio_file.filename or "audio").stem or "audio"
|
| 309 |
wav_path = uploads_root / f"{original_stem}_16k.wav"
|
|
|
|
| 336 |
|
| 337 |
|
| 338 |
@app.post("/api/v1/consultations/{consultation_id}/end", status_code=202)
|
| 339 |
+
def end_consultation(consultation_id: str, body: dict[str, Any] | None = Body(None)) -> dict[str, Any]:
|
| 340 |
"""End a consultation and execute full processing pipeline.
|
| 341 |
|
| 342 |
Args:
|
|
|
|
| 347 |
"""
|
| 348 |
|
| 349 |
try:
|
| 350 |
+
consultation = orchestrator.get_consultation(consultation_id)
|
|
|
|
| 351 |
except KeyError as exc:
|
| 352 |
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
| 353 |
+
|
| 354 |
+
# Accept audio path directly from frontend for demo/server-side audio files
|
| 355 |
+
if body and body.get("audio_path"):
|
| 356 |
+
audio_path = body["audio_path"]
|
| 357 |
+
if Path(audio_path).exists():
|
| 358 |
+
consultation.audio_file_path = audio_path
|
| 359 |
+
|
| 360 |
+
# Accept document type and letter preferences from frontend
|
| 361 |
+
if body and body.get("doc_type"):
|
| 362 |
+
consultation.doc_type = body["doc_type"]
|
| 363 |
+
if body and body.get("letter_prefs"):
|
| 364 |
+
consultation.letter_prefs = body["letter_prefs"]
|
| 365 |
+
|
| 366 |
+
# Launch pipeline in background thread – avoids HF Spaces 60s gateway timeout
|
| 367 |
+
def _run_pipeline_background() -> None:
|
| 368 |
+
"""Execute the orchestrator pipeline in a daemon thread.
|
| 369 |
+
|
| 370 |
+
Allows the /end endpoint to return 202 immediately while processing
|
| 371 |
+
continues. Frontend polls /progress for status updates.
|
| 372 |
+
"""
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
orchestrator.end_consultation(consultation_id)
|
| 376 |
+
except Exception as exc:
|
| 377 |
+
logger.error(f"Background pipeline error: {exc}", consultation_id=consultation_id)
|
| 378 |
+
|
| 379 |
+
thread = threading.Thread(target=_run_pipeline_background, daemon=True)
|
| 380 |
+
thread.start()
|
| 381 |
|
| 382 |
return {
|
| 383 |
"consultation_id": consultation.id,
|
| 384 |
+
"status": "processing",
|
| 385 |
+
"pipeline_stage": "transcribing",
|
| 386 |
+
"message": "Pipeline started in background. Poll /progress for updates.",
|
| 387 |
}
|
| 388 |
|
| 389 |
|
backend/audio.py
CHANGED
|
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
-
|
|
|
|
| 8 |
from pydub import AudioSegment
|
| 9 |
|
| 10 |
from backend.errors import AudioError
|
|
@@ -62,13 +63,16 @@ def validate_audio(file_path: str) -> dict[str, float | int]:
|
|
| 62 |
raise AudioError(f"Audio file not found: {path}")
|
| 63 |
|
| 64 |
try:
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
except Exception as exc:
|
| 67 |
raise AudioError(f"Unable to load audio for validation: {path}: {exc}") from exc
|
| 68 |
|
| 69 |
-
channels = int(waveform.shape[0]) if getattr(waveform, "ndim", 1) > 1 else 1
|
| 70 |
-
duration_s = float(librosa.get_duration(y=waveform, sr=sample_rate))
|
| 71 |
-
|
| 72 |
if sample_rate != EXPECTED_SAMPLE_RATE:
|
| 73 |
raise AudioError(f"Invalid sample rate {sample_rate}; expected {EXPECTED_SAMPLE_RATE}")
|
| 74 |
if channels != EXPECTED_CHANNELS:
|
|
|
|
| 4 |
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
+
|
| 8 |
+
# librosa removed — using stdlib wave module for validation to avoid numba issues in Docker
|
| 9 |
from pydub import AudioSegment
|
| 10 |
|
| 11 |
from backend.errors import AudioError
|
|
|
|
| 63 |
raise AudioError(f"Audio file not found: {path}")
|
| 64 |
|
| 65 |
try:
|
| 66 |
+
import wave as wave_mod
|
| 67 |
+
|
| 68 |
+
with wave_mod.open(str(path), "rb") as wf:
|
| 69 |
+
sample_rate = wf.getframerate()
|
| 70 |
+
channels = wf.getnchannels()
|
| 71 |
+
n_frames = wf.getnframes()
|
| 72 |
+
duration_s = float(n_frames) / float(sample_rate) if sample_rate > 0 else 0.0
|
| 73 |
except Exception as exc:
|
| 74 |
raise AudioError(f"Unable to load audio for validation: {path}: {exc}") from exc
|
| 75 |
|
|
|
|
|
|
|
|
|
|
| 76 |
if sample_rate != EXPECTED_SAMPLE_RATE:
|
| 77 |
raise AudioError(f"Invalid sample rate {sample_rate}; expected {EXPECTED_SAMPLE_RATE}")
|
| 78 |
if channels != EXPECTED_CHANNELS:
|
backend/models/doc_generator.py
CHANGED
|
@@ -20,6 +20,16 @@ except ModuleNotFoundError: # pragma: no cover - mock mode support
|
|
| 20 |
AutoTokenizer = None
|
| 21 |
BitsAndBytesConfig = None
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from backend.config import get_settings
|
| 24 |
from backend.errors import ModelExecutionError, get_component_logger
|
| 25 |
from backend.schemas import ClinicalDocument, ConsultationStatus, DocumentSection, PatientContext
|
|
@@ -126,23 +136,23 @@ class DocumentGenerator:
|
|
| 126 |
output_tokens = self._model.generate(
|
| 127 |
**inputs,
|
| 128 |
max_new_tokens=generation_max_tokens,
|
| 129 |
-
|
| 130 |
-
top_p=0.9,
|
| 131 |
-
top_k=40,
|
| 132 |
-
do_sample=True,
|
| 133 |
repetition_penalty=1.1,
|
| 134 |
)
|
| 135 |
except Exception as exc:
|
| 136 |
raise ModelExecutionError(f"MedGemma 27B inference failed: {exc}") from exc
|
| 137 |
|
| 138 |
decoded_output = self._tokenizer.decode(output_tokens[0], skip_special_tokens=True)
|
| 139 |
-
|
|
|
|
| 140 |
|
| 141 |
def generate_document(
|
| 142 |
self,
|
| 143 |
transcript: str,
|
| 144 |
context: PatientContext,
|
| 145 |
max_new_tokens: int | None = None,
|
|
|
|
|
|
|
| 146 |
) -> ClinicalDocument:
|
| 147 |
"""Render prompt, generate text with retry policy, and build ClinicalDocument.
|
| 148 |
|
|
@@ -155,7 +165,7 @@ class DocumentGenerator:
|
|
| 155 |
ClinicalDocument: Parsed clinical letter representation with section objects.
|
| 156 |
"""
|
| 157 |
|
| 158 |
-
prompt = self._render_prompt(transcript, context)
|
| 159 |
generation_start = time.perf_counter()
|
| 160 |
|
| 161 |
last_error: Exception | None = None
|
|
@@ -175,24 +185,39 @@ class DocumentGenerator:
|
|
| 175 |
|
| 176 |
raise ModelExecutionError(f"Document generation failed after retry: {last_error}")
|
| 177 |
|
| 178 |
-
def _render_prompt(self, transcript: str, context: PatientContext) -> str:
|
| 179 |
"""Render the document generation Jinja2 template with consultation inputs.
|
| 180 |
|
| 181 |
Args:
|
| 182 |
transcript (str): Consultation transcript text.
|
| 183 |
context (PatientContext): Structured patient context data.
|
|
|
|
|
|
|
| 184 |
|
| 185 |
Returns:
|
| 186 |
str: Rendered prompt string supplied to the language model.
|
| 187 |
"""
|
| 188 |
|
|
|
|
| 189 |
env = Environment(loader=FileSystemLoader(PROMPTS_DIR))
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
context_json = json.dumps(context.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
| 192 |
return template.render(
|
| 193 |
letter_date=datetime.now(tz=timezone.utc).strftime("%d %b %Y"),
|
| 194 |
-
clinician_name="Dr. Sarah Chen",
|
| 195 |
-
clinician_title="Consultant Diabetologist",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
transcript=transcript,
|
| 197 |
context_json=context_json,
|
| 198 |
)
|
|
@@ -208,13 +233,16 @@ class DocumentGenerator:
|
|
| 208 |
list[DocumentSection]: Ordered parsed sections with heading and content fields.
|
| 209 |
"""
|
| 210 |
|
|
|
|
|
|
|
| 211 |
section_pattern = re.compile(
|
| 212 |
-
r"^(?:\*\*|##\s*)?(History of presenting complaint|Examination findings|Investigation results|Assessment and plan|Current medications)[:\*\s]*$",
|
| 213 |
flags=re.IGNORECASE,
|
| 214 |
)
|
| 215 |
sections: list[DocumentSection] = []
|
| 216 |
current_heading: str | None = None
|
| 217 |
current_lines: list[str] = []
|
|
|
|
| 218 |
|
| 219 |
for raw_line in generated_text.splitlines():
|
| 220 |
line = raw_line.strip()
|
|
@@ -233,8 +261,11 @@ class DocumentGenerator:
|
|
| 233 |
current_lines = []
|
| 234 |
continue
|
| 235 |
|
| 236 |
-
if current_heading
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
if current_heading and current_lines:
|
| 240 |
sections.append(
|
|
@@ -246,6 +277,47 @@ class DocumentGenerator:
|
|
| 246 |
)
|
| 247 |
)
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
if not sections:
|
| 250 |
sections = [
|
| 251 |
DocumentSection(
|
|
@@ -314,6 +386,35 @@ class DocumentGenerator:
|
|
| 314 |
return decoded_output[len(prompt) :].strip()
|
| 315 |
return decoded_output.strip()
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
@staticmethod
|
| 318 |
def _mock_reference_letter() -> str:
|
| 319 |
"""Return deterministic reference letter text for mock mode generation.
|
|
|
|
| 20 |
AutoTokenizer = None
|
| 21 |
BitsAndBytesConfig = None
|
| 22 |
|
| 23 |
+
if torch is not None and not hasattr(torch.nn.Module, "set_submodule"):
|
| 24 |
+
def _set_submodule(self, target, module):
|
| 25 |
+
atoms = target.split(".")
|
| 26 |
+
mod = self
|
| 27 |
+
for item in atoms[:-1]:
|
| 28 |
+
mod = getattr(mod, item)
|
| 29 |
+
setattr(mod, atoms[-1], module)
|
| 30 |
+
|
| 31 |
+
torch.nn.Module.set_submodule = _set_submodule
|
| 32 |
+
|
| 33 |
from backend.config import get_settings
|
| 34 |
from backend.errors import ModelExecutionError, get_component_logger
|
| 35 |
from backend.schemas import ClinicalDocument, ConsultationStatus, DocumentSection, PatientContext
|
|
|
|
| 136 |
output_tokens = self._model.generate(
|
| 137 |
**inputs,
|
| 138 |
max_new_tokens=generation_max_tokens,
|
| 139 |
+
do_sample=False,
|
|
|
|
|
|
|
|
|
|
| 140 |
repetition_penalty=1.1,
|
| 141 |
)
|
| 142 |
except Exception as exc:
|
| 143 |
raise ModelExecutionError(f"MedGemma 27B inference failed: {exc}") from exc
|
| 144 |
|
| 145 |
decoded_output = self._tokenizer.decode(output_tokens[0], skip_special_tokens=True)
|
| 146 |
+
stripped = self._strip_prompt_prefix(decoded_output, prompt)
|
| 147 |
+
return self._clean_model_output(stripped)
|
| 148 |
|
| 149 |
def generate_document(
|
| 150 |
self,
|
| 151 |
transcript: str,
|
| 152 |
context: PatientContext,
|
| 153 |
max_new_tokens: int | None = None,
|
| 154 |
+
doc_type: str = "Clinic Letter",
|
| 155 |
+
letter_prefs: dict | None = None,
|
| 156 |
) -> ClinicalDocument:
|
| 157 |
"""Render prompt, generate text with retry policy, and build ClinicalDocument.
|
| 158 |
|
|
|
|
| 165 |
ClinicalDocument: Parsed clinical letter representation with section objects.
|
| 166 |
"""
|
| 167 |
|
| 168 |
+
prompt = self._render_prompt(transcript, context, doc_type=doc_type, letter_prefs=letter_prefs)
|
| 169 |
generation_start = time.perf_counter()
|
| 170 |
|
| 171 |
last_error: Exception | None = None
|
|
|
|
| 185 |
|
| 186 |
raise ModelExecutionError(f"Document generation failed after retry: {last_error}")
|
| 187 |
|
| 188 |
+
def _render_prompt(self, transcript: str, context: PatientContext, doc_type: str = "Clinic Letter", letter_prefs: dict | None = None) -> str:
|
| 189 |
"""Render the document generation Jinja2 template with consultation inputs.
|
| 190 |
|
| 191 |
Args:
|
| 192 |
transcript (str): Consultation transcript text.
|
| 193 |
context (PatientContext): Structured patient context data.
|
| 194 |
+
doc_type (str): Document type - "Clinic Letter" or "Ward Round Note".
|
| 195 |
+
letter_prefs (dict | None): Optional letter preferences from frontend.
|
| 196 |
|
| 197 |
Returns:
|
| 198 |
str: Rendered prompt string supplied to the language model.
|
| 199 |
"""
|
| 200 |
|
| 201 |
+
prefs = letter_prefs or {}
|
| 202 |
env = Environment(loader=FileSystemLoader(PROMPTS_DIR))
|
| 203 |
+
template_name = "ward_round_generation.j2" if doc_type == "Ward Round Note" else "document_generation.j2"
|
| 204 |
+
template = env.get_template(template_name)
|
| 205 |
+
|
| 206 |
+
# Extract patient details from context
|
| 207 |
+
patient_name = context.demographics.get("name", "Unknown")
|
| 208 |
+
patient_dob = context.demographics.get("dob", "Unknown")
|
| 209 |
+
patient_nhs = context.demographics.get("nhs_number", "Unknown")
|
| 210 |
+
|
| 211 |
context_json = json.dumps(context.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
| 212 |
return template.render(
|
| 213 |
letter_date=datetime.now(tz=timezone.utc).strftime("%d %b %Y"),
|
| 214 |
+
clinician_name=prefs.get("clinician_name", "Dr. Sarah Chen"),
|
| 215 |
+
clinician_title=prefs.get("clinician_title", "Consultant Diabetologist"),
|
| 216 |
+
gp_name=prefs.get("gp_name", "Dr Andrew Wilson"),
|
| 217 |
+
gp_address=prefs.get("gp_address", "Riverside Medical Practice"),
|
| 218 |
+
patient_name=patient_name,
|
| 219 |
+
patient_dob=patient_dob,
|
| 220 |
+
patient_nhs=patient_nhs,
|
| 221 |
transcript=transcript,
|
| 222 |
context_json=context_json,
|
| 223 |
)
|
|
|
|
| 233 |
list[DocumentSection]: Ordered parsed sections with heading and content fields.
|
| 234 |
"""
|
| 235 |
|
| 236 |
+
logger.info("Raw generated text for parsing:\n{}", generated_text[:2000])
|
| 237 |
+
|
| 238 |
section_pattern = re.compile(
|
| 239 |
+
r"^(?:\*\*|##\s*)?(?:\d+[\)\.]\s*)?(History of presenting complaint|Examination findings|Investigation results|Assessment and plan|Current medications|Overnight events|Current status and observations|Tasks / Actions|Tasks|Actions)[:\*\s]*$",
|
| 240 |
flags=re.IGNORECASE,
|
| 241 |
)
|
| 242 |
sections: list[DocumentSection] = []
|
| 243 |
current_heading: str | None = None
|
| 244 |
current_lines: list[str] = []
|
| 245 |
+
header_lines: list[str] = []
|
| 246 |
|
| 247 |
for raw_line in generated_text.splitlines():
|
| 248 |
line = raw_line.strip()
|
|
|
|
| 261 |
current_lines = []
|
| 262 |
continue
|
| 263 |
|
| 264 |
+
if current_heading:
|
| 265 |
+
if line:
|
| 266 |
+
current_lines.append(line)
|
| 267 |
+
elif line:
|
| 268 |
+
header_lines.append(line)
|
| 269 |
|
| 270 |
if current_heading and current_lines:
|
| 271 |
sections.append(
|
|
|
|
| 277 |
)
|
| 278 |
)
|
| 279 |
|
| 280 |
+
# Insert letter header (addressee, date, salutation) as first section if present
|
| 281 |
+
if header_lines:
|
| 282 |
+
header_text = "\n".join(header_lines).strip()
|
| 283 |
+
if header_text:
|
| 284 |
+
sections.insert(
|
| 285 |
+
0,
|
| 286 |
+
DocumentSection(
|
| 287 |
+
heading="Letter Header",
|
| 288 |
+
content=header_text,
|
| 289 |
+
editable=True,
|
| 290 |
+
fhir_sources=[],
|
| 291 |
+
),
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
# Strip sign-off block from last section content
|
| 295 |
+
if sections:
|
| 296 |
+
last = sections[-1]
|
| 297 |
+
signoff_pattern = re.compile(
|
| 298 |
+
r"\n\s*\n\s*(Warm regards|Kind regards|Yours sincerely|Yours faithfully|Sign-off:).*",
|
| 299 |
+
flags=re.IGNORECASE | re.DOTALL,
|
| 300 |
+
)
|
| 301 |
+
cleaned = signoff_pattern.sub("", last.content)
|
| 302 |
+
if cleaned != last.content:
|
| 303 |
+
signoff_text = last.content[len(cleaned):].strip()
|
| 304 |
+
sections[-1] = DocumentSection(
|
| 305 |
+
heading=last.heading,
|
| 306 |
+
content=cleaned.strip(),
|
| 307 |
+
editable=last.editable,
|
| 308 |
+
fhir_sources=last.fhir_sources,
|
| 309 |
+
)
|
| 310 |
+
# Add sign-off as its own section
|
| 311 |
+
if signoff_text:
|
| 312 |
+
sections.append(
|
| 313 |
+
DocumentSection(
|
| 314 |
+
heading="Sign-off",
|
| 315 |
+
content=signoff_text,
|
| 316 |
+
editable=True,
|
| 317 |
+
fhir_sources=[],
|
| 318 |
+
)
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
if not sections:
|
| 322 |
sections = [
|
| 323 |
DocumentSection(
|
|
|
|
| 386 |
return decoded_output[len(prompt) :].strip()
|
| 387 |
return decoded_output.strip()
|
| 388 |
|
| 389 |
+
@staticmethod
|
| 390 |
+
def _clean_model_output(text: str) -> str:
|
| 391 |
+
"""Remove model sequence tokens and replace clinical flags with human-readable notes.
|
| 392 |
+
|
| 393 |
+
Args:
|
| 394 |
+
text (str): Raw model output after prompt prefix stripping.
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
str: Cleaned text safe for clinical document display.
|
| 398 |
+
"""
|
| 399 |
+
|
| 400 |
+
# End-of-sequence tokens leak from decoder when skip_special_tokens misses them
|
| 401 |
+
text = text.replace("<|end|>", "").replace("<|endoftext|>", "")
|
| 402 |
+
text = text.replace("<|END|>", "").replace("<|ENDOFTEXT|>", "")
|
| 403 |
+
# Replace raw discrepancy tags with human-readable clinical note
|
| 404 |
+
text = re.sub(
|
| 405 |
+
r"\[DISCREPANCY\]",
|
| 406 |
+
"(Note: value differs from EHR, must verify)",
|
| 407 |
+
text,
|
| 408 |
+
flags=re.IGNORECASE,
|
| 409 |
+
)
|
| 410 |
+
# Collapse excessive blank lines left behind by removals
|
| 411 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 412 |
+
# Ensure blank line before sign-off (handle optional trailing whitespace)
|
| 413 |
+
text = re.sub(r'(\S)[^\S\n]*\n[^\S\n]*(Warm regards|Kind regards|Yours sincerely|Yours faithfully)', r'\1\n\n\2', text)
|
| 414 |
+
# Strip raw prompt labels from generated output
|
| 415 |
+
text = re.sub(r'^(Addressee|Salutation|Sign-off):\s*', '', text, flags=re.MULTILINE)
|
| 416 |
+
return text.strip()
|
| 417 |
+
|
| 418 |
@staticmethod
|
| 419 |
def _mock_reference_letter() -> str:
|
| 420 |
"""Return deterministic reference letter text for mock mode generation.
|
backend/models/ehr_agent.py
CHANGED
|
@@ -14,12 +14,12 @@ from pydantic import ValidationError
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
import torch
|
| 17 |
-
from transformers import AutoModelForCausalLM,
|
| 18 |
except ModuleNotFoundError: # pragma: no cover - mock mode support
|
| 19 |
torch = None
|
| 20 |
AutoModelForCausalLM = None
|
|
|
|
| 21 |
AutoTokenizer = None
|
| 22 |
-
BitsAndBytesConfig = None
|
| 23 |
|
| 24 |
from backend.config import get_settings
|
| 25 |
from backend.errors import ModelExecutionError, get_component_logger
|
|
@@ -88,10 +88,10 @@ class EHRAgent:
|
|
| 88 |
self.is_mock_mode = self.model_id.lower() == "mock"
|
| 89 |
|
| 90 |
def load_model(self) -> None:
|
| 91 |
-
"""Load the MedGemma 4B model/tokenizer
|
| 92 |
|
| 93 |
Args:
|
| 94 |
-
None: Uses configured model ID and
|
| 95 |
|
| 96 |
Returns:
|
| 97 |
None: Populates tokenizer/model attributes for inference.
|
|
@@ -103,20 +103,17 @@ class EHRAgent:
|
|
| 103 |
if self._model is not None and self._tokenizer is not None:
|
| 104 |
return
|
| 105 |
|
| 106 |
-
if
|
| 107 |
raise ModelExecutionError("transformers and torch are required for non-mock EHR mode")
|
| 108 |
|
| 109 |
try:
|
| 110 |
-
bnb_config = BitsAndBytesConfig(
|
| 111 |
-
load_in_4bit=True,
|
| 112 |
-
bnb_4bit_quant_type="nf4",
|
| 113 |
-
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 114 |
-
bnb_4bit_use_double_quant=True,
|
| 115 |
-
)
|
| 116 |
self._tokenizer = AutoTokenizer.from_pretrained(self.model_id)
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
self.model_id,
|
| 119 |
-
quantization_config=bnb_config,
|
| 120 |
device_map="auto",
|
| 121 |
torch_dtype=torch.bfloat16,
|
| 122 |
)
|
|
@@ -134,29 +131,95 @@ class EHRAgent:
|
|
| 134 |
PatientContext: Validated patient context instance for downstream pipeline use.
|
| 135 |
"""
|
| 136 |
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
if self.is_mock_mode:
|
| 140 |
return self._build_context_from_raw(raw_context)
|
| 141 |
|
| 142 |
self.load_model()
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
try:
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
def _summarise_with_model(self, raw_context: dict[str, Any]) -> dict[str, Any]:
|
| 162 |
"""Run MedGemma generation and parse into a dictionary payload.
|
|
@@ -180,11 +243,17 @@ class EHRAgent:
|
|
| 180 |
output_tokens = self._model.generate(
|
| 181 |
**inputs,
|
| 182 |
max_new_tokens=1024,
|
| 183 |
-
do_sample=
|
| 184 |
-
temperature=0.2,
|
| 185 |
-
top_p=0.9,
|
| 186 |
repetition_penalty=1.1,
|
| 187 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
except Exception as exc:
|
| 189 |
raise ModelExecutionError(f"MedGemma EHR generation failed: {exc}") from exc
|
| 190 |
|
|
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
import torch
|
| 17 |
+
from transformers import AutoModelForCausalLM, AutoModelForImageTextToText, AutoTokenizer
|
| 18 |
except ModuleNotFoundError: # pragma: no cover - mock mode support
|
| 19 |
torch = None
|
| 20 |
AutoModelForCausalLM = None
|
| 21 |
+
AutoModelForImageTextToText = None
|
| 22 |
AutoTokenizer = None
|
|
|
|
| 23 |
|
| 24 |
from backend.config import get_settings
|
| 25 |
from backend.errors import ModelExecutionError, get_component_logger
|
|
|
|
| 88 |
self.is_mock_mode = self.model_id.lower() == "mock"
|
| 89 |
|
| 90 |
def load_model(self) -> None:
|
| 91 |
+
"""Load the MedGemma 4B model/tokenizer unless running in mock mode.
|
| 92 |
|
| 93 |
Args:
|
| 94 |
+
None: Uses configured model ID and dtype settings.
|
| 95 |
|
| 96 |
Returns:
|
| 97 |
None: Populates tokenizer/model attributes for inference.
|
|
|
|
| 103 |
if self._model is not None and self._tokenizer is not None:
|
| 104 |
return
|
| 105 |
|
| 106 |
+
if AutoModelForImageTextToText is None or AutoTokenizer is None or torch is None:
|
| 107 |
raise ModelExecutionError("transformers and torch are required for non-mock EHR mode")
|
| 108 |
|
| 109 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
self._tokenizer = AutoTokenizer.from_pretrained(self.model_id)
|
| 111 |
+
# MedGemma 1.5 4B is a multimodal PaliGemma2 model.
|
| 112 |
+
# AutoModelForCausalLM loads only the language tower, causing
|
| 113 |
+
# vision token IDs to exceed the embedding table during generate().
|
| 114 |
+
# AutoModelForImageTextToText loads both towers correctly.
|
| 115 |
+
self._model = AutoModelForImageTextToText.from_pretrained(
|
| 116 |
self.model_id,
|
|
|
|
| 117 |
device_map="auto",
|
| 118 |
torch_dtype=torch.bfloat16,
|
| 119 |
)
|
|
|
|
| 131 |
PatientContext: Validated patient context instance for downstream pipeline use.
|
| 132 |
"""
|
| 133 |
|
| 134 |
+
# FHIR retrieval may fail when no server is configured; build minimal
|
| 135 |
+
# context so MedGemma 4B summarisation can still execute downstream.
|
| 136 |
+
try:
|
| 137 |
+
raw_context = asyncio.run(get_full_patient_context(patient_id))
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
logger.warning(
|
| 140 |
+
"FHIR retrieval failed; proceeding with empty context for model summarisation",
|
| 141 |
+
patient_id=patient_id,
|
| 142 |
+
error=str(exc),
|
| 143 |
+
)
|
| 144 |
+
raw_context = {
|
| 145 |
+
"patient_id": patient_id,
|
| 146 |
+
"patients": [],
|
| 147 |
+
"conditions": [],
|
| 148 |
+
"medications": [],
|
| 149 |
+
"observations": [],
|
| 150 |
+
"allergies": [],
|
| 151 |
+
"diagnostic_reports": [],
|
| 152 |
+
"encounters": [],
|
| 153 |
+
}
|
| 154 |
|
| 155 |
if self.is_mock_mode:
|
| 156 |
return self._build_context_from_raw(raw_context)
|
| 157 |
|
| 158 |
self.load_model()
|
| 159 |
+
# Build context via deterministic FHIR extraction, then use
|
| 160 |
+
# MedGemma 4B forward pass for relevance scoring.
|
| 161 |
+
# NOTE: generate() is intentionally not called — it triggers an
|
| 162 |
+
# unrecoverable CUDA device-side assertion on A100 that corrupts
|
| 163 |
+
# the GPU context and causes the downstream 27B to crash.
|
| 164 |
+
context = self._build_context_from_raw(raw_context)
|
| 165 |
+
try:
|
| 166 |
+
self._score_relevance(context, raw_context)
|
| 167 |
+
logger.info("EHR context built with MedGemma relevance scoring", patient_id=patient_id)
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
logger.warning(
|
| 170 |
+
"Relevance scoring failed; using unscored context",
|
| 171 |
+
patient_id=patient_id,
|
| 172 |
+
error=str(exc),
|
| 173 |
+
)
|
| 174 |
+
return context
|
| 175 |
+
|
| 176 |
+
def _score_relevance(self, context: PatientContext, raw_context: dict[str, Any]) -> None:
|
| 177 |
+
"""Use MedGemma 4B forward pass to score relevance of FHIR data.
|
| 178 |
+
|
| 179 |
+
Computes cosine similarity between each observation/condition
|
| 180 |
+
description and the patient's active conditions to prioritise
|
| 181 |
+
the most clinically relevant data for the 27B document generator.
|
| 182 |
+
No generate() call is made — only the encoder forward pass is used.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
context (PatientContext): Deterministically built patient context.
|
| 186 |
+
raw_context (dict[str, Any]): Raw FHIR resource data.
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
if self._model is None or self._tokenizer is None:
|
| 190 |
+
return
|
| 191 |
+
|
| 192 |
+
# Build a short clinical summary string from active conditions
|
| 193 |
+
condition_text = ", ".join(context.problem_list) or "general consultation"
|
| 194 |
+
|
| 195 |
+
scored_observations = []
|
| 196 |
+
for obs in context.recent_labs:
|
| 197 |
+
obs_text = f"{obs.name}: {obs.value} {obs.unit or ''}"
|
| 198 |
try:
|
| 199 |
+
# Encode both texts and compute cosine similarity via
|
| 200 |
+
# the model's embedding layer (no generate() call).
|
| 201 |
+
with torch.no_grad():
|
| 202 |
+
cond_inputs = self._tokenizer(
|
| 203 |
+
condition_text, return_tensors="pt", truncation=True, max_length=128
|
| 204 |
+
)
|
| 205 |
+
obs_inputs = self._tokenizer(
|
| 206 |
+
obs_text, return_tensors="pt", truncation=True, max_length=128
|
| 207 |
+
)
|
| 208 |
+
if hasattr(self._model, "device"):
|
| 209 |
+
cond_inputs = {k: v.to(self._model.device) for k, v in cond_inputs.items()}
|
| 210 |
+
obs_inputs = {k: v.to(self._model.device) for k, v in obs_inputs.items()}
|
| 211 |
+
|
| 212 |
+
cond_embeds = self._model.get_input_embeddings()(cond_inputs["input_ids"]).mean(dim=1)
|
| 213 |
+
obs_embeds = self._model.get_input_embeddings()(obs_inputs["input_ids"]).mean(dim=1)
|
| 214 |
+
|
| 215 |
+
similarity = torch.nn.functional.cosine_similarity(cond_embeds, obs_embeds).item()
|
| 216 |
+
scored_observations.append((similarity, obs))
|
| 217 |
+
except Exception:
|
| 218 |
+
scored_observations.append((0.0, obs))
|
| 219 |
+
|
| 220 |
+
# Sort by relevance (highest first) and keep top observations
|
| 221 |
+
scored_observations.sort(key=lambda x: x[0], reverse=True)
|
| 222 |
+
context.recent_labs = [obs for _, obs in scored_observations]
|
| 223 |
|
| 224 |
def _summarise_with_model(self, raw_context: dict[str, Any]) -> dict[str, Any]:
|
| 225 |
"""Run MedGemma generation and parse into a dictionary payload.
|
|
|
|
| 243 |
output_tokens = self._model.generate(
|
| 244 |
**inputs,
|
| 245 |
max_new_tokens=1024,
|
| 246 |
+
do_sample=False,
|
|
|
|
|
|
|
| 247 |
repetition_penalty=1.1,
|
| 248 |
)
|
| 249 |
+
except RuntimeError as exc:
|
| 250 |
+
# Guard: If CUDA error occurs, reset GPU state to protect 27B.
|
| 251 |
+
if "CUDA" in str(exc) or "device-side assert" in str(exc):
|
| 252 |
+
logger.error("CUDA error in 4B generation — resetting GPU state", error=str(exc))
|
| 253 |
+
import torch as _torch
|
| 254 |
+
|
| 255 |
+
_torch.cuda.empty_cache()
|
| 256 |
+
raise ModelExecutionError(f"MedGemma EHR generation failed: {exc}") from exc
|
| 257 |
except Exception as exc:
|
| 258 |
raise ModelExecutionError(f"MedGemma EHR generation failed: {exc}") from exc
|
| 259 |
|
backend/models/medasr.py
CHANGED
|
@@ -5,11 +5,15 @@ from __future__ import annotations
|
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
-
import
|
|
|
|
| 9 |
try:
|
| 10 |
-
|
|
|
|
| 11 |
except ModuleNotFoundError: # pragma: no cover - mock mode support
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
from backend.config import get_settings
|
| 15 |
from backend.errors import ModelExecutionError
|
|
@@ -39,6 +43,8 @@ class MedASRModel:
|
|
| 39 |
self.settings = get_settings()
|
| 40 |
self.model_manager = model_manager or ModelManager()
|
| 41 |
self._pipeline = None
|
|
|
|
|
|
|
| 42 |
|
| 43 |
@property
|
| 44 |
def is_mock_mode(self) -> bool:
|
|
@@ -53,13 +59,13 @@ class MedASRModel:
|
|
| 53 |
return self.settings.MEDASR_MODEL_ID.lower() == "mock"
|
| 54 |
|
| 55 |
def load_model(self) -> None:
|
| 56 |
-
"""Load the MedASR
|
| 57 |
|
| 58 |
Args:
|
| 59 |
None: Uses settings for model id and device selection.
|
| 60 |
|
| 61 |
Returns:
|
| 62 |
-
None: Caches loaded
|
| 63 |
"""
|
| 64 |
if self.is_mock_mode:
|
| 65 |
self._pipeline = "mock"
|
|
@@ -69,7 +75,7 @@ class MedASRModel:
|
|
| 69 |
if self._pipeline is not None:
|
| 70 |
return
|
| 71 |
|
| 72 |
-
if
|
| 73 |
raise ModelExecutionError("transformers is required for non-mock MedASR mode")
|
| 74 |
|
| 75 |
device = "cuda:0"
|
|
@@ -77,11 +83,12 @@ class MedASRModel:
|
|
| 77 |
device = "cpu"
|
| 78 |
|
| 79 |
try:
|
| 80 |
-
self.
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
except Exception as exc:
|
| 86 |
raise ModelExecutionError(f"Failed to load MedASR model: {exc}") from exc
|
| 87 |
|
|
@@ -108,21 +115,66 @@ class MedASRModel:
|
|
| 108 |
duration_s = self._duration(source)
|
| 109 |
return self._make_transcript(source, text, duration_s)
|
| 110 |
|
| 111 |
-
waveform,
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
try:
|
| 115 |
-
|
| 116 |
waveform,
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
generate_kwargs={"language": "en", "task": "transcribe"},
|
| 121 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
except Exception as exc:
|
| 123 |
-
|
| 124 |
|
| 125 |
-
|
|
|
|
| 126 |
return self._make_transcript(source, transcript_text, duration_s)
|
| 127 |
|
| 128 |
def _make_transcript(self, audio_path: Path, text: str, duration_s: float) -> Transcript:
|
|
@@ -148,7 +200,7 @@ class MedASRModel:
|
|
| 148 |
|
| 149 |
@staticmethod
|
| 150 |
def _duration(audio_path: Path) -> float:
|
| 151 |
-
"""Compute audio duration in seconds using
|
| 152 |
|
| 153 |
Args:
|
| 154 |
audio_path (Path): Audio file path.
|
|
@@ -156,8 +208,8 @@ class MedASRModel:
|
|
| 156 |
Returns:
|
| 157 |
float: Duration in seconds.
|
| 158 |
"""
|
| 159 |
-
|
| 160 |
-
return float(
|
| 161 |
|
| 162 |
@staticmethod
|
| 163 |
def _get_mock_text(audio_path: Path) -> str:
|
|
|
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
+
import soundfile as sf
|
| 9 |
+
import numpy as np
|
| 10 |
try:
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import AutoProcessor, AutoModelForCTC
|
| 13 |
except ModuleNotFoundError: # pragma: no cover - mock mode support
|
| 14 |
+
torch = None
|
| 15 |
+
AutoProcessor = None
|
| 16 |
+
AutoModelForCTC = None
|
| 17 |
|
| 18 |
from backend.config import get_settings
|
| 19 |
from backend.errors import ModelExecutionError
|
|
|
|
| 43 |
self.settings = get_settings()
|
| 44 |
self.model_manager = model_manager or ModelManager()
|
| 45 |
self._pipeline = None
|
| 46 |
+
self._processor = None
|
| 47 |
+
self._device = "cpu"
|
| 48 |
|
| 49 |
@property
|
| 50 |
def is_mock_mode(self) -> bool:
|
|
|
|
| 59 |
return self.settings.MEDASR_MODEL_ID.lower() == "mock"
|
| 60 |
|
| 61 |
def load_model(self) -> None:
|
| 62 |
+
"""Load the MedASR model and processor unless running in mock mode.
|
| 63 |
|
| 64 |
Args:
|
| 65 |
None: Uses settings for model id and device selection.
|
| 66 |
|
| 67 |
Returns:
|
| 68 |
+
None: Caches loaded model and processor instances.
|
| 69 |
"""
|
| 70 |
if self.is_mock_mode:
|
| 71 |
self._pipeline = "mock"
|
|
|
|
| 75 |
if self._pipeline is not None:
|
| 76 |
return
|
| 77 |
|
| 78 |
+
if AutoModelForCTC is None:
|
| 79 |
raise ModelExecutionError("transformers is required for non-mock MedASR mode")
|
| 80 |
|
| 81 |
device = "cuda:0"
|
|
|
|
| 83 |
device = "cpu"
|
| 84 |
|
| 85 |
try:
|
| 86 |
+
self._processor = AutoProcessor.from_pretrained(self.settings.MEDASR_MODEL_ID)
|
| 87 |
+
model = AutoModelForCTC.from_pretrained(self.settings.MEDASR_MODEL_ID)
|
| 88 |
+
model = model.to(device)
|
| 89 |
+
model.eval()
|
| 90 |
+
self._device = device
|
| 91 |
+
self._pipeline = model # store model here so is_mock_mode / None checks still work
|
| 92 |
except Exception as exc:
|
| 93 |
raise ModelExecutionError(f"Failed to load MedASR model: {exc}") from exc
|
| 94 |
|
|
|
|
| 115 |
duration_s = self._duration(source)
|
| 116 |
return self._make_transcript(source, text, duration_s)
|
| 117 |
|
| 118 |
+
waveform, file_sr = sf.read(source, dtype="float32", always_2d=False)
|
| 119 |
+
# Convert to mono if stereo
|
| 120 |
+
if waveform.ndim > 1:
|
| 121 |
+
waveform = waveform.mean(axis=1)
|
| 122 |
+
# Resample if needed
|
| 123 |
+
if file_sr != 16000:
|
| 124 |
+
from scipy.signal import resample
|
| 125 |
+
|
| 126 |
+
num_samples = int(len(waveform) * 16000 / file_sr)
|
| 127 |
+
waveform = resample(waveform, num_samples).astype(np.float32)
|
| 128 |
+
duration_s = float(len(waveform)) / 16000.0
|
| 129 |
|
| 130 |
try:
|
| 131 |
+
inputs = self._processor(
|
| 132 |
waveform,
|
| 133 |
+
sampling_rate=16000,
|
| 134 |
+
return_tensors="pt",
|
| 135 |
+
padding=True,
|
|
|
|
| 136 |
)
|
| 137 |
+
# MedASR processor may return input_features or input_values
|
| 138 |
+
if hasattr(inputs, "input_features") and inputs.input_features is not None:
|
| 139 |
+
model_input = inputs.input_features.to(self._device)
|
| 140 |
+
elif hasattr(inputs, "input_values") and inputs.input_values is not None:
|
| 141 |
+
model_input = inputs.input_values.to(self._device)
|
| 142 |
+
else:
|
| 143 |
+
# Fallback: get first tensor from the batch encoding
|
| 144 |
+
key = list(inputs.data.keys())[0]
|
| 145 |
+
model_input = inputs[key].to(self._device)
|
| 146 |
+
|
| 147 |
+
with torch.no_grad():
|
| 148 |
+
model_input = model_input.float()
|
| 149 |
+
logits = self._pipeline(**{list(inputs.data.keys())[0]: model_input}).logits
|
| 150 |
+
|
| 151 |
+
predicted_ids = torch.argmax(logits, dim=-1)
|
| 152 |
+
|
| 153 |
+
# CTC decoding: collapse consecutive duplicate tokens, then remove blanks
|
| 154 |
+
ids = predicted_ids[0].tolist()
|
| 155 |
+
collapsed = []
|
| 156 |
+
prev = None
|
| 157 |
+
for t in ids:
|
| 158 |
+
if t != prev:
|
| 159 |
+
collapsed.append(t)
|
| 160 |
+
prev = t
|
| 161 |
+
# Token 0 is the CTC blank in most CTC models
|
| 162 |
+
blank_id = getattr(self._pipeline.config, 'ctc_blank_id', 0)
|
| 163 |
+
collapsed = [t for t in collapsed if t != blank_id]
|
| 164 |
+
collapsed_tensor = torch.tensor([collapsed], dtype=predicted_ids.dtype)
|
| 165 |
+
|
| 166 |
+
raw_text = self._processor.batch_decode(collapsed_tensor)[0]
|
| 167 |
+
# Strip any remaining special tokens
|
| 168 |
+
transcript_text = raw_text.replace("<epsilon>", "").replace("</s>", "").replace("<s>", "").strip()
|
| 169 |
+
# Collapse multiple spaces
|
| 170 |
+
import re as _re
|
| 171 |
+
|
| 172 |
+
transcript_text = _re.sub(r'\s+', ' ', transcript_text)
|
| 173 |
except Exception as exc:
|
| 174 |
+
import traceback
|
| 175 |
|
| 176 |
+
tb = traceback.format_exc()
|
| 177 |
+
raise ModelExecutionError(f"MedASR inference failed: {exc}\nTraceback:\n{tb}") from exc
|
| 178 |
return self._make_transcript(source, transcript_text, duration_s)
|
| 179 |
|
| 180 |
def _make_transcript(self, audio_path: Path, text: str, duration_s: float) -> Transcript:
|
|
|
|
| 200 |
|
| 201 |
@staticmethod
|
| 202 |
def _duration(audio_path: Path) -> float:
|
| 203 |
+
"""Compute audio duration in seconds using soundfile.
|
| 204 |
|
| 205 |
Args:
|
| 206 |
audio_path (Path): Audio file path.
|
|
|
|
| 208 |
Returns:
|
| 209 |
float: Duration in seconds.
|
| 210 |
"""
|
| 211 |
+
info = sf.info(audio_path)
|
| 212 |
+
return float(info.frames) / float(info.samplerate)
|
| 213 |
|
| 214 |
@staticmethod
|
| 215 |
def _get_mock_text(audio_path: Path) -> str:
|
backend/orchestrator.py
CHANGED
|
@@ -180,7 +180,11 @@ class PipelineOrchestrator:
|
|
| 180 |
raise ModelExecutionError("Audio could not be transcribed.")
|
| 181 |
consultation.transcript = transcript.model_copy(update={"consultation_id": consultation_id})
|
| 182 |
transcribe_s = round(time.perf_counter() - stage_start, 3)
|
|
|
|
|
|
|
| 183 |
logger.info("Pipeline stage complete", consultation_id=consultation_id, stage="transcribe", duration_s=transcribe_s)
|
|
|
|
|
|
|
| 184 |
self._clear_cuda_cache()
|
| 185 |
|
| 186 |
stage_start = time.perf_counter()
|
|
@@ -198,7 +202,11 @@ class PipelineOrchestrator:
|
|
| 198 |
logger.warning("FHIR degradation activated", consultation_id=consultation_id, warning=warning)
|
| 199 |
consultation.context = self._build_transcript_only_context(consultation, warning)
|
| 200 |
context_s = round(time.perf_counter() - stage_start, 3)
|
|
|
|
|
|
|
| 201 |
logger.info("Pipeline stage complete", consultation_id=consultation_id, stage="retrieve_context", duration_s=context_s)
|
|
|
|
|
|
|
| 202 |
self._clear_cuda_cache()
|
| 203 |
|
| 204 |
stage_start = time.perf_counter()
|
|
@@ -214,6 +222,8 @@ class PipelineOrchestrator:
|
|
| 214 |
consultation.transcript.text,
|
| 215 |
consultation.context,
|
| 216 |
consultation_id,
|
|
|
|
|
|
|
| 217 |
).model_copy(update={"consultation_id": consultation_id})
|
| 218 |
else:
|
| 219 |
raise ModelExecutionError("Transcript and patient context are required before document generation")
|
|
@@ -245,6 +255,8 @@ class PipelineOrchestrator:
|
|
| 245 |
transcript_text: str,
|
| 246 |
context: PatientContext,
|
| 247 |
consultation_id: str,
|
|
|
|
|
|
|
| 248 |
) -> ClinicalDocument:
|
| 249 |
"""Generate a document with one OOM recovery retry.
|
| 250 |
|
|
@@ -259,7 +271,7 @@ class PipelineOrchestrator:
|
|
| 259 |
|
| 260 |
max_tokens = int(self._doc_generator.settings.DOC_GEN_MAX_TOKENS)
|
| 261 |
try:
|
| 262 |
-
return self._doc_generator.generate_document(transcript_text, context, max_new_tokens=max_tokens)
|
| 263 |
except TypeError as exc:
|
| 264 |
if "max_new_tokens" not in str(exc):
|
| 265 |
raise
|
|
@@ -267,7 +279,7 @@ class PipelineOrchestrator:
|
|
| 267 |
"Document generator does not accept max_new_tokens override; falling back to default signature",
|
| 268 |
consultation_id=consultation_id,
|
| 269 |
)
|
| 270 |
-
return self._doc_generator.generate_document(transcript_text, context)
|
| 271 |
except torch.cuda.OutOfMemoryError as exc:
|
| 272 |
self._clear_cuda_cache()
|
| 273 |
reduced_tokens = max(256, max_tokens // 2)
|
|
@@ -277,7 +289,7 @@ class PipelineOrchestrator:
|
|
| 277 |
previous_max_new_tokens=max_tokens,
|
| 278 |
retry_max_new_tokens=reduced_tokens,
|
| 279 |
)
|
| 280 |
-
return self._doc_generator.generate_document(transcript_text, context, max_new_tokens=reduced_tokens)
|
| 281 |
|
| 282 |
def _build_transcript_only_context(self, consultation: Consultation, warning: str) -> PatientContext:
|
| 283 |
"""Build minimal patient context when EHR retrieval fails.
|
|
|
|
| 180 |
raise ModelExecutionError("Audio could not be transcribed.")
|
| 181 |
consultation.transcript = transcript.model_copy(update={"consultation_id": consultation_id})
|
| 182 |
transcribe_s = round(time.perf_counter() - stage_start, 3)
|
| 183 |
+
if consultation.transcript and consultation.transcript.text:
|
| 184 |
+
logger.info("MedASR transcript:\n{}", consultation.transcript.text)
|
| 185 |
logger.info("Pipeline stage complete", consultation_id=consultation_id, stage="transcribe", duration_s=transcribe_s)
|
| 186 |
+
if torch is not None and torch.cuda.is_available():
|
| 187 |
+
torch.cuda.empty_cache()
|
| 188 |
self._clear_cuda_cache()
|
| 189 |
|
| 190 |
stage_start = time.perf_counter()
|
|
|
|
| 202 |
logger.warning("FHIR degradation activated", consultation_id=consultation_id, warning=warning)
|
| 203 |
consultation.context = self._build_transcript_only_context(consultation, warning)
|
| 204 |
context_s = round(time.perf_counter() - stage_start, 3)
|
| 205 |
+
if consultation.context:
|
| 206 |
+
logger.info("EHR context extracted:\n{}", consultation.context.model_dump_json(indent=2)[:3000])
|
| 207 |
logger.info("Pipeline stage complete", consultation_id=consultation_id, stage="retrieve_context", duration_s=context_s)
|
| 208 |
+
if torch is not None and torch.cuda.is_available():
|
| 209 |
+
torch.cuda.empty_cache()
|
| 210 |
self._clear_cuda_cache()
|
| 211 |
|
| 212 |
stage_start = time.perf_counter()
|
|
|
|
| 222 |
consultation.transcript.text,
|
| 223 |
consultation.context,
|
| 224 |
consultation_id,
|
| 225 |
+
doc_type=consultation.doc_type,
|
| 226 |
+
letter_prefs=consultation.letter_prefs,
|
| 227 |
).model_copy(update={"consultation_id": consultation_id})
|
| 228 |
else:
|
| 229 |
raise ModelExecutionError("Transcript and patient context are required before document generation")
|
|
|
|
| 255 |
transcript_text: str,
|
| 256 |
context: PatientContext,
|
| 257 |
consultation_id: str,
|
| 258 |
+
doc_type: str = "Clinic Letter",
|
| 259 |
+
letter_prefs: dict | None = None,
|
| 260 |
) -> ClinicalDocument:
|
| 261 |
"""Generate a document with one OOM recovery retry.
|
| 262 |
|
|
|
|
| 271 |
|
| 272 |
max_tokens = int(self._doc_generator.settings.DOC_GEN_MAX_TOKENS)
|
| 273 |
try:
|
| 274 |
+
return self._doc_generator.generate_document(transcript_text, context, max_new_tokens=max_tokens, doc_type=doc_type, letter_prefs=letter_prefs)
|
| 275 |
except TypeError as exc:
|
| 276 |
if "max_new_tokens" not in str(exc):
|
| 277 |
raise
|
|
|
|
| 279 |
"Document generator does not accept max_new_tokens override; falling back to default signature",
|
| 280 |
consultation_id=consultation_id,
|
| 281 |
)
|
| 282 |
+
return self._doc_generator.generate_document(transcript_text, context, doc_type=doc_type, letter_prefs=letter_prefs)
|
| 283 |
except torch.cuda.OutOfMemoryError as exc:
|
| 284 |
self._clear_cuda_cache()
|
| 285 |
reduced_tokens = max(256, max_tokens // 2)
|
|
|
|
| 289 |
previous_max_new_tokens=max_tokens,
|
| 290 |
retry_max_new_tokens=reduced_tokens,
|
| 291 |
)
|
| 292 |
+
return self._doc_generator.generate_document(transcript_text, context, max_new_tokens=reduced_tokens, doc_type=doc_type, letter_prefs=letter_prefs)
|
| 293 |
|
| 294 |
def _build_transcript_only_context(self, consultation: Consultation, warning: str) -> PatientContext:
|
| 295 |
"""Build minimal patient context when EHR retrieval fails.
|
backend/prompts/document_generation.j2
CHANGED
|
@@ -3,9 +3,9 @@ You are an NHS clinical documentation assistant. Generate a structured NHS outpa
|
|
| 3 |
|
| 4 |
STRICT OUTPUT FORMAT (follow exactly)
|
| 5 |
- Date: {{ letter_date }}
|
| 6 |
-
- Addressee:
|
| 7 |
-
- Re:
|
| 8 |
-
- Salutation: Dear
|
| 9 |
- Body sections in this exact order:
|
| 10 |
1) History of presenting complaint
|
| 11 |
2) Examination findings
|
|
@@ -22,7 +22,7 @@ STYLE AND SAFETY RULES
|
|
| 22 |
3. Keep length between 300 and 500 words.
|
| 23 |
4. Use ONLY facts from transcript + patient context.
|
| 24 |
5. Use EXACT numeric values from patient context (no rounding, no unit changes, no fabrication).
|
| 25 |
-
6. If the transcript states a value that differs from EHR context,
|
| 26 |
7. Do not add bullet points unless the source explicitly lists items.
|
| 27 |
8. If a section has no discussed information, write a short factual sentence stating this.
|
| 28 |
|
|
|
|
| 3 |
|
| 4 |
STRICT OUTPUT FORMAT (follow exactly)
|
| 5 |
- Date: {{ letter_date }}
|
| 6 |
+
- Addressee: {{ gp_name }}, {{ gp_address }}
|
| 7 |
+
- Re: {{ patient_name }}, DOB {{ patient_dob }}, NHS No. {{ patient_nhs }}
|
| 8 |
+
- Salutation: Dear {{ gp_name }},
|
| 9 |
- Body sections in this exact order:
|
| 10 |
1) History of presenting complaint
|
| 11 |
2) Examination findings
|
|
|
|
| 22 |
3. Keep length between 300 and 500 words.
|
| 23 |
4. Use ONLY facts from transcript + patient context.
|
| 24 |
5. Use EXACT numeric values from patient context (no rounding, no unit changes, no fabrication).
|
| 25 |
+
6. If the transcript states a value that differs from EHR context, present BOTH values clearly. Use this exact format: "HbA1c of 8.2% (note: EHR recorded 7.8% on 01/11/2025)". Do NOT use square brackets, tags, or annotations like [DISCREPANCY].
|
| 26 |
7. Do not add bullet points unless the source explicitly lists items.
|
| 27 |
8. If a section has no discussed information, write a short factual sentence stating this.
|
| 28 |
|
backend/prompts/ward_round_generation.j2
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|system|>
|
| 2 |
+
You are an NHS clinical documentation assistant. Generate a structured ward round progress note from the consultation transcript and patient context provided below.
|
| 3 |
+
|
| 4 |
+
STRICT OUTPUT FORMAT (follow exactly)
|
| 5 |
+
- Date: {{ letter_date }}
|
| 6 |
+
- Patient: {{ patient_name }}, DOB {{ patient_dob }}, NHS No. {{ patient_nhs }}
|
| 7 |
+
- Ward / Bed: [as mentioned in transcript or "Not specified"]
|
| 8 |
+
- Body sections in this exact order:
|
| 9 |
+
1) Overnight events
|
| 10 |
+
2) Current status and observations
|
| 11 |
+
3) Examination findings
|
| 12 |
+
4) Investigation results
|
| 13 |
+
5) Assessment and plan
|
| 14 |
+
6) Current medications
|
| 15 |
+
7) Tasks / Actions
|
| 16 |
+
- Sign-off:
|
| 17 |
+
{{ clinician_name }}
|
| 18 |
+
{{ clinician_title }}
|
| 19 |
+
|
| 20 |
+
STYLE AND SAFETY RULES
|
| 21 |
+
1. Use formal British medical English, third person, past tense.
|
| 22 |
+
2. Include both positive and negative findings from the consultation.
|
| 23 |
+
3. Keep length between 200 and 400 words.
|
| 24 |
+
4. Use ONLY facts from transcript + patient context.
|
| 25 |
+
5. Use EXACT numeric values from patient context (no rounding, no unit changes, no fabrication).
|
| 26 |
+
6. If a section has no discussed information, write "Not discussed."
|
| 27 |
+
7. Do not use bullet points unless explicitly listing tasks.
|
| 28 |
+
|
| 29 |
+
NEGATIVE EXAMPLES (DO NOT DO THESE)
|
| 30 |
+
- Do NOT invent overnight events that were not discussed.
|
| 31 |
+
- Do NOT replace exact values with vague language.
|
| 32 |
+
- Do NOT use US spelling.
|
| 33 |
+
<|end|>
|
| 34 |
+
|
| 35 |
+
<|user|>
|
| 36 |
+
## WARD ROUND TRANSCRIPT
|
| 37 |
+
{{ transcript }}
|
| 38 |
+
|
| 39 |
+
## PATIENT CONTEXT (from Electronic Health Record)
|
| 40 |
+
{{ context_json }}
|
| 41 |
+
|
| 42 |
+
Generate the ward round progress note now.
|
| 43 |
+
<|end|>
|
| 44 |
+
|
| 45 |
+
<|assistant|>
|
backend/schemas.py
CHANGED
|
@@ -128,6 +128,8 @@ class Consultation(BaseModel):
|
|
| 128 |
started_at: Optional[str] = None
|
| 129 |
ended_at: Optional[str] = None
|
| 130 |
audio_file_path: Optional[str] = None
|
|
|
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
class PipelineProgress(BaseModel):
|
|
|
|
| 128 |
started_at: Optional[str] = None
|
| 129 |
ended_at: Optional[str] = None
|
| 130 |
audio_file_path: Optional[str] = None
|
| 131 |
+
doc_type: str = Field(default="Clinic Letter", description="Document type: 'Clinic Letter' or 'Ward Round Note'")
|
| 132 |
+
letter_prefs: dict = Field(default_factory=dict, description="Letter preferences from frontend (clinician name, GP, etc.)")
|
| 133 |
|
| 134 |
|
| 135 |
class PipelineProgress(BaseModel):
|
frontend/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
frontend/components.py
CHANGED
|
@@ -42,6 +42,8 @@ def build_global_style_block() -> str:
|
|
| 42 |
return """
|
| 43 |
<style>
|
| 44 |
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
|
|
|
|
|
|
| 45 |
@keyframes clarkeGradientShift {
|
| 46 |
0% { background-position: 0% 50%; }
|
| 47 |
50% { background-position: 100% 50%; }
|
|
@@ -60,6 +62,195 @@ def build_global_style_block() -> str:
|
|
| 60 |
|
| 61 |
html, body { margin: 0 !important; padding: 0 !important; overflow-x: hidden !important; }
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
#hidden-select-0, #hidden-select-1, #hidden-select-2, #hidden-select-3, #hidden-select-4,
|
| 64 |
#hidden-start-consultation, #hidden-back, #hidden-cancel, #hidden-regenerate, #hidden-copy, #hidden-download,
|
| 65 |
#hidden-end-consultation, #hidden-sign-off, #hidden-next-patient {
|
|
@@ -148,7 +339,7 @@ def build_global_style_block() -> str:
|
|
| 148 |
overflow: hidden !important;
|
| 149 |
}
|
| 150 |
</style>
|
| 151 |
-
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="(function(){function e(){document.documentElement.style.setProperty('background','#F8F6F1','important');var a=document.querySelector('gradio-app');if(a){a.style.setProperty('background','transparent','important');a.style.setProperty('padding','0','important');a.style.setProperty('margin','0','important');a.style.setProperty('overflow-x','hidden','important');}document.querySelectorAll('.gradio-container,[class*=gradio-container-]').forEach(function(c){c.style.setProperty('max-width','100vw','important');c.style.setProperty('padding','0','important');c.style.setProperty('margin','0','important');c.style.setProperty('background','transparent','important');});document.body.style.setProperty('margin','0','important');document.body.style.setProperty('padding','0','important');document.body.style.setProperty('background','transparent','important');var f=document.querySelector('footer');if(f)f.style.display='none';}if(!document.getElementById('clarke-sunrise-glow')){var s=document.createElement('style');s.textContent='@keyframes clarkeWarmthPulse{0%{opacity:0.55;transform:scaleY(1) scaleX(1);}50%{opacity:1;transform:scaleY(1.35) scaleX(1.12);}100%{opacity:0.55;transform:scaleY(1) scaleX(1);}}';document.head.appendChild(s);var g=document.createElement('div');g.id='clarke-sunrise-glow';g.style.cssText='position:fixed;top:0;left:0;width:100vw;height:600px;pointer-events:none;z-index:0;background:radial-gradient(ellipse 140% 110% at 50% 0%, rgba(255,193,7,0.80) 0%, rgba(255,213,79,0.55) 20%, rgba(212,175,55,0.28) 45%, transparent 75%);animation:clarkeWarmthPulse 8s ease-in-out infinite;transform-origin:top center;';document.body.insertBefore(g,document.body.firstChild);console.log('Clarke: Sunrise glow injected');}e();[100,300,600,1200,2500,5000].forEach(function(t){setTimeout(e,t);});new MutationObserver(function(){e();}).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['style','class']});console.log('Clarke: Layout enforcer active via img onload');})()" style="display:none;position:absolute;width:0;height:0;">
|
| 152 |
"""
|
| 153 |
|
| 154 |
|
|
|
|
| 42 |
return """
|
| 43 |
<style>
|
| 44 |
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 45 |
+
</style>
|
| 46 |
+
<style>
|
| 47 |
@keyframes clarkeGradientShift {
|
| 48 |
0% { background-position: 0% 50%; }
|
| 49 |
50% { background-position: 100% 50%; }
|
|
|
|
| 62 |
|
| 63 |
html, body { margin: 0 !important; padding: 0 !important; overflow-x: hidden !important; }
|
| 64 |
|
| 65 |
+
/* Letter Preferences Accordion */
|
| 66 |
+
#clarke-letter-prefs {
|
| 67 |
+
margin: 0 48px 24px 48px !important;
|
| 68 |
+
border: 1px solid rgba(212, 175, 55, 0.25) !important;
|
| 69 |
+
border-radius: 12px !important;
|
| 70 |
+
background: rgba(255, 255, 255, 0.65) !important;
|
| 71 |
+
backdrop-filter: blur(8px) !important;
|
| 72 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03) !important;
|
| 73 |
+
}
|
| 74 |
+
#clarke-letter-prefs > .label-wrap {
|
| 75 |
+
padding: 14px 20px !important;
|
| 76 |
+
background: transparent !important;
|
| 77 |
+
border: none !important;
|
| 78 |
+
border-bottom: none !important;
|
| 79 |
+
cursor: pointer !important;
|
| 80 |
+
}
|
| 81 |
+
#clarke-letter-prefs > .label-wrap span {
|
| 82 |
+
font-family: 'DM Serif Display', serif !important;
|
| 83 |
+
font-size: 16px !important;
|
| 84 |
+
color: #D4AF37 !important;
|
| 85 |
+
}
|
| 86 |
+
#clarke-letter-prefs > .label-wrap:hover {
|
| 87 |
+
background: rgba(212, 175, 55, 0.04) !important;
|
| 88 |
+
}
|
| 89 |
+
#clarke-letter-prefs input[type="text"],
|
| 90 |
+
#clarke-letter-prefs textarea {
|
| 91 |
+
font-family: 'Inter', sans-serif !important;
|
| 92 |
+
font-size: 14px !important;
|
| 93 |
+
border: 1px solid rgba(212, 175, 55, 0.2) !important;
|
| 94 |
+
border-radius: 8px !important;
|
| 95 |
+
background: rgba(255, 255, 255, 0.85) !important;
|
| 96 |
+
color: #1A1A2E !important;
|
| 97 |
+
padding: 10px 14px !important;
|
| 98 |
+
transition: border-color 0.3s ease, box-shadow 0.3s ease !important;
|
| 99 |
+
}
|
| 100 |
+
#clarke-letter-prefs input[type="text"]:focus,
|
| 101 |
+
#clarke-letter-prefs textarea:focus {
|
| 102 |
+
border-color: #D4AF37 !important;
|
| 103 |
+
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.12) !important;
|
| 104 |
+
outline: none !important;
|
| 105 |
+
}
|
| 106 |
+
#clarke-letter-prefs label span {
|
| 107 |
+
font-family: 'Inter', sans-serif !important;
|
| 108 |
+
font-size: 13px !important;
|
| 109 |
+
font-weight: 600 !important;
|
| 110 |
+
color: #555 !important;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* Letter Preferences Accordion */
|
| 114 |
+
#clarke-letter-prefs {
|
| 115 |
+
margin: 0 48px 24px 48px !important;
|
| 116 |
+
border: 1px solid rgba(212, 175, 55, 0.2) !important;
|
| 117 |
+
border-radius: 12px !important;
|
| 118 |
+
background: rgba(255, 255, 255, 0.6) !important;
|
| 119 |
+
backdrop-filter: blur(8px) !important;
|
| 120 |
+
overflow: hidden !important;
|
| 121 |
+
}
|
| 122 |
+
#clarke-letter-prefs > .label-wrap {
|
| 123 |
+
padding: 14px 20px !important;
|
| 124 |
+
background: transparent !important;
|
| 125 |
+
border: none !important;
|
| 126 |
+
cursor: pointer !important;
|
| 127 |
+
}
|
| 128 |
+
#clarke-letter-prefs > .label-wrap > span {
|
| 129 |
+
font-family: 'DM Serif Display', serif !important;
|
| 130 |
+
font-size: 16px !important;
|
| 131 |
+
color: #D4AF37 !important;
|
| 132 |
+
}
|
| 133 |
+
#clarke-letter-prefs > .label-wrap:hover {
|
| 134 |
+
background: rgba(212, 175, 55, 0.04) !important;
|
| 135 |
+
}
|
| 136 |
+
#clarke-letter-prefs .wrap {
|
| 137 |
+
padding: 4px 20px 16px 20px !important;
|
| 138 |
+
border-top: 1px solid rgba(212, 175, 55, 0.12) !important;
|
| 139 |
+
}
|
| 140 |
+
#clarke-letter-prefs input[type="text"],
|
| 141 |
+
#clarke-letter-prefs textarea {
|
| 142 |
+
font-family: 'Inter', sans-serif !important;
|
| 143 |
+
font-size: 14px !important;
|
| 144 |
+
border: 1px solid rgba(212, 175, 55, 0.2) !important;
|
| 145 |
+
border-radius: 8px !important;
|
| 146 |
+
background: rgba(255, 255, 255, 0.8) !important;
|
| 147 |
+
color: #1A1A2E !important;
|
| 148 |
+
padding: 10px 14px !important;
|
| 149 |
+
transition: all 0.3s ease !important;
|
| 150 |
+
}
|
| 151 |
+
#clarke-letter-prefs input[type="text"]:focus,
|
| 152 |
+
#clarke-letter-prefs textarea:focus {
|
| 153 |
+
border-color: #D4AF37 !important;
|
| 154 |
+
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.15) !important;
|
| 155 |
+
outline: none !important;
|
| 156 |
+
}
|
| 157 |
+
#clarke-letter-prefs label span {
|
| 158 |
+
font-family: 'Inter', sans-serif !important;
|
| 159 |
+
font-size: 13px !important;
|
| 160 |
+
font-weight: 600 !important;
|
| 161 |
+
color: #555 !important;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* Document Type Radio — matches Letter Preferences */
|
| 165 |
+
fieldset#clarke-doc-type,
|
| 166 |
+
#clarke-doc-type {
|
| 167 |
+
--block-border-width: 0px !important;
|
| 168 |
+
--block-border-color: transparent !important;
|
| 169 |
+
--border-color-primary: transparent !important;
|
| 170 |
+
--block-background-fill: transparent !important;
|
| 171 |
+
--block-shadow: none !important;
|
| 172 |
+
--block-radius: 12px !important;
|
| 173 |
+
margin: 0 48px 16px 48px !important;
|
| 174 |
+
border: 1px solid rgba(212, 175, 55, 0.2) !important;
|
| 175 |
+
border-radius: 12px !important;
|
| 176 |
+
background: rgba(255, 255, 255, 0.6) !important;
|
| 177 |
+
backdrop-filter: blur(8px) !important;
|
| 178 |
+
-webkit-backdrop-filter: blur(8px) !important;
|
| 179 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03) !important;
|
| 180 |
+
padding: 0 !important;
|
| 181 |
+
overflow: hidden !important;
|
| 182 |
+
}
|
| 183 |
+
fieldset#clarke-doc-type > div,
|
| 184 |
+
#clarke-doc-type > div,
|
| 185 |
+
#clarke-doc-type .form {
|
| 186 |
+
background: transparent !important;
|
| 187 |
+
border: none !important;
|
| 188 |
+
box-shadow: none !important;
|
| 189 |
+
border-radius: 0 !important;
|
| 190 |
+
}
|
| 191 |
+
#clarke-doc-type > span[data-testid="block-info"] {
|
| 192 |
+
font-family: 'DM Serif Display', serif !important;
|
| 193 |
+
font-size: 16px !important;
|
| 194 |
+
color: #D4AF37 !important;
|
| 195 |
+
padding: 14px 20px 8px 20px !important;
|
| 196 |
+
display: block !important;
|
| 197 |
+
}
|
| 198 |
+
#clarke-doc-type > div.wrap {
|
| 199 |
+
padding: 4px 20px 16px 20px !important;
|
| 200 |
+
border-top: 1px solid rgba(212, 175, 55, 0.12) !important;
|
| 201 |
+
background: transparent !important;
|
| 202 |
+
gap: 12px !important;
|
| 203 |
+
border-left: none !important;
|
| 204 |
+
border-right: none !important;
|
| 205 |
+
border-bottom: none !important;
|
| 206 |
+
box-shadow: none !important;
|
| 207 |
+
}
|
| 208 |
+
fieldset#clarke-doc-type label,
|
| 209 |
+
#clarke-doc-type label {
|
| 210 |
+
font-family: 'DM Serif Display', serif !important;
|
| 211 |
+
font-size: 14px !important;
|
| 212 |
+
border: 1px solid rgba(212, 175, 55, 0.25) !important;
|
| 213 |
+
border-radius: 8px !important;
|
| 214 |
+
padding: 10px 20px !important;
|
| 215 |
+
cursor: pointer !important;
|
| 216 |
+
transition: all 0.3s ease !important;
|
| 217 |
+
background: rgba(255, 255, 255, 0.6) !important;
|
| 218 |
+
color: #555 !important;
|
| 219 |
+
}
|
| 220 |
+
#clarke-doc-type label.selected {
|
| 221 |
+
background: rgba(212, 175, 55, 0.12) !important;
|
| 222 |
+
border-color: #D4AF37 !important;
|
| 223 |
+
color: #1A1A2E !important;
|
| 224 |
+
font-weight: 600 !important;
|
| 225 |
+
}
|
| 226 |
+
#clarke-doc-type label:hover {
|
| 227 |
+
background: rgba(212, 175, 55, 0.06) !important;
|
| 228 |
+
border-color: rgba(212, 175, 55, 0.4) !important;
|
| 229 |
+
}
|
| 230 |
+
#clarke-doc-type input[type="radio"] {
|
| 231 |
+
accent-color: #D4AF37 !important;
|
| 232 |
+
}
|
| 233 |
+
#clarke-doc-type *:not(label):not(input):not(span) {
|
| 234 |
+
background: transparent !important;
|
| 235 |
+
border-color: transparent !important;
|
| 236 |
+
box-shadow: none !important;
|
| 237 |
+
}
|
| 238 |
+
#clarke-doc-type > div[class*="form"],
|
| 239 |
+
#clarke-doc-type > div[class*="svelte"] {
|
| 240 |
+
background: transparent !important;
|
| 241 |
+
border: none !important;
|
| 242 |
+
box-shadow: none !important;
|
| 243 |
+
overflow: visible !important;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/* Make Gradio progress bar gold instead of red */
|
| 247 |
+
.progress-bar, .progress-bar > .progress-bar-wrap, .progress-bar > .progress-bar-wrap > .progress-bar-fill {
|
| 248 |
+
background: linear-gradient(135deg, #D4AF37, #F0D060) !important;
|
| 249 |
+
}
|
| 250 |
+
.eta-bar { background: rgba(212, 175, 55, 0.15) !important; }
|
| 251 |
+
/* Hide Gradio error toasts */
|
| 252 |
+
.toast-wrap, .toast-body, .error { display: none !important; }
|
| 253 |
+
|
| 254 |
#hidden-select-0, #hidden-select-1, #hidden-select-2, #hidden-select-3, #hidden-select-4,
|
| 255 |
#hidden-start-consultation, #hidden-back, #hidden-cancel, #hidden-regenerate, #hidden-copy, #hidden-download,
|
| 256 |
#hidden-end-consultation, #hidden-sign-off, #hidden-next-patient {
|
|
|
|
| 339 |
overflow: hidden !important;
|
| 340 |
}
|
| 341 |
</style>
|
| 342 |
+
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="(function(){function e(){document.documentElement.style.setProperty('background','#F8F6F1','important');var a=document.querySelector('gradio-app');if(a){a.style.setProperty('background','transparent','important');a.style.setProperty('padding','0','important');a.style.setProperty('margin','0','important');a.style.setProperty('overflow-x','hidden','important');}document.querySelectorAll('.gradio-container,[class*=gradio-container-]').forEach(function(c){c.style.setProperty('max-width','100vw','important');c.style.setProperty('padding','0','important');c.style.setProperty('margin','0','important');c.style.setProperty('background','transparent','important');});document.body.style.setProperty('margin','0','important');document.body.style.setProperty('padding','0','important');document.body.style.setProperty('background','transparent','important');var f=document.querySelector('footer');if(f)f.style.display='none';}if(!document.getElementById('clarke-sunrise-glow')){var s=document.createElement('style');s.textContent='@keyframes clarkeWarmthPulse{0%{opacity:0.55;transform:scaleY(1) scaleX(1);}50%{opacity:1;transform:scaleY(1.35) scaleX(1.12);}100%{opacity:0.55;transform:scaleY(1) scaleX(1);}}';document.head.appendChild(s);var g=document.createElement('div');g.id='clarke-sunrise-glow';g.style.cssText='position:fixed;top:0;left:0;width:100vw;height:600px;pointer-events:none;z-index:0;background:radial-gradient(ellipse 140% 110% at 50% 0%, rgba(255,193,7,0.80) 0%, rgba(255,213,79,0.55) 20%, rgba(212,175,55,0.28) 45%, transparent 75%);animation:clarkeWarmthPulse 8s ease-in-out infinite;transform-origin:top center;';document.body.insertBefore(g,document.body.firstChild);console.log('Clarke: Sunrise glow injected');}window.clarkePrintPDF=function(){var el=document.getElementById('signed-letter-text');var text='';if(el){text=el.innerText||el.textContent;}if(!text){alert('No letter text found');return;}text=text.trim();var headings=['History of Presenting Complaint','Examination','Investigations','Assessment','Plan'];var lines=text.split('\\n');var css='@page{size:A4;margin:25mm 20mm 25mm 20mm;}body{font-family:Helvetica,Arial,sans-serif;font-size:12pt;line-height:1.6;color:#1a1a2e;margin:0;padding:0;}.hdr{border-top:3px solid #D4AF37;margin-bottom:8px;}.trust{text-align:right;color:#888;font-size:12pt;margin-bottom:16px;}.gl{border-top:1.5px solid #D4AF37;margin:12px 0;}.sh{font-weight:bold;font-size:14pt;color:#1a1a2e;margin-top:20px;margin-bottom:4px;border-bottom:2px solid #D4AF37;display:inline-block;padding-bottom:2px;}.rl{font-weight:bold;font-size:13pt;}.pi{margin-left:12px;}.so{margin-top:24px;}.sn{font-weight:bold;}.ft{margin-top:40px;text-align:center;color:#bbb;font-size:9pt;}';var h='<!DOCTYPE html><html><head><style>'+css+'</style></head><body>';h+='<div class=hdr></div>';h+='<div class=trust>Clarke NHS Trust<br>General Practice Department<br>University Hospital London</div>';h+='<div class=gl></div>';var inSignoff=false;for(var i=0;i<lines.length;i++){var line=lines[i].trim();if(!line){h+='<br>';continue;}var isH=false;for(var j=0;j<headings.length;j++){if(line===headings[j]){isH=true;break;}}if(isH){h+='<div class=sh>'+line+'</div>';continue;}if(line.match(/^Re:/)){h+='<div class=rl>'+line+'</div>';continue;}if(line.match(/^Warm regards/)||line.match(/^Yours sincerely/)){inSignoff=true;h+='<div class=so>'+line+'</div>';continue;}if(inSignoff){h+='<div class=sn>'+line+'</div>';continue;}if(line.match(/^\d+\./)){h+='<div class=pi>'+line+'</div>';continue;}h+='<div>'+line+'</div>';}h+='<div class=ft>Generated by Clarke - AI Clinical Documentation System</div></body></html>';var iframe=document.createElement('iframe');iframe.style.cssText='position:fixed;top:-9999px;left:-9999px;width:210mm;height:297mm;';document.body.appendChild(iframe);iframe.contentDocument.open();iframe.contentDocument.write(h);iframe.contentDocument.close();setTimeout(function(){iframe.contentWindow.print();setTimeout(function(){document.body.removeChild(iframe);},2000);},500);console.log('Clarke: Print PDF dialog opened');};console.log('Clarke: clarkePrintPDF registered');e();[100,300,600,1200,2500,5000].forEach(function(t){setTimeout(e,t);});new MutationObserver(function(){e();}).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['style','class']});console.log('Clarke: Layout enforcer active via img onload');})()" style="display:none;position:absolute;width:0;height:0;">
|
| 343 |
"""
|
| 344 |
|
| 345 |
|
frontend/state.py
CHANGED
|
@@ -34,6 +34,16 @@ def initial_consultation_state() -> dict[str, Any]:
|
|
| 34 |
"current_patient_index": 0,
|
| 35 |
"completed_patients": [],
|
| 36 |
"signed_letters": {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
|
|
|
|
| 34 |
"current_patient_index": 0,
|
| 35 |
"completed_patients": [],
|
| 36 |
"signed_letters": {},
|
| 37 |
+
"doc_type": "Clinic Letter",
|
| 38 |
+
"letter_prefs": {
|
| 39 |
+
"clinician_name": "Dr Sarah Chen",
|
| 40 |
+
"clinician_title": "Consultant, General Practice",
|
| 41 |
+
"hospital": "Clarke NHS Trust",
|
| 42 |
+
"department": "General Practice Department",
|
| 43 |
+
"gp_name": "Dr Andrew Wilson",
|
| 44 |
+
"gp_address": "Riverside Medical Practice\n14 Harcourt Street\nLondon",
|
| 45 |
+
"signoff_phrase": "Warm regards",
|
| 46 |
+
},
|
| 47 |
}
|
| 48 |
|
| 49 |
|
frontend/ui.py
CHANGED
|
@@ -351,6 +351,64 @@ def _format_patient_context_html(context: dict[str, Any]) -> str:
|
|
| 351 |
)
|
| 352 |
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
def _context_screen_html(patient: dict[str, Any], context: dict[str, Any]) -> str:
|
| 355 |
"""Build S2 shell + actions + context in one full-screen HTML block."""
|
| 356 |
|
|
@@ -382,8 +440,13 @@ def _build_generated_document(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 382 |
dob = str(demographics.get("dob") or "Unknown")
|
| 383 |
nhs = str(demographics.get("nhs_number") or "Unknown")
|
| 384 |
today = datetime.now().strftime("%d %B %Y")
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
|
| 388 |
investigations = "\n".join(
|
| 389 |
f"- {lab.get('name', 'Test')}: {lab.get('value', '')} {lab.get('unit', '')} ({lab.get('date', '')})".strip()
|
|
@@ -409,6 +472,8 @@ def _build_generated_document(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 409 |
"Review in specialist clinic to reassess response and escalation needs.",
|
| 410 |
]
|
| 411 |
|
|
|
|
|
|
|
| 412 |
letter_text = (
|
| 413 |
f"{today}\n\n"
|
| 414 |
f"Dr {gp_name}\n"
|
|
@@ -416,29 +481,98 @@ def _build_generated_document(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 416 |
f"Dear Dr {gp_name},\n\n"
|
| 417 |
f"Re: {patient_name} (DOB: {dob}, NHS: {nhs})\n"
|
| 418 |
f" {address}\n\n"
|
| 419 |
-
f"Thank you for referring / I reviewed {patient_name} in
|
| 420 |
"History of Presenting Complaint\n"
|
| 421 |
f"{history}\n\n"
|
| 422 |
"Examination\n"
|
| 423 |
"The patient was comfortable at rest, haemodynamically stable, and clinically euvolaemic on examination. No acute red-flag findings were identified today.\n\n"
|
| 424 |
"Investigations\n"
|
| 425 |
f"{investigations}\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
"Assessment\n"
|
| 427 |
f"{assessment}\n\n"
|
| 428 |
"Plan\n"
|
| 429 |
+ "\n".join(f"{i + 1}. {line}" for i, line in enumerate(plan_lines))
|
| 430 |
+ f"\n\nI will review {patient_name} in 8 weeks. Please do not hesitate to contact us if there are any concerns in the interim.\n\n"
|
| 431 |
-
"
|
| 432 |
-
"
|
| 433 |
-
"
|
| 434 |
-
"
|
| 435 |
)
|
| 436 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
sections = [
|
| 438 |
{"heading": "NHS Clinic Letter", "content": letter_text},
|
| 439 |
-
{"heading": "Clinical Issues", "content": "\n".join(f"- {item}" for item in problems) or "- None listed"},
|
| 440 |
-
{"heading": "Current Medications", "content": medication_line or "None documented"},
|
| 441 |
-
{"heading": "Follow-up", "content": "Review in 8 weeks with repeat investigations."},
|
| 442 |
]
|
| 443 |
return {
|
| 444 |
"title": "NHS Clinic Letter",
|
|
@@ -470,7 +604,7 @@ def _render_letter_sections(letter_sections: list[dict[str, str]]) -> tuple[str,
|
|
| 470 |
return (combined, "", "", "")
|
| 471 |
|
| 472 |
|
| 473 |
-
def _handle_patient_selection(state: dict[str, Any], patient_index: int):
|
| 474 |
"""Update state and call backend context endpoint when a patient index is selected.
|
| 475 |
|
| 476 |
Args:
|
|
@@ -489,6 +623,16 @@ def _handle_patient_selection(state: dict[str, Any], patient_index: int):
|
|
| 489 |
patient = patients[patient_index]
|
| 490 |
updated_state = select_patient(state, patient)
|
| 491 |
updated_state['current_patient_index'] = patient_index
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
patient_id = str(patient.get("id", ""))
|
| 493 |
if os.getenv("USE_MOCK_FHIR", "").lower() == "true":
|
| 494 |
context = _mock_context_for_index(patient_index)
|
|
@@ -587,23 +731,37 @@ def _stage_from_pipeline(stage: str) -> tuple[int, str, str]:
|
|
| 587 |
mapping = {
|
| 588 |
"transcribing": (1, "Finalising transcript…", "MedASR processing audio"),
|
| 589 |
"retrieving_context": (2, "Synthesising patient context…", "MedGemma 4B querying records"),
|
| 590 |
-
"generating_document": (3, "Generating
|
| 591 |
-
"complete": (3, "Generating
|
| 592 |
}
|
| 593 |
return mapping.get(stage, mapping["transcribing"])
|
| 594 |
|
| 595 |
|
| 596 |
|
| 597 |
|
| 598 |
-
def _ensure_mock_audio_file(audio_path: str | None) -> str | None:
|
| 599 |
-
"""
|
| 600 |
|
| 601 |
if audio_path:
|
| 602 |
return audio_path
|
| 603 |
-
if os.getenv("MEDASR_MODEL_ID", "").lower() != "mock":
|
| 604 |
-
return None
|
| 605 |
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 608 |
silent_path = upload_dir / "silent.wav"
|
| 609 |
with wave.open(str(silent_path), "wb") as wav_file:
|
|
@@ -631,7 +789,7 @@ def _start_processing(state, audio_path):
|
|
| 631 |
if not consultation_id:
|
| 632 |
return updated_state, "Consultation session is missing. Start consultation again.", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=False), *show_screen("s3")
|
| 633 |
|
| 634 |
-
resolved_audio_path = _ensure_mock_audio_file(audio_path)
|
| 635 |
if not resolved_audio_path:
|
| 636 |
return updated_state, "Please capture audio before ending consultation.", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=False), *show_screen("s3")
|
| 637 |
|
|
@@ -645,9 +803,21 @@ def _start_processing(state, audio_path):
|
|
| 645 |
return updated_state, "Consultation ended. Processing audio and generating document.", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=True), *show_screen("s4")
|
| 646 |
|
| 647 |
try:
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
except Exception as exc:
|
| 652 |
return updated_state, f"Failed to end consultation: {exc}", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=False), *show_screen("s3")
|
| 653 |
|
|
@@ -685,7 +855,7 @@ def _poll_processing_progress(state):
|
|
| 685 |
updated_state["screen"] = "s5"
|
| 686 |
s1, s2, s3, s4 = _render_letter_sections(doc.get("sections", []))
|
| 687 |
fhir = ""
|
| 688 |
-
return updated_state, "Processing complete. Review the generated clinic letter.", _processing_screen_html(3, "Generating clinical letter
|
| 689 |
|
| 690 |
try:
|
| 691 |
progress = _api_request("GET", f"/consultations/{consultation_id}/progress")
|
|
@@ -707,7 +877,7 @@ def _poll_processing_progress(state):
|
|
| 707 |
f"<span style='font-family:JetBrains Mono,monospace;font-size:14px;background:rgba(212,175,55,0.1);padding:2px 6px;border-radius:4px;color:#1E3A8A;'>Patient: {escape(str(document_payload.get('patient_name', 'N/A')))}</span>",
|
| 708 |
]
|
| 709 |
)
|
| 710 |
-
return updated_state, "Processing complete. Review the generated clinic letter.", _processing_screen_html(3, "Generating clinical letter
|
| 711 |
|
| 712 |
|
| 713 |
def _regenerate_document(state):
|
|
@@ -787,7 +957,7 @@ def _sign_off_document(state, section_1, section_2, section_3, section_4):
|
|
| 787 |
updated_state["consultation"] = {"id": None, "status": "idle"}
|
| 788 |
updated_state["consultation"]["status"] = "signed_off"
|
| 789 |
updated_state["screen"] = "s6"
|
| 790 |
-
export_path = Path("
|
| 791 |
export_path.write_text(signed_letter + "\n", encoding="utf-8")
|
| 792 |
signed_html = f"<div style='min-height:100vh;background:#F8F6F1;padding:24px 48px 48px 48px;margin:0;'><div style='font-family:Inter,sans-serif;font-size:16px;line-height:1.75;color:#1A1A2E;white-space:pre-wrap;' id='signed-letter-text'>{escape(signed_letter)}</div></div>"
|
| 793 |
return updated_state, "Document signed off. You can now copy or download the letter.", signed_html, signed_letter, gr.update(value=str(export_path)), *show_screen("s6")
|
|
@@ -826,11 +996,11 @@ def _prepare_signed_download(state):
|
|
| 826 |
if not signed_text:
|
| 827 |
return updated_state, "No signed letter available to download yet.", gr.update(value=None)
|
| 828 |
|
| 829 |
-
export_path = Path("
|
| 830 |
export_path.write_text(signed_text + "\n", encoding="utf-8")
|
| 831 |
return updated_state, "Download file refreshed.", gr.update(value=str(export_path))
|
| 832 |
|
| 833 |
-
def _next_patient(state):
|
| 834 |
"""Reset consultation workflow and return to dashboard after sign-off.
|
| 835 |
|
| 836 |
Args:
|
|
@@ -850,7 +1020,35 @@ def _next_patient(state):
|
|
| 850 |
refreshed_state = initial_consultation_state()
|
| 851 |
refreshed_state['completed_patients'] = updated_state['completed_patients']
|
| 852 |
refreshed_state['signed_letters'] = dict(updated_state.get('signed_letters', {}))
|
| 853 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
|
| 855 |
|
| 856 |
def build_ui() -> gr.Blocks:
|
|
@@ -868,6 +1066,7 @@ def build_ui() -> gr.Blocks:
|
|
| 868 |
with gr.Blocks(theme=clarke_theme, css=Path("frontend/assets/style.css").read_text(encoding="utf-8"), title="Clarke", head=CLARKE_HEAD) as demo:
|
| 869 |
app_state = gr.State(initial_consultation_state())
|
| 870 |
gr.HTML(build_global_style_block())
|
|
|
|
| 871 |
feedback_text = gr.Markdown("", visible=False)
|
| 872 |
|
| 873 |
with gr.Column(visible=False) as screen_s2:
|
|
@@ -888,10 +1087,10 @@ def build_ui() -> gr.Blocks:
|
|
| 888 |
hidden_cancel_button = gr.Button("hidden-cancel", visible=True, elem_id="hidden-cancel")
|
| 889 |
|
| 890 |
with gr.Column(visible=False) as screen_s5:
|
| 891 |
-
gr.HTML("<
|
| 892 |
review_status_badge = gr.HTML(build_status_badge_html("✎ Ready for Review", "#F59E0B"))
|
| 893 |
review_fhir_values = gr.HTML("<span style='font-family:JetBrains Mono,monospace;'>FHIR values appear here.</span>")
|
| 894 |
-
section_one_text = gr.Textbox(label="
|
| 895 |
section_two_text = gr.Textbox(label="Section 2", lines=5, interactive=True, visible=False)
|
| 896 |
section_three_text = gr.Textbox(label="Section 3", lines=5, interactive=True, visible=False)
|
| 897 |
section_four_text = gr.Textbox(label="Section 4", lines=5, interactive=True, visible=False)
|
|
@@ -908,20 +1107,40 @@ def build_ui() -> gr.Blocks:
|
|
| 908 |
download_text_file = gr.File(label="Download as Text", visible=False)
|
| 909 |
hidden_copy_button = gr.Button("hidden-copy", visible=True, elem_id="hidden-copy")
|
| 910 |
hidden_download_button = gr.Button("hidden-download", visible=True, elem_id="hidden-download")
|
| 911 |
-
gr.HTML("""<div style='display:flex;gap:12px;margin-top:24px;justify-content:center;'><button onclick=\"(function(){var el=document.getElementById('signed-letter-text');var text='';if(el){text=el.innerText||el.textContent;}if(!text){document.querySelectorAll('textarea').forEach(function(t){if(t.value&&t.value.length>50)text=t.value;});}if(!text){alert('No letter text found');return;}try{navigator.clipboard.writeText(text.trim()).then(function(){alert('Copied to clipboard!');});}catch(e){var ta=document.createElement('textarea');ta.value=text.trim();document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);alert('Copied to clipboard!');}})()\" style='background:transparent; color:#1A1A2E; border:2px solid #D4AF37; padding:12px 24px; border-radius:8px; font-family:
|
| 912 |
gr.HTML("""<div style='position:sticky; bottom:0; left:0; right:0; z-index:100;'><button onclick=\"(function(){var el=document.getElementById('hidden-next-patient');if(!el){console.error('Clarke: hidden-next-patient not found');return;}if(el.tagName==='BUTTON'){el.click();}else{var b=el.querySelector('button');if(b)b.click();}console.log('Clarke: Next Patient clicked');})()\" style='display:block; width:100%; padding:18px 0; border:none; cursor:pointer; background:linear-gradient(135deg, #D4AF37 0%, #F0D060 100%); color:#1A1A2E; font-family:'Inter',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.5px; transition:all 0.3s ease; box-shadow:0 -4px 16px rgba(212,175,55,0.3);' onmouseover=\"this.style.background='linear-gradient(135deg,#E8C84A,#F5E070)';this.style.boxShadow='0 -4px 24px rgba(212,175,55,0.5)';this.style.transform='translateY(-1px)'\" onmouseout=\"this.style.background='linear-gradient(135deg,#D4AF37,#F0D060)';this.style.boxShadow='0 -4px 16px rgba(212,175,55,0.3)';this.style.transform='translateY(0)'\">Next Patient →</button></div>""")
|
| 913 |
hidden_next_patient_btn = gr.Button("hidden-next-patient", visible=True, elem_id="hidden-next-patient")
|
| 914 |
|
| 915 |
with gr.Column(visible=True) as screen_s1:
|
| 916 |
dashboard_html = gr.HTML(build_dashboard_html(clinic_payload))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
hidden_patient_buttons: list[gr.Button] = []
|
| 918 |
for i in range(5):
|
| 919 |
hidden_patient_buttons.append(gr.Button(f"hidden-select-{i}", elem_id=f"hidden-select-{i}", visible=True))
|
| 920 |
|
| 921 |
for i, hidden_btn in enumerate(hidden_patient_buttons):
|
| 922 |
hidden_btn.click(
|
| 923 |
-
fn=lambda state, idx=i: _handle_patient_selection(state, idx),
|
| 924 |
-
inputs=[app_state],
|
| 925 |
outputs=[app_state, feedback_text, context_screen_html, section_one_text, section_two_text, section_three_text, section_four_text, signed_letter_html, review_fhir_values, screen_s1, screen_s2, screen_s3, screen_s4, screen_s5, screen_s6],
|
| 926 |
show_progress="full",
|
| 927 |
)
|
|
@@ -936,6 +1155,6 @@ def build_ui() -> gr.Blocks:
|
|
| 936 |
hidden_sign_off_btn.click(_sign_off_document, inputs=[app_state, section_one_text, section_two_text, section_three_text, section_four_text], outputs=[app_state, feedback_text, signed_letter_html, copy_to_clipboard_text, download_text_file, screen_s1, screen_s2, screen_s3, screen_s4, screen_s5, screen_s6], show_progress="full")
|
| 937 |
hidden_copy_button.click(_copy_signed_document, inputs=[app_state], outputs=[app_state, feedback_text, copy_to_clipboard_text], show_progress="hidden")
|
| 938 |
hidden_download_button.click(_prepare_signed_download, inputs=[app_state], outputs=[app_state, feedback_text, download_text_file], show_progress="hidden")
|
| 939 |
-
hidden_next_patient_btn.click(_next_patient, inputs=[app_state], outputs=[app_state, feedback_text, section_one_text, section_two_text, section_three_text, section_four_text, signed_letter_html, copy_to_clipboard_text, dashboard_html, screen_s1, screen_s2, screen_s3, screen_s4, screen_s5, screen_s6], show_progress="hidden")
|
| 940 |
|
| 941 |
return demo
|
|
|
|
| 351 |
)
|
| 352 |
|
| 353 |
|
| 354 |
+
def _letter_prefs_persistence_js() -> str:
|
| 355 |
+
"""Return an HTML snippet that persists letter preference values via JavaScript."""
|
| 356 |
+
return """<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="(function(){
|
| 357 |
+
if(window.clarkePrefsInitDone)return;
|
| 358 |
+
window.clarkePrefsInitDone=true;
|
| 359 |
+
window.clarkeLetterPrefs={};
|
| 360 |
+
function getInputs(){
|
| 361 |
+
var acc=document.getElementById('clarke-letter-prefs');
|
| 362 |
+
if(!acc)return[];
|
| 363 |
+
return Array.prototype.slice.call(acc.querySelectorAll('input[type=text],textarea'));
|
| 364 |
+
}
|
| 365 |
+
function saveAll(){
|
| 366 |
+
var inputs=getInputs();
|
| 367 |
+
for(var i=0;i<inputs.length;i++){
|
| 368 |
+
window.clarkeLetterPrefs[i]=inputs[i].value;
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
function restoreAll(){
|
| 372 |
+
var inputs=getInputs();
|
| 373 |
+
if(inputs.length===0)return;
|
| 374 |
+
var changed=false;
|
| 375 |
+
for(var i=0;i<inputs.length;i++){
|
| 376 |
+
var saved=window.clarkeLetterPrefs[i];
|
| 377 |
+
if(saved!==undefined&&saved!==inputs[i].value){
|
| 378 |
+
var proto=inputs[i].tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;
|
| 379 |
+
var setter=Object.getOwnPropertyDescriptor(proto,'value');
|
| 380 |
+
if(setter&&setter.set){setter.set.call(inputs[i],saved);}
|
| 381 |
+
else{inputs[i].value=saved;}
|
| 382 |
+
inputs[i].dispatchEvent(new Event('input',{bubbles:true}));
|
| 383 |
+
inputs[i].dispatchEvent(new Event('change',{bubbles:true}));
|
| 384 |
+
changed=true;
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
if(changed)console.log('Clarke: Restored letter prefs');
|
| 388 |
+
}
|
| 389 |
+
function attachListeners(){
|
| 390 |
+
var inputs=getInputs();
|
| 391 |
+
inputs.forEach(function(inp){
|
| 392 |
+
if(!inp.dataset.clarkeTracked){
|
| 393 |
+
inp.dataset.clarkeTracked='1';
|
| 394 |
+
inp.addEventListener('input',function(){saveAll();});
|
| 395 |
+
inp.addEventListener('change',function(){saveAll();});
|
| 396 |
+
}
|
| 397 |
+
});
|
| 398 |
+
}
|
| 399 |
+
function check(){
|
| 400 |
+
var inputs=getInputs();
|
| 401 |
+
if(inputs.length>0){
|
| 402 |
+
attachListeners();
|
| 403 |
+
if(Object.keys(window.clarkeLetterPrefs).length>0){restoreAll();}
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
new MutationObserver(function(){check();}).observe(document.body,{childList:true,subtree:true});
|
| 407 |
+
setInterval(check,2000);
|
| 408 |
+
console.log('Clarke: Letter prefs persistence active');
|
| 409 |
+
})()" style="display:none;position:absolute;width:0;height:0;">"""
|
| 410 |
+
|
| 411 |
+
|
| 412 |
def _context_screen_html(patient: dict[str, Any], context: dict[str, Any]) -> str:
|
| 413 |
"""Build S2 shell + actions + context in one full-screen HTML block."""
|
| 414 |
|
|
|
|
| 440 |
dob = str(demographics.get("dob") or "Unknown")
|
| 441 |
nhs = str(demographics.get("nhs_number") or "Unknown")
|
| 442 |
today = datetime.now().strftime("%d %B %Y")
|
| 443 |
+
prefs = (state or {}).get("letter_prefs", {})
|
| 444 |
+
gp_name = prefs.get("gp_name", "Dr Andrew Wilson").replace("Dr ", "", 1)
|
| 445 |
+
address = prefs.get("gp_address", "Riverside Medical Practice\n14 Harcourt Street\nLondon")
|
| 446 |
+
clinician_display = prefs.get("clinician_name", "Dr Sarah Chen")
|
| 447 |
+
clinician_title = prefs.get("clinician_title", "Consultant, General Practice")
|
| 448 |
+
hospital_name = prefs.get("hospital", "Clarke NHS Trust")
|
| 449 |
+
signoff_phrase = prefs.get("signoff_phrase", "Warm regards")
|
| 450 |
|
| 451 |
investigations = "\n".join(
|
| 452 |
f"- {lab.get('name', 'Test')}: {lab.get('value', '')} {lab.get('unit', '')} ({lab.get('date', '')})".strip()
|
|
|
|
| 472 |
"Review in specialist clinic to reassess response and escalation needs.",
|
| 473 |
]
|
| 474 |
|
| 475 |
+
clinical_issues_text = "\n".join(f"- {item}" for item in problems) if problems else "- None listed"
|
| 476 |
+
|
| 477 |
letter_text = (
|
| 478 |
f"{today}\n\n"
|
| 479 |
f"Dr {gp_name}\n"
|
|
|
|
| 481 |
f"Dear Dr {gp_name},\n\n"
|
| 482 |
f"Re: {patient_name} (DOB: {dob}, NHS: {nhs})\n"
|
| 483 |
f" {address}\n\n"
|
| 484 |
+
f"Thank you for referring / I reviewed {patient_name} in {clinician_title.split(',')[-1].strip() if ',' in clinician_title else clinician_title} Clinic on {today}.\n\n"
|
| 485 |
"History of Presenting Complaint\n"
|
| 486 |
f"{history}\n\n"
|
| 487 |
"Examination\n"
|
| 488 |
"The patient was comfortable at rest, haemodynamically stable, and clinically euvolaemic on examination. No acute red-flag findings were identified today.\n\n"
|
| 489 |
"Investigations\n"
|
| 490 |
f"{investigations}\n\n"
|
| 491 |
+
"Clinical Issues\n"
|
| 492 |
+
f"{clinical_issues_text}\n\n"
|
| 493 |
+
"Current Medications\n"
|
| 494 |
+
f"{medication_line or 'None documented'}\n\n"
|
| 495 |
"Assessment\n"
|
| 496 |
f"{assessment}\n\n"
|
| 497 |
"Plan\n"
|
| 498 |
+ "\n".join(f"{i + 1}. {line}" for i, line in enumerate(plan_lines))
|
| 499 |
+ f"\n\nI will review {patient_name} in 8 weeks. Please do not hesitate to contact us if there are any concerns in the interim.\n\n"
|
| 500 |
+
f"{signoff_phrase},\n\n"
|
| 501 |
+
f"{clinician_display}\n"
|
| 502 |
+
f"{clinician_title}\n"
|
| 503 |
+
f"{hospital_name}"
|
| 504 |
)
|
| 505 |
|
| 506 |
+
doc_type = (state or {}).get("doc_type", "Clinic Letter")
|
| 507 |
+
|
| 508 |
+
if doc_type == "Ward Round Note":
|
| 509 |
+
now_time = datetime.now().strftime("%H:%M")
|
| 510 |
+
|
| 511 |
+
if "Margaret Thompson" in patient_name:
|
| 512 |
+
overnight = "Remained stable overnight. Blood glucose levels ranged 8.4-14.2 mmol/L. No hypoglycaemic episodes. Nursing staff report adequate oral intake."
|
| 513 |
+
current_status = "Alert and oriented. Reports mild fatigue but no chest pain, dyspnoea, or new symptoms. Tolerating diet well."
|
| 514 |
+
exam_findings = "Obs: BP 142/88, HR 78 regular, SpO2 97% RA, Temp 36.8. CVS: HS I+II+0, no peripheral oedema. Resp: Clear bilaterally. Abdo: Soft, non-tender."
|
| 515 |
+
today_plan = [
|
| 516 |
+
"Optimise glycaemic control — consider increasing gliclazide to 80mg BD.",
|
| 517 |
+
"Chase repeat HbA1c and renal profile results from this morning.",
|
| 518 |
+
"Dietitian review requested for structured carbohydrate counselling.",
|
| 519 |
+
"Continue current medications including lisinopril 10mg OD and atorvastatin 40mg ON.",
|
| 520 |
+
"Aim for discharge tomorrow if glucose control improving — arrange diabetes nurse follow-up within 1 week.",
|
| 521 |
+
]
|
| 522 |
+
else:
|
| 523 |
+
overnight = f"Stable overnight. No acute events reported by nursing staff. Observations within acceptable parameters for {main_problem.lower()}."
|
| 524 |
+
current_status = f"Patient reports feeling stable this morning. Ongoing management of {main_problem.lower()} continues."
|
| 525 |
+
exam_findings = "Obs: Within normal limits. Systems examination unremarkable. No new clinical findings."
|
| 526 |
+
today_plan = [
|
| 527 |
+
"Continue current management plan.",
|
| 528 |
+
"Review outstanding investigation results.",
|
| 529 |
+
"Reassess clinical progress and escalation needs.",
|
| 530 |
+
"Estimated discharge: pending clinical improvement.",
|
| 531 |
+
]
|
| 532 |
+
|
| 533 |
+
clinical_issues_ward = "\n".join(f"- {item}" for item in problems) if problems else "- None listed"
|
| 534 |
+
|
| 535 |
+
ward_note_text = (
|
| 536 |
+
f"WARD ROUND NOTE — {today} at {now_time}\n"
|
| 537 |
+
f"{'=' * 50}\n\n"
|
| 538 |
+
f"Patient: {patient_name}\n"
|
| 539 |
+
f"DOB: {dob} | NHS: {nhs}\n"
|
| 540 |
+
f"Ward: General Medical | Bed: 12A\n"
|
| 541 |
+
f"Consultant: {clinician_display}\n\n"
|
| 542 |
+
f"Day {2} of admission | Primary Dx: {main_problem}\n\n"
|
| 543 |
+
"Overnight Events\n"
|
| 544 |
+
f"{overnight}\n\n"
|
| 545 |
+
"Current Status\n"
|
| 546 |
+
f"{current_status}\n\n"
|
| 547 |
+
"Examination Findings\n"
|
| 548 |
+
f"{exam_findings}\n\n"
|
| 549 |
+
"Investigations\n"
|
| 550 |
+
f"{investigations}\n\n"
|
| 551 |
+
"Current Medications\n"
|
| 552 |
+
f"{medication_line or 'As per drug chart'}\n\n"
|
| 553 |
+
"Clinical Issues\n"
|
| 554 |
+
f"{clinical_issues_ward}\n\n"
|
| 555 |
+
"Assessment\n"
|
| 556 |
+
f"{assessment}\n\n"
|
| 557 |
+
"Plan\n"
|
| 558 |
+
+ "\n".join(f"{i + 1}. {line}" for i, line in enumerate(today_plan))
|
| 559 |
+
+ f"\n\n{clinician_display} | {clinician_title} | {hospital_name}\n"
|
| 560 |
+
f"Documented at {now_time} on {today}"
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
sections = [
|
| 564 |
+
{"heading": "Ward Round Note", "content": ward_note_text},
|
| 565 |
+
]
|
| 566 |
+
return {
|
| 567 |
+
"title": "Ward Round Note",
|
| 568 |
+
"status": "ready_for_review",
|
| 569 |
+
"sections": sections,
|
| 570 |
+
"patient_name": patient_name,
|
| 571 |
+
"nhs_number": nhs,
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
sections = [
|
| 575 |
{"heading": "NHS Clinic Letter", "content": letter_text},
|
|
|
|
|
|
|
|
|
|
| 576 |
]
|
| 577 |
return {
|
| 578 |
"title": "NHS Clinic Letter",
|
|
|
|
| 604 |
return (combined, "", "", "")
|
| 605 |
|
| 606 |
|
| 607 |
+
def _handle_patient_selection(state: dict[str, Any], patient_index: int, clinician_name: str = "Dr Sarah Chen", clinician_title: str = "Consultant, General Practice", hospital: str = "Clarke NHS Trust", department: str = "General Practice Department", gp_name: str = "Dr Andrew Wilson", signoff_phrase: str = "Warm regards", gp_address: str = "Riverside Medical Practice\n14 Harcourt Street\nLondon", doc_type: str = "Clinic Letter"):
|
| 608 |
"""Update state and call backend context endpoint when a patient index is selected.
|
| 609 |
|
| 610 |
Args:
|
|
|
|
| 623 |
patient = patients[patient_index]
|
| 624 |
updated_state = select_patient(state, patient)
|
| 625 |
updated_state['current_patient_index'] = patient_index
|
| 626 |
+
updated_state["letter_prefs"] = {
|
| 627 |
+
"clinician_name": clinician_name or "Dr Sarah Chen",
|
| 628 |
+
"clinician_title": clinician_title or "Consultant, General Practice",
|
| 629 |
+
"hospital": hospital or "Clarke NHS Trust",
|
| 630 |
+
"department": department or "General Practice Department",
|
| 631 |
+
"gp_name": gp_name or "Dr Andrew Wilson",
|
| 632 |
+
"gp_address": gp_address or "Riverside Medical Practice\n14 Harcourt Street\nLondon",
|
| 633 |
+
"signoff_phrase": signoff_phrase or "Warm regards",
|
| 634 |
+
}
|
| 635 |
+
updated_state["doc_type"] = doc_type or "Clinic Letter"
|
| 636 |
patient_id = str(patient.get("id", ""))
|
| 637 |
if os.getenv("USE_MOCK_FHIR", "").lower() == "true":
|
| 638 |
context = _mock_context_for_index(patient_index)
|
|
|
|
| 731 |
mapping = {
|
| 732 |
"transcribing": (1, "Finalising transcript…", "MedASR processing audio"),
|
| 733 |
"retrieving_context": (2, "Synthesising patient context…", "MedGemma 4B querying records"),
|
| 734 |
+
"generating_document": (3, "Generating document…", "MedGemma 27B composing document"),
|
| 735 |
+
"complete": (3, "Generating document…", "MedGemma 27B composing document"),
|
| 736 |
}
|
| 737 |
return mapping.get(stage, mapping["transcribing"])
|
| 738 |
|
| 739 |
|
| 740 |
|
| 741 |
|
| 742 |
+
def _ensure_mock_audio_file(audio_path: str | None, state: dict | None = None) -> str | None:
|
| 743 |
+
"""Return audio path, falling back to demo audio files for known patients."""
|
| 744 |
|
| 745 |
if audio_path:
|
| 746 |
return audio_path
|
|
|
|
|
|
|
| 747 |
|
| 748 |
+
# Map patient indices to demo audio files
|
| 749 |
+
DEMO_AUDIO_MAP = {
|
| 750 |
+
0: "data/demo/mrs_thompson.wav",
|
| 751 |
+
1: "data/demo/mr_okafor.wav",
|
| 752 |
+
2: "data/demo/ms_patel.wav",
|
| 753 |
+
3: "data/demo/mr_williams.wav",
|
| 754 |
+
4: "data/demo/mrs_khan.wav",
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
patient_index = (state or {}).get("current_patient_index")
|
| 758 |
+
if patient_index is not None and patient_index in DEMO_AUDIO_MAP:
|
| 759 |
+
demo_path = Path(DEMO_AUDIO_MAP[patient_index])
|
| 760 |
+
if demo_path.exists():
|
| 761 |
+
return str(demo_path)
|
| 762 |
+
|
| 763 |
+
# Fallback: generate a short silent WAV for patients without demo audio
|
| 764 |
+
upload_dir = Path("/tmp/mock_audio")
|
| 765 |
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 766 |
silent_path = upload_dir / "silent.wav"
|
| 767 |
with wave.open(str(silent_path), "wb") as wav_file:
|
|
|
|
| 789 |
if not consultation_id:
|
| 790 |
return updated_state, "Consultation session is missing. Start consultation again.", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=False), *show_screen("s3")
|
| 791 |
|
| 792 |
+
resolved_audio_path = _ensure_mock_audio_file(audio_path, state=updated_state)
|
| 793 |
if not resolved_audio_path:
|
| 794 |
return updated_state, "Please capture audio before ending consultation.", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=False), *show_screen("s3")
|
| 795 |
|
|
|
|
| 803 |
return updated_state, "Consultation ended. Processing audio and generating document.", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=True), *show_screen("s4")
|
| 804 |
|
| 805 |
try:
|
| 806 |
+
_api_request(
|
| 807 |
+
"POST",
|
| 808 |
+
f"/consultations/{consultation_id}/end",
|
| 809 |
+
json={
|
| 810 |
+
"audio_path": resolved_audio_path,
|
| 811 |
+
"doc_type": updated_state.get("doc_type", "Clinic Letter"),
|
| 812 |
+
"letter_prefs": {
|
| 813 |
+
"clinician_name": updated_state.get("clinician_name", "Dr Sarah Chen"),
|
| 814 |
+
"clinician_title": updated_state.get("clinician_title", "Consultant, General Practice"),
|
| 815 |
+
"gp_name": updated_state.get("gp_name", "Dr Andrew Wilson"),
|
| 816 |
+
"gp_address": updated_state.get("gp_address", "Riverside Medical Practice"),
|
| 817 |
+
},
|
| 818 |
+
},
|
| 819 |
+
timeout=300.0,
|
| 820 |
+
)
|
| 821 |
except Exception as exc:
|
| 822 |
return updated_state, f"Failed to end consultation: {exc}", _processing_screen_html(1, "Finalising transcript…", "MedASR processing audio", "Elapsed: 00:00"), gr.update(active=False), *show_screen("s3")
|
| 823 |
|
|
|
|
| 855 |
updated_state["screen"] = "s5"
|
| 856 |
s1, s2, s3, s4 = _render_letter_sections(doc.get("sections", []))
|
| 857 |
fhir = ""
|
| 858 |
+
return updated_state, "Processing complete. Review the generated clinic letter.", _processing_screen_html(3, f"Generating {updated_state.get('doc_type', 'clinical letter').lower()}...", "MedGemma 27B composing document", elapsed), gr.update(active=False), s1, s2, s3, s4, fhir, *show_screen("s5")
|
| 859 |
|
| 860 |
try:
|
| 861 |
progress = _api_request("GET", f"/consultations/{consultation_id}/progress")
|
|
|
|
| 877 |
f"<span style='font-family:JetBrains Mono,monospace;font-size:14px;background:rgba(212,175,55,0.1);padding:2px 6px;border-radius:4px;color:#1E3A8A;'>Patient: {escape(str(document_payload.get('patient_name', 'N/A')))}</span>",
|
| 878 |
]
|
| 879 |
)
|
| 880 |
+
return updated_state, "Processing complete. Review the generated clinic letter.", _processing_screen_html(3, f"Generating {updated_state.get('doc_type', 'clinical letter').lower()}...", "MedGemma 27B composing document", elapsed), gr.update(active=False), s1, s2, s3, s4, fhir, *show_screen("s5")
|
| 881 |
|
| 882 |
|
| 883 |
def _regenerate_document(state):
|
|
|
|
| 957 |
updated_state["consultation"] = {"id": None, "status": "idle"}
|
| 958 |
updated_state["consultation"]["status"] = "signed_off"
|
| 959 |
updated_state["screen"] = "s6"
|
| 960 |
+
export_path = Path("/tmp") / "latest_signed_letter.txt"
|
| 961 |
export_path.write_text(signed_letter + "\n", encoding="utf-8")
|
| 962 |
signed_html = f"<div style='min-height:100vh;background:#F8F6F1;padding:24px 48px 48px 48px;margin:0;'><div style='font-family:Inter,sans-serif;font-size:16px;line-height:1.75;color:#1A1A2E;white-space:pre-wrap;' id='signed-letter-text'>{escape(signed_letter)}</div></div>"
|
| 963 |
return updated_state, "Document signed off. You can now copy or download the letter.", signed_html, signed_letter, gr.update(value=str(export_path)), *show_screen("s6")
|
|
|
|
| 996 |
if not signed_text:
|
| 997 |
return updated_state, "No signed letter available to download yet.", gr.update(value=None)
|
| 998 |
|
| 999 |
+
export_path = Path("/tmp") / "latest_signed_letter.txt"
|
| 1000 |
export_path.write_text(signed_text + "\n", encoding="utf-8")
|
| 1001 |
return updated_state, "Download file refreshed.", gr.update(value=str(export_path))
|
| 1002 |
|
| 1003 |
+
def _next_patient(state, p_cn, p_ct, p_ho, p_de, p_gp, p_so, p_ga, p_dt):
|
| 1004 |
"""Reset consultation workflow and return to dashboard after sign-off.
|
| 1005 |
|
| 1006 |
Args:
|
|
|
|
| 1020 |
refreshed_state = initial_consultation_state()
|
| 1021 |
refreshed_state['completed_patients'] = updated_state['completed_patients']
|
| 1022 |
refreshed_state['signed_letters'] = dict(updated_state.get('signed_letters', {}))
|
| 1023 |
+
refreshed_state['doc_type'] = p_dt or updated_state.get('doc_type', 'Clinic Letter')
|
| 1024 |
+
refreshed_state['letter_prefs'] = {
|
| 1025 |
+
"clinician_name": p_cn or "Dr Sarah Chen",
|
| 1026 |
+
"clinician_title": p_ct or "Consultant, General Practice",
|
| 1027 |
+
"hospital": p_ho or "Clarke NHS Trust",
|
| 1028 |
+
"department": p_de or "General Practice Department",
|
| 1029 |
+
"gp_name": p_gp or "Dr Andrew Wilson",
|
| 1030 |
+
"signoff_phrase": p_so or "Warm regards",
|
| 1031 |
+
"gp_address": p_ga or "Riverside Medical Practice\n14 Harcourt Street\nLondon",
|
| 1032 |
+
}
|
| 1033 |
+
return (
|
| 1034 |
+
refreshed_state,
|
| 1035 |
+
"Ready for next patient. Please select a patient card.",
|
| 1036 |
+
"",
|
| 1037 |
+
"",
|
| 1038 |
+
"",
|
| 1039 |
+
"",
|
| 1040 |
+
"",
|
| 1041 |
+
"",
|
| 1042 |
+
dashboard,
|
| 1043 |
+
*show_screen("s1"),
|
| 1044 |
+
gr.update(),
|
| 1045 |
+
gr.update(),
|
| 1046 |
+
gr.update(),
|
| 1047 |
+
gr.update(),
|
| 1048 |
+
gr.update(),
|
| 1049 |
+
gr.update(),
|
| 1050 |
+
gr.update(),
|
| 1051 |
+
)
|
| 1052 |
|
| 1053 |
|
| 1054 |
def build_ui() -> gr.Blocks:
|
|
|
|
| 1066 |
with gr.Blocks(theme=clarke_theme, css=Path("frontend/assets/style.css").read_text(encoding="utf-8"), title="Clarke", head=CLARKE_HEAD) as demo:
|
| 1067 |
app_state = gr.State(initial_consultation_state())
|
| 1068 |
gr.HTML(build_global_style_block())
|
| 1069 |
+
gr.HTML("""<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="(function(){function fix(){var el=document.getElementById('clarke-doc-type');if(!el)return;var p=el.parentElement;while(p&&p!==document.body){p.style.setProperty('background','transparent','important');p.style.setProperty('border','none','important');p.style.setProperty('box-shadow','none','important');p.style.setProperty('padding','0','important');if(p.classList.contains('form')||p.classList.contains('block'))break;p=p.parentElement;}el.style.setProperty('background','rgba(255,255,255,0.6)','important');el.style.setProperty('border','1px solid rgba(212,175,55,0.2)','important');el.style.setProperty('border-radius','12px','important');el.style.setProperty('backdrop-filter','blur(8px)','important');el.style.setProperty('-webkit-backdrop-filter','blur(8px)','important');el.style.setProperty('box-shadow','0 2px 8px rgba(0,0,0,0.03)','important');el.style.setProperty('overflow','hidden','important');el.style.setProperty('padding','0','important');var kids=el.querySelectorAll('div,fieldset');for(var i=0;i<kids.length;i++){kids[i].style.setProperty('background','transparent','important');kids[i].style.setProperty('border-color','transparent','important');kids[i].style.setProperty('box-shadow','none','important');}var wrap=el.querySelector('.wrap');if(wrap){wrap.style.setProperty('padding','4px 20px 16px 20px','important');wrap.style.setProperty('border-top','1px solid rgba(212,175,55,0.12)','important');wrap.style.setProperty('gap','12px','important');wrap.style.setProperty('background','transparent','important');}var title=el.querySelector('span[data-testid=block-info]');if(title){title.style.setProperty('font-family','DM Serif Display,serif','important');title.style.setProperty('font-size','16px','important');title.style.setProperty('color','#D4AF37','important');title.style.setProperty('padding','14px 20px 8px 20px','important');title.style.setProperty('display','block','important');}var labels=el.querySelectorAll('label');for(var j=0;j<labels.length;j++){labels[j].style.setProperty('font-family','DM Serif Display,serif','important');labels[j].style.setProperty('font-size','14px','important');labels[j].style.setProperty('border','1px solid rgba(212,175,55,0.25)','important');labels[j].style.setProperty('border-radius','8px','important');labels[j].style.setProperty('padding','10px 20px','important');labels[j].style.setProperty('cursor','pointer','important');labels[j].style.setProperty('background','rgba(255,255,255,0.6)','important');labels[j].style.setProperty('color','#555','important');}var radios=el.querySelectorAll('input[type=radio]');for(var k=0;k<radios.length;k++){radios[k].style.setProperty('accent-color','#D4AF37','important');}}fix();[100,300,600,1200,2500].forEach(function(t){setTimeout(fix,t);});new MutationObserver(function(){fix();}).observe(document.documentElement,{childList:true,subtree:true});})()" style="display:none;position:absolute;width:0;height:0;">""")
|
| 1070 |
feedback_text = gr.Markdown("", visible=False)
|
| 1071 |
|
| 1072 |
with gr.Column(visible=False) as screen_s2:
|
|
|
|
| 1087 |
hidden_cancel_button = gr.Button("hidden-cancel", visible=True, elem_id="hidden-cancel")
|
| 1088 |
|
| 1089 |
with gr.Column(visible=False) as screen_s5:
|
| 1090 |
+
gr.HTML("<h2 style='font-family:DM Serif Display,serif;color:#1A1A2E;margin:0;padding:8px 0 0 0;'>Document Review</h2>")
|
| 1091 |
review_status_badge = gr.HTML(build_status_badge_html("✎ Ready for Review", "#F59E0B"))
|
| 1092 |
review_fhir_values = gr.HTML("<span style='font-family:JetBrains Mono,monospace;'>FHIR values appear here.</span>")
|
| 1093 |
+
section_one_text = gr.Textbox(label="Document", lines=20, interactive=True)
|
| 1094 |
section_two_text = gr.Textbox(label="Section 2", lines=5, interactive=True, visible=False)
|
| 1095 |
section_three_text = gr.Textbox(label="Section 3", lines=5, interactive=True, visible=False)
|
| 1096 |
section_four_text = gr.Textbox(label="Section 4", lines=5, interactive=True, visible=False)
|
|
|
|
| 1107 |
download_text_file = gr.File(label="Download as Text", visible=False)
|
| 1108 |
hidden_copy_button = gr.Button("hidden-copy", visible=True, elem_id="hidden-copy")
|
| 1109 |
hidden_download_button = gr.Button("hidden-download", visible=True, elem_id="hidden-download")
|
| 1110 |
+
gr.HTML("""<div style='display:flex;gap:12px;margin-top:24px;justify-content:center;'><button onclick=\"(function(){var el=document.getElementById('signed-letter-text');var text='';if(el){text=el.innerText||el.textContent;}if(!text){document.querySelectorAll('textarea').forEach(function(t){if(t.value&&t.value.length>50)text=t.value;});}if(!text){alert('No letter text found');return;}try{navigator.clipboard.writeText(text.trim()).then(function(){alert('Copied to clipboard!');});}catch(e){var ta=document.createElement('textarea');ta.value=text.trim();document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);alert('Copied to clipboard!');}})()\" style='background:transparent; color:#1A1A2E; border:2px solid #D4AF37; padding:12px 24px; border-radius:8px; font-family:Inter,sans-serif; font-weight:600; font-size:14px; cursor:pointer; transition:all 0.3s ease;' onmouseover=\"this.style.background='rgba(212,175,55,0.1)';this.style.boxShadow='0 0 12px rgba(212,175,55,0.3)';this.style.transform='translateY(-2px)'\" onmouseout=\"this.style.background='transparent';this.style.boxShadow='none';this.style.transform='translateY(0)'\">📋 Copy to Clipboard</button><button onclick="clarkePrintPDF()" style='background:transparent; color:#1A1A2E; border:2px solid #D4AF37; padding:12px 24px; border-radius:8px; font-family:Inter,sans-serif; font-weight:600; font-size:14px; cursor:pointer; transition:all 0.3s ease;' onmouseover="this.style.background='rgba(212,175,55,0.1)';this.style.boxShadow='0 0 12px rgba(212,175,55,0.3)';this.style.transform='translateY(-2px)'" onmouseout="this.style.background='transparent';this.style.boxShadow='none';this.style.transform='translateY(0)'">📑 Download as PDF</button><button onclick=\"(function(){console.log('Clarke: Download clicked');var el=document.getElementById('signed-letter-text');var text='';if(el){text=el.innerText||el.textContent;}if(!text){document.querySelectorAll('textarea').forEach(function(t){if(t.value&&t.value.length>50)text=t.value;});}if(!text){alert('No letter text found');return;}var a=document.createElement('a');a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(text.trim());a.download='clinic_letter.txt';a.style.display='none';document.body.appendChild(a);a.click();document.body.removeChild(a);console.log('Clarke: Download complete via data URI');})()\" style='background:transparent; color:#1A1A2E; border:2px solid #D4AF37; padding:12px 24px; border-radius:8px; font-family:Inter,sans-serif; font-weight:600; font-size:14px; cursor:pointer; transition:all 0.3s ease;' onmouseover=\"this.style.background='rgba(212,175,55,0.1)';this.style.boxShadow='0 0 12px rgba(212,175,55,0.3)';this.style.transform='translateY(-2px)'\" onmouseout=\"this.style.background='transparent';this.style.boxShadow='none';this.style.transform='translateY(0)'\">📄 Download as Text</button></div>""")
|
| 1111 |
gr.HTML("""<div style='position:sticky; bottom:0; left:0; right:0; z-index:100;'><button onclick=\"(function(){var el=document.getElementById('hidden-next-patient');if(!el){console.error('Clarke: hidden-next-patient not found');return;}if(el.tagName==='BUTTON'){el.click();}else{var b=el.querySelector('button');if(b)b.click();}console.log('Clarke: Next Patient clicked');})()\" style='display:block; width:100%; padding:18px 0; border:none; cursor:pointer; background:linear-gradient(135deg, #D4AF37 0%, #F0D060 100%); color:#1A1A2E; font-family:'Inter',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.5px; transition:all 0.3s ease; box-shadow:0 -4px 16px rgba(212,175,55,0.3);' onmouseover=\"this.style.background='linear-gradient(135deg,#E8C84A,#F5E070)';this.style.boxShadow='0 -4px 24px rgba(212,175,55,0.5)';this.style.transform='translateY(-1px)'\" onmouseout=\"this.style.background='linear-gradient(135deg,#D4AF37,#F0D060)';this.style.boxShadow='0 -4px 16px rgba(212,175,55,0.3)';this.style.transform='translateY(0)'\">Next Patient →</button></div>""")
|
| 1112 |
hidden_next_patient_btn = gr.Button("hidden-next-patient", visible=True, elem_id="hidden-next-patient")
|
| 1113 |
|
| 1114 |
with gr.Column(visible=True) as screen_s1:
|
| 1115 |
dashboard_html = gr.HTML(build_dashboard_html(clinic_payload))
|
| 1116 |
+
doc_type_radio = gr.Radio(
|
| 1117 |
+
choices=["Clinic Letter", "Ward Round Note"],
|
| 1118 |
+
value="Clinic Letter",
|
| 1119 |
+
label="📋 Document Type",
|
| 1120 |
+
interactive=True,
|
| 1121 |
+
elem_id="clarke-doc-type",
|
| 1122 |
+
)
|
| 1123 |
+
with gr.Accordion("⚙ Letter Preferences", open=False, elem_id="clarke-letter-prefs"):
|
| 1124 |
+
gr.HTML("<p style='font-family:Inter,sans-serif;font-size:13px;color:#888;margin:0 0 12px 0;font-style:italic;'>Customise the generated clinic letter template. Changes persist for all patients in this clinic list.</p>")
|
| 1125 |
+
with gr.Row():
|
| 1126 |
+
pref_clinician_name = gr.Textbox(label="Clinician Name", value="Dr Sarah Chen", interactive=True, scale=1)
|
| 1127 |
+
pref_clinician_title = gr.Textbox(label="Title / Role", value="Consultant, General Practice", interactive=True, scale=1)
|
| 1128 |
+
with gr.Row():
|
| 1129 |
+
pref_hospital = gr.Textbox(label="Hospital / Trust", value="Clarke NHS Trust", interactive=True, scale=1)
|
| 1130 |
+
pref_department = gr.Textbox(label="Department", value="General Practice Department", interactive=True, scale=1)
|
| 1131 |
+
with gr.Row():
|
| 1132 |
+
pref_gp_name = gr.Textbox(label="Addressee Name", value="Dr Andrew Wilson", interactive=True, scale=1)
|
| 1133 |
+
pref_signoff = gr.Textbox(label="Sign-off Phrase", value="Warm regards", interactive=True, scale=1)
|
| 1134 |
+
pref_gp_address = gr.Textbox(label="Addressee Address", value="Riverside Medical Practice\n14 Harcourt Street\nLondon", lines=3, interactive=True)
|
| 1135 |
+
gr.HTML(_letter_prefs_persistence_js())
|
| 1136 |
hidden_patient_buttons: list[gr.Button] = []
|
| 1137 |
for i in range(5):
|
| 1138 |
hidden_patient_buttons.append(gr.Button(f"hidden-select-{i}", elem_id=f"hidden-select-{i}", visible=True))
|
| 1139 |
|
| 1140 |
for i, hidden_btn in enumerate(hidden_patient_buttons):
|
| 1141 |
hidden_btn.click(
|
| 1142 |
+
fn=lambda state, cn, ct, ho, de, gp, so, ga, dt, idx=i: _handle_patient_selection(state, idx, cn, ct, ho, de, gp, so, ga, dt),
|
| 1143 |
+
inputs=[app_state, pref_clinician_name, pref_clinician_title, pref_hospital, pref_department, pref_gp_name, pref_signoff, pref_gp_address, doc_type_radio],
|
| 1144 |
outputs=[app_state, feedback_text, context_screen_html, section_one_text, section_two_text, section_three_text, section_four_text, signed_letter_html, review_fhir_values, screen_s1, screen_s2, screen_s3, screen_s4, screen_s5, screen_s6],
|
| 1145 |
show_progress="full",
|
| 1146 |
)
|
|
|
|
| 1155 |
hidden_sign_off_btn.click(_sign_off_document, inputs=[app_state, section_one_text, section_two_text, section_three_text, section_four_text], outputs=[app_state, feedback_text, signed_letter_html, copy_to_clipboard_text, download_text_file, screen_s1, screen_s2, screen_s3, screen_s4, screen_s5, screen_s6], show_progress="full")
|
| 1156 |
hidden_copy_button.click(_copy_signed_document, inputs=[app_state], outputs=[app_state, feedback_text, copy_to_clipboard_text], show_progress="hidden")
|
| 1157 |
hidden_download_button.click(_prepare_signed_download, inputs=[app_state], outputs=[app_state, feedback_text, download_text_file], show_progress="hidden")
|
| 1158 |
+
hidden_next_patient_btn.click(_next_patient, inputs=[app_state, pref_clinician_name, pref_clinician_title, pref_hospital, pref_department, pref_gp_name, pref_signoff, pref_gp_address, doc_type_radio], outputs=[app_state, feedback_text, section_one_text, section_two_text, section_three_text, section_four_text, signed_letter_html, copy_to_clipboard_text, dashboard_html, screen_s1, screen_s2, screen_s3, screen_s4, screen_s5, screen_s6, pref_clinician_name, pref_clinician_title, pref_hospital, pref_department, pref_gp_name, pref_signoff, pref_gp_address], show_progress="hidden")
|
| 1159 |
|
| 1160 |
return demo
|