yashvshetty commited on
Commit
874f913
·
1 Parent(s): e618ecb

Restore all features from origin/fresh-main

Browse files
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("data/uploads") / consultation_id
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.end_consultation(consultation_id)
350
- progress = orchestrator.get_progress(consultation_id)
351
  except KeyError as exc:
352
  raise HTTPException(status_code=404, detail=str(exc)) from exc
353
- except TimeoutError as exc:
354
- raise HTTPException(status_code=504, detail={"error": "timeout", "message": str(exc)}) from exc
355
- except AudioError as exc:
356
- raise HTTPException(status_code=400, detail={"error": "audio_error", "message": str(exc)}) from exc
357
- except ModelExecutionError as exc:
358
- error_type = "audio_error" if "transcribed" in str(exc).lower() else "model_error"
359
- status_code = 400 if error_type == "audio_error" else 500
360
- raise HTTPException(status_code=status_code, detail={"error": error_type, "message": str(exc)}) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
  return {
363
  "consultation_id": consultation.id,
364
- "status": consultation.status.value,
365
- "pipeline_stage": progress.stage.value,
366
- "message": "Pipeline completed. Document is ready for review.",
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
- import librosa
 
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
- waveform, sample_rate = librosa.load(path, sr=None, mono=False)
 
 
 
 
 
 
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
- temperature=0.3,
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
- return self._strip_prompt_prefix(decoded_output, prompt)
 
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
- template = env.get_template("document_generation.j2")
 
 
 
 
 
 
 
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 and line:
237
- current_lines.append(line)
 
 
 
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, AutoTokenizer, BitsAndBytesConfig
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 in 4-bit mode unless running in mock mode.
92
 
93
  Args:
94
- None: Uses configured model ID and quantisation settings.
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 AutoModelForCausalLM is None or AutoTokenizer is None or BitsAndBytesConfig is None or torch is None:
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
- self._model = AutoModelForCausalLM.from_pretrained(
 
 
 
 
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
- raw_context = asyncio.run(get_full_patient_context(patient_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  if self.is_mock_mode:
140
  return self._build_context_from_raw(raw_context)
141
 
142
  self.load_model()
143
- for attempt in range(1, 3):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  try:
145
- summarised_context = self._summarise_with_model(raw_context)
146
- return PatientContext.model_validate(summarised_context)
147
- except (ValidationError, ValueError, ModelExecutionError, json.JSONDecodeError) as exc:
148
- logger.warning(
149
- "EHR model summarisation failed; retrying or falling back",
150
- patient_id=patient_id,
151
- attempt=attempt,
152
- error=str(exc),
153
- )
154
-
155
- fallback_context = self._build_context_from_raw(raw_context)
156
- fallback_context.retrieval_warnings.append(
157
- "MedGemma summarisation unavailable; context built via deterministic extraction."
158
- )
159
- return fallback_context
 
 
 
 
 
 
 
 
 
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=True,
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 librosa
 
9
  try:
10
- from transformers import pipeline
 
11
  except ModuleNotFoundError: # pragma: no cover - mock mode support
12
- pipeline = None
 
 
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 transformer pipeline unless running in mock mode.
57
 
58
  Args:
59
  None: Uses settings for model id and device selection.
60
 
61
  Returns:
62
- None: Caches loaded pipeline instance.
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 pipeline is None:
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._pipeline = pipeline(
81
- "automatic-speech-recognition",
82
- model=self.settings.MEDASR_MODEL_ID,
83
- device=device,
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, _ = librosa.load(source, sr=16000, mono=True)
112
- duration_s = float(librosa.get_duration(y=waveform, sr=16000))
 
 
 
 
 
 
 
 
 
113
 
114
  try:
115
- result = self._pipeline(
116
  waveform,
117
- chunk_length_s=20,
118
- stride_length_s=(4, 2),
119
- return_timestamps=True,
120
- generate_kwargs={"language": "en", "task": "transcribe"},
121
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  except Exception as exc:
123
- raise ModelExecutionError(f"MedASR inference failed: {exc}") from exc
124
 
125
- transcript_text = str(result.get("text", "")).strip()
 
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 librosa.
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
- waveform, sample_rate = librosa.load(audio_path, sr=16000, mono=True)
160
- return float(librosa.get_duration(y=waveform, sr=sample_rate))
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: GP name and practice address from the patient record
7
- - Re: Full patient name, DOB, NHS number
8
- - Salutation: Dear Dr [GP surname],
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, include the EHR value and mark the mismatch as [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
 
 
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
- gp_name = "Andrew Wilson"
386
- address = "Riverside Medical Practice\n14 Harcourt Street\nLondon"
 
 
 
 
 
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 General Practice Clinic on {today}.\n\n"
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
- "Yours sincerely,\n\n"
432
- "Dr Sarah Chen\n"
433
- "Consultant, General Practice\n"
434
- "Clarke NHS Trust"
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 clinical letter…", "MedGemma 27B composing document"),
591
- "complete": (3, "Generating clinical letter…", "MedGemma 27B composing document"),
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
- """Create a short silent WAV when running in mock mode and no audio was captured."""
600
 
601
  if audio_path:
602
  return audio_path
603
- if os.getenv("MEDASR_MODEL_ID", "").lower() != "mock":
604
- return None
605
 
606
- upload_dir = Path("data/uploads/mock")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- with Path(resolved_audio_path).open("rb") as stream:
649
- _api_request("POST", f"/consultations/{consultation_id}/audio", files={"audio_file": (Path(resolved_audio_path).name, stream, "audio/wav")}, data={"is_final": "true"}, timeout=120.0)
650
- _api_request("POST", f"/consultations/{consultation_id}/end", timeout=180.0)
 
 
 
 
 
 
 
 
 
 
 
 
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", "MedGemma 27B composing document", elapsed), gr.update(active=False), s1, s2, s3, s4, fhir, *show_screen("s5")
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", "MedGemma 27B composing document", elapsed), gr.update(active=False), s1, s2, s3, s4, fhir, *show_screen("s5")
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("data") / "demo" / "latest_signed_letter.txt"
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("data") / "demo" / "latest_signed_letter.txt"
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
- return refreshed_state, "Ready for next patient. Please select a patient card.", "", "", "", "", "", "", dashboard, *show_screen("s1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("<div style='min-height:100vh;background:#F8F6F1;padding:32px 48px;margin:0;'><h2 style='font-family:DM Serif Display,serif;color:#1A1A2E;margin:0 0 16px 0;'>Document Review</h2></div>")
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="NHS Clinic Letter", lines=20, interactive=True)
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:'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=\"(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>""")
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