Mohibullah commited on
Commit
85b83d5
·
1 Parent(s): b8dddf7

Add local Nemotron validation UI

Browse files
Files changed (1) hide show
  1. gradio_pharmacopilot_demo.py +278 -18
gradio_pharmacopilot_demo.py CHANGED
@@ -51,11 +51,15 @@ INVENTORY_PATH = data_path("inventory.json")
51
 
52
  MODEL_ID = os.getenv("PHARMACOPILOT_MODEL_ID", "openbmb/MiniCPM-V-4_5")
53
  LIVE_GPU_OCR = os.getenv("PHARMACOPILOT_LIVE_GPU_OCR", "1").lower() not in {"0", "false", "no"}
 
 
54
  DEMO_OCR_TEXT = "Neuoxen"
55
  DEMO_PROMPT = "Read the handwritten medicine name in the image. Return only the text."
56
  ACCEPTANCE_THRESHOLD = int(os.getenv("PHARMACOPILOT_ACCEPTANCE_THRESHOLD", "75"))
57
  OCR_MODEL = None
58
  OCR_TOKENIZER = None
 
 
59
 
60
 
61
  def load_json(path: Path, fallback: Any) -> Any:
@@ -213,6 +217,183 @@ def first_strength(strength: str) -> str:
213
  return strength.split(",")[0].strip()
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  def load_kpi_metrics(searches: int = 0) -> str:
217
  metrics_path = ROOT / "training" / "baseline_eval" / "minicpm_v_4_5" / "baseline_minicpm_v_4_5_metrics.json"
218
  fallback_path = ROOT / "training" / "baseline_eval" / "minicpm_v_4_5" / "baseline_minicpm_v_4_5_report.md"
@@ -273,13 +454,19 @@ def confidence_gauge(confidence: int = 97):
273
  return fig
274
 
275
 
276
- def pipeline_html(stage: int = 0) -> str:
 
 
 
 
 
 
277
  steps = [
278
- ("Prescription", "captured"),
279
- ("MiniCPM OCR", "completed"),
280
- ("Retrieval Engine", "searched"),
281
- ("Validation", "checked"),
282
- ("Medicine Found", "ready"),
283
  ]
284
  cards = []
285
  logs = []
@@ -297,7 +484,7 @@ def pipeline_html(stage: int = 0) -> str:
297
  logs.append(f"<li>✓ {title} {status}</li>")
298
  return f"""
299
  <div class="pipeline">
300
- <div class="pipeline-title">Live AI Pipeline</div>
301
  <div class="flow">{''.join(cards)}</div>
302
  <ul class="logs">{''.join(logs)}</ul>
303
  </div>
@@ -310,15 +497,16 @@ def medicine_details_html(
310
  ocr_text: str,
311
  display_name: str,
312
  confidence: int,
 
313
  ) -> str:
314
- accepted = confidence >= ACCEPTANCE_THRESHOLD
315
  medicine_label = display_name if accepted else "Needs pharmacist review"
316
  generic_label = medicine.get("name", "Unknown") if accepted else f"Suggestion: {medicine.get('name', 'Unknown')}"
317
  strength_label = first_strength(medicine.get("strength", "")) if accepted else "Not confirmed"
318
  manufacturer_label = (medicine.get("manufacturer") or "Not listed") if accepted else "Not confirmed"
319
  category_label = medicine.get("category", "General") if accepted else "Not confirmed"
320
  price_label = "PKR 145" if accepted else "Not confirmed"
321
- validation_label = "Pending review" if not accepted else "Accepted by retrieval threshold"
322
  inventory_label = (
323
  f"Shelf {inventory['shelf']}, row {inventory['row']}"
324
  if accepted
@@ -347,6 +535,31 @@ def medicine_details_html(
347
  """
348
 
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  def package_status_html(inventory: dict[str, Any], accepted: bool = True) -> str:
351
  if not accepted:
352
  return """
@@ -382,13 +595,19 @@ def candidates_html(candidates: list[dict[str, Any]]) -> str:
382
  """
383
 
384
 
385
- def ocr_compare_html(medicine: dict[str, Any], ocr_text: str, display_name: str, confidence: int) -> str:
386
- corrected = display_name if confidence >= ACCEPTANCE_THRESHOLD else f"Needs review: {display_name}"
 
 
 
 
 
 
387
  return f"""
388
  <div class="compare-grid">
389
  <div><span>OCR Output</span><strong>{ocr_text}</strong></div>
390
  <div><span>AI Corrected</span><strong>{corrected}</strong></div>
391
- <div><span>Canonical</span><strong>{medicine['name']}</strong></div>
392
  </div>
393
  """
394
 
@@ -439,7 +658,7 @@ def run_minicpm_ocr(pil_image: Image.Image) -> str:
439
  return clean_prediction(raw_prediction) or raw_prediction.strip()
440
 
441
 
442
- @spaces.GPU(duration=60)
443
  def analyze_prescription(image, progress=gr.Progress()):
444
  global SESSION_SEARCHES
445
  if image is None:
@@ -453,17 +672,20 @@ def analyze_prescription(image, progress=gr.Progress()):
453
  time.sleep(0.15)
454
 
455
  ocr_text = run_minicpm_ocr(image)
 
456
 
457
  for pct, label in [
458
  (0.70, "Retrieval search over medicine aliases"),
459
- (0.88, "Validation check"),
460
  (1.00, "Result prepared"),
461
  ]:
462
  progress(pct, desc=label)
463
  time.sleep(0.25)
464
 
465
  medicine, candidates, display_name, confidence = find_medicine_from_ocr(ocr_text)
466
- accepted = confidence >= ACCEPTANCE_THRESHOLD
 
 
467
  inventory = get_inventory(medicine)
468
  image_path = resolve_asset_path(medicine.get("image_path"))
469
  package_image = str(image_path) if image_path and accepted else None
@@ -480,13 +702,14 @@ def analyze_prescription(image, progress=gr.Progress()):
480
 
481
  return (
482
  load_kpi_metrics(SESSION_SEARCHES),
483
- pipeline_html(5),
484
- medicine_details_html(medicine, inventory, ocr_text, display_name, confidence),
485
  package_image,
486
  package_status_html(inventory, accepted),
487
  confidence_gauge(confidence),
488
  candidates_html(candidates),
489
- ocr_compare_html(medicine, ocr_text, display_name, confidence),
 
490
  gr.update(visible=True),
491
  gr.update(visible=True, interactive=accepted),
492
  state,
@@ -704,6 +927,40 @@ CSS = """
704
  background: #ffffff;
705
  }
706
  .compare-grid strong { color: var(--ink) !important; display: block; margin-top: 6px; font-size: 18px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
  .compact { margin-top: 0; }
708
  .gradio-container button.primary,
709
  .gradio-container button[variant="primary"] {
@@ -721,6 +978,7 @@ CSS = """
721
  .powered { text-align: left; margin-top: 10px; }
722
  .metric-row, .flow, .stock-card, .compare-grid { grid-template-columns: 1fr; }
723
  .details { grid-template-columns: 1fr; }
 
724
  }
725
  """
726
 
@@ -776,6 +1034,7 @@ with gr.Blocks(title="PharmaCopilot") as demo:
776
  candidates = gr.HTML()
777
  gr.Markdown("### OCR vs Corrected")
778
  comparison = gr.HTML()
 
779
  locate_btn = gr.Button("Locate Medicine", variant="primary", size="lg")
780
  locate_status = gr.Markdown()
781
 
@@ -806,6 +1065,7 @@ with gr.Blocks(title="PharmaCopilot") as demo:
806
  gauge,
807
  candidates,
808
  comparison,
 
809
  result_section,
810
  locate_btn,
811
  state,
 
51
 
52
  MODEL_ID = os.getenv("PHARMACOPILOT_MODEL_ID", "openbmb/MiniCPM-V-4_5")
53
  LIVE_GPU_OCR = os.getenv("PHARMACOPILOT_LIVE_GPU_OCR", "1").lower() not in {"0", "false", "no"}
54
+ LIVE_NEMOTRON = os.getenv("PHARMACOPILOT_LIVE_NEMOTRON", "1").lower() not in {"0", "false", "no"}
55
+ NEMOTRON_MODEL_ID = os.getenv("NEMOTRON_MODEL_ID", "nvidia/NVIDIA-Nemotron-Nano-9B-v2")
56
  DEMO_OCR_TEXT = "Neuoxen"
57
  DEMO_PROMPT = "Read the handwritten medicine name in the image. Return only the text."
58
  ACCEPTANCE_THRESHOLD = int(os.getenv("PHARMACOPILOT_ACCEPTANCE_THRESHOLD", "75"))
59
  OCR_MODEL = None
60
  OCR_TOKENIZER = None
61
+ NEMOTRON_MODEL = None
62
+ NEMOTRON_TOKENIZER = None
63
 
64
 
65
  def load_json(path: Path, fallback: Any) -> Any:
 
217
  return strength.split(",")[0].strip()
218
 
219
 
220
+ def fallback_prescription_plan(
221
+ ocr_text: str,
222
+ medicine: dict[str, Any],
223
+ display_name: str,
224
+ confidence: int,
225
+ note: str = "Nemotron did not run",
226
+ ) -> dict[str, Any]:
227
+ accepted = confidence >= ACCEPTANCE_THRESHOLD
228
+ return {
229
+ "status": "needs_review" if not accepted else "retrieval_only",
230
+ "medicine_name": display_name if accepted else "Needs review",
231
+ "canonical_name": medicine.get("name", "Unknown") if accepted else f"Suggestion: {medicine.get('name', 'Unknown')}",
232
+ "dose": first_strength(medicine.get("strength", "")) if accepted else "Not confirmed",
233
+ "route": "Not specified",
234
+ "timing": "Not specified",
235
+ "frequency": "Not specified",
236
+ "duration": "Not specified",
237
+ "instructions": "Pharmacist review required before dispensing.",
238
+ "validation_note": note,
239
+ "ocr_text": ocr_text,
240
+ }
241
+
242
+
243
+ def release_model_memory() -> None:
244
+ try:
245
+ import gc
246
+ import torch
247
+
248
+ gc.collect()
249
+ if torch.cuda.is_available():
250
+ torch.cuda.empty_cache()
251
+ except Exception:
252
+ return
253
+
254
+
255
+ def unload_ocr_model() -> None:
256
+ global OCR_MODEL, OCR_TOKENIZER
257
+ OCR_MODEL = None
258
+ OCR_TOKENIZER = None
259
+ release_model_memory()
260
+
261
+
262
+ def unload_nemotron_model() -> None:
263
+ global NEMOTRON_MODEL, NEMOTRON_TOKENIZER
264
+ NEMOTRON_MODEL = None
265
+ NEMOTRON_TOKENIZER = None
266
+ release_model_memory()
267
+
268
+
269
+ def extract_json_object(text: str) -> dict[str, Any]:
270
+ cleaned = str(text or "").strip()
271
+ cleaned = cleaned.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
272
+ start = cleaned.find("{")
273
+ end = cleaned.rfind("}")
274
+ if start >= 0 and end > start:
275
+ cleaned = cleaned[start : end + 1]
276
+ return json.loads(cleaned)
277
+
278
+
279
+ def validate_with_nemotron(
280
+ ocr_text: str,
281
+ medicine: dict[str, Any],
282
+ display_name: str,
283
+ confidence: int,
284
+ retrieval_candidates: list[dict[str, Any]],
285
+ ) -> dict[str, Any]:
286
+ global NEMOTRON_MODEL, NEMOTRON_TOKENIZER
287
+
288
+ if not LIVE_NEMOTRON:
289
+ return fallback_prescription_plan(
290
+ ocr_text, medicine, display_name, confidence, "Local Nemotron validation is disabled"
291
+ )
292
+
293
+ validation_payload = {
294
+ "ocr_text": ocr_text,
295
+ "retrieved_display_name": display_name,
296
+ "retrieved_canonical_name": medicine.get("name", "Unknown"),
297
+ "retrieval_confidence": confidence,
298
+ "strength": first_strength(medicine.get("strength", "")),
299
+ "category": medicine.get("category", "Unknown"),
300
+ "top_candidates": [
301
+ {
302
+ "display_name": item["label"],
303
+ "canonical_name": item["medicine"]["name"],
304
+ "score": round(item["score"] * 100),
305
+ }
306
+ for item in retrieval_candidates[:3]
307
+ ],
308
+ }
309
+ prompt = f"""
310
+ You are a pharmacy prescription validation assistant.
311
+
312
+ Input JSON:
313
+ {json.dumps(validation_payload, ensure_ascii=False)}
314
+
315
+ Task:
316
+ 1. Decide whether the retrieved medicine is safe to accept.
317
+ 2. Translate the prescription into a clean pharmacy instruction row.
318
+ 3. Do not invent dose/timing/duration if it is not visible or inferable.
319
+
320
+ Return ONLY valid JSON with these keys:
321
+ status: one of validated, needs_review
322
+ medicine_name
323
+ canonical_name
324
+ dose
325
+ route
326
+ timing
327
+ frequency
328
+ duration
329
+ instructions
330
+ validation_note
331
+ ocr_text
332
+ """
333
+ try:
334
+ import torch
335
+ from transformers import AutoModelForCausalLM, AutoTokenizer
336
+
337
+ if NEMOTRON_MODEL is None or NEMOTRON_TOKENIZER is None:
338
+ NEMOTRON_TOKENIZER = AutoTokenizer.from_pretrained(NEMOTRON_MODEL_ID, trust_remote_code=True)
339
+ NEMOTRON_MODEL = AutoModelForCausalLM.from_pretrained(
340
+ NEMOTRON_MODEL_ID,
341
+ trust_remote_code=True,
342
+ torch_dtype=torch.bfloat16,
343
+ device_map="auto",
344
+ ).eval()
345
+
346
+ messages = [{"role": "user", "content": prompt}]
347
+ if hasattr(NEMOTRON_TOKENIZER, "apply_chat_template"):
348
+ input_ids = NEMOTRON_TOKENIZER.apply_chat_template(
349
+ messages,
350
+ add_generation_prompt=True,
351
+ return_tensors="pt",
352
+ )
353
+ else:
354
+ input_ids = NEMOTRON_TOKENIZER(prompt, return_tensors="pt").input_ids
355
+
356
+ device = next(NEMOTRON_MODEL.parameters()).device
357
+ input_ids = input_ids.to(device)
358
+ with torch.inference_mode():
359
+ output_ids = NEMOTRON_MODEL.generate(
360
+ input_ids,
361
+ do_sample=False,
362
+ temperature=0.0,
363
+ top_p=1.0,
364
+ max_new_tokens=320,
365
+ pad_token_id=NEMOTRON_TOKENIZER.eos_token_id,
366
+ )
367
+ generated = output_ids[0][input_ids.shape[-1] :]
368
+ content = NEMOTRON_TOKENIZER.decode(generated, skip_special_tokens=True).strip()
369
+ plan = extract_json_object(content)
370
+ if plan.get("status") not in {"validated", "needs_review"}:
371
+ plan["status"] = "needs_review"
372
+ if confidence < ACCEPTANCE_THRESHOLD:
373
+ plan["status"] = "needs_review"
374
+ plan["validation_note"] = (
375
+ f"Retrieval confidence {confidence}% is below the {ACCEPTANCE_THRESHOLD}% acceptance threshold"
376
+ )
377
+ return {
378
+ **fallback_prescription_plan(
379
+ ocr_text,
380
+ medicine,
381
+ display_name,
382
+ confidence,
383
+ f"Validated by local {NEMOTRON_MODEL_ID}",
384
+ ),
385
+ **plan,
386
+ }
387
+ except Exception as exc:
388
+ return fallback_prescription_plan(
389
+ ocr_text,
390
+ medicine,
391
+ display_name,
392
+ confidence,
393
+ f"Local Nemotron failed: {exc}",
394
+ )
395
+
396
+
397
  def load_kpi_metrics(searches: int = 0) -> str:
398
  metrics_path = ROOT / "training" / "baseline_eval" / "minicpm_v_4_5" / "baseline_minicpm_v_4_5_metrics.json"
399
  fallback_path = ROOT / "training" / "baseline_eval" / "minicpm_v_4_5" / "baseline_minicpm_v_4_5_report.md"
 
454
  return fig
455
 
456
 
457
+ def pipeline_html(stage: int = 0, validation_status: str = "waiting") -> str:
458
+ validation_label = {
459
+ "validated": "Nemotron Validated",
460
+ "needs_review": "Needs Review",
461
+ "retrieval_only": "Retrieval Only",
462
+ "waiting": "Awaiting Analysis",
463
+ }.get(validation_status, "Nemotron Review")
464
  steps = [
465
+ ("Prescription", "uploaded"),
466
+ ("MiniCPM OCR", "ran on image"),
467
+ ("Retrieval Engine", "ranked candidates"),
468
+ (validation_label, "returned a decision"),
469
+ ("Pharmacy View", "prepared"),
470
  ]
471
  cards = []
472
  logs = []
 
484
  logs.append(f"<li>✓ {title} {status}</li>")
485
  return f"""
486
  <div class="pipeline">
487
+ <div class="pipeline-title">Actual Run Trace</div>
488
  <div class="flow">{''.join(cards)}</div>
489
  <ul class="logs">{''.join(logs)}</ul>
490
  </div>
 
497
  ocr_text: str,
498
  display_name: str,
499
  confidence: int,
500
+ plan: dict[str, Any],
501
  ) -> str:
502
+ accepted = plan.get("status") == "validated" and confidence >= ACCEPTANCE_THRESHOLD
503
  medicine_label = display_name if accepted else "Needs pharmacist review"
504
  generic_label = medicine.get("name", "Unknown") if accepted else f"Suggestion: {medicine.get('name', 'Unknown')}"
505
  strength_label = first_strength(medicine.get("strength", "")) if accepted else "Not confirmed"
506
  manufacturer_label = (medicine.get("manufacturer") or "Not listed") if accepted else "Not confirmed"
507
  category_label = medicine.get("category", "General") if accepted else "Not confirmed"
508
  price_label = "PKR 145" if accepted else "Not confirmed"
509
+ validation_label = plan.get("validation_note") or plan.get("status", "Not available")
510
  inventory_label = (
511
  f"Shelf {inventory['shelf']}, row {inventory['row']}"
512
  if accepted
 
535
  """
536
 
537
 
538
+ def translated_prescription_html(plan: dict[str, Any]) -> str:
539
+ rows = [
540
+ ("Medicine", plan.get("medicine_name") or "Not confirmed"),
541
+ ("Canonical", plan.get("canonical_name") or "Not confirmed"),
542
+ ("Dose", plan.get("dose") or "Not specified"),
543
+ ("Route", plan.get("route") or "Not specified"),
544
+ ("When to take", plan.get("timing") or "Not specified"),
545
+ ("Pill timing", plan.get("frequency") or "Not specified"),
546
+ ("Duration", plan.get("duration") or "Not specified"),
547
+ ("Instructions", plan.get("instructions") or "Pharmacist review required"),
548
+ ]
549
+ row_html = "".join(f"<dt>{label}</dt><dd>{value}</dd>" for label, value in rows)
550
+ status = plan.get("status", "needs_review").replace("_", " ").title()
551
+ return f"""
552
+ <div class="translated-card">
553
+ <div class="translated-head">
554
+ <h3>Translated Prescription</h3>
555
+ <span class="status-pill">{status}</span>
556
+ </div>
557
+ <dl class="details translated-details">{row_html}</dl>
558
+ <p class="fine-print">Generated from OCR text and retrieval candidates. Confirm before dispensing.</p>
559
+ </div>
560
+ """
561
+
562
+
563
  def package_status_html(inventory: dict[str, Any], accepted: bool = True) -> str:
564
  if not accepted:
565
  return """
 
595
  """
596
 
597
 
598
+ def ocr_compare_html(
599
+ medicine: dict[str, Any],
600
+ ocr_text: str,
601
+ display_name: str,
602
+ confidence: int,
603
+ plan: dict[str, Any],
604
+ ) -> str:
605
+ corrected = display_name if plan.get("status") == "validated" else f"Needs review: {display_name}"
606
  return f"""
607
  <div class="compare-grid">
608
  <div><span>OCR Output</span><strong>{ocr_text}</strong></div>
609
  <div><span>AI Corrected</span><strong>{corrected}</strong></div>
610
+ <div><span>Canonical</span><strong>{medicine['name'] if plan.get('status') == 'validated' else 'Not confirmed'}</strong></div>
611
  </div>
612
  """
613
 
 
658
  return clean_prediction(raw_prediction) or raw_prediction.strip()
659
 
660
 
661
+ @spaces.GPU(duration=300)
662
  def analyze_prescription(image, progress=gr.Progress()):
663
  global SESSION_SEARCHES
664
  if image is None:
 
672
  time.sleep(0.15)
673
 
674
  ocr_text = run_minicpm_ocr(image)
675
+ unload_ocr_model()
676
 
677
  for pct, label in [
678
  (0.70, "Retrieval search over medicine aliases"),
679
+ (0.88, "Nemotron prescription validation"),
680
  (1.00, "Result prepared"),
681
  ]:
682
  progress(pct, desc=label)
683
  time.sleep(0.25)
684
 
685
  medicine, candidates, display_name, confidence = find_medicine_from_ocr(ocr_text)
686
+ plan = validate_with_nemotron(ocr_text, medicine, display_name, confidence, candidates)
687
+ unload_nemotron_model()
688
+ accepted = plan.get("status") == "validated" and confidence >= ACCEPTANCE_THRESHOLD
689
  inventory = get_inventory(medicine)
690
  image_path = resolve_asset_path(medicine.get("image_path"))
691
  package_image = str(image_path) if image_path and accepted else None
 
702
 
703
  return (
704
  load_kpi_metrics(SESSION_SEARCHES),
705
+ pipeline_html(5, plan.get("status", "needs_review")),
706
+ medicine_details_html(medicine, inventory, ocr_text, display_name, confidence, plan),
707
  package_image,
708
  package_status_html(inventory, accepted),
709
  confidence_gauge(confidence),
710
  candidates_html(candidates),
711
+ ocr_compare_html(medicine, ocr_text, display_name, confidence, plan),
712
+ translated_prescription_html(plan),
713
  gr.update(visible=True),
714
  gr.update(visible=True, interactive=accepted),
715
  state,
 
927
  background: #ffffff;
928
  }
929
  .compare-grid strong { color: var(--ink) !important; display: block; margin-top: 6px; font-size: 18px; }
930
+ .translated-card {
931
+ border: 1px solid var(--line);
932
+ background: #ffffff;
933
+ border-radius: 8px;
934
+ padding: 18px;
935
+ margin-top: 12px;
936
+ }
937
+ .translated-head {
938
+ display: flex;
939
+ justify-content: space-between;
940
+ gap: 12px;
941
+ align-items: center;
942
+ margin-bottom: 12px;
943
+ }
944
+ .translated-head h3 { color: var(--ink) !important; margin: 0; font-size: 20px; }
945
+ .status-pill {
946
+ background: #ebfbf5;
947
+ border: 1px solid #85d7bd;
948
+ color: #075f45;
949
+ border-radius: 999px;
950
+ padding: 5px 10px;
951
+ font-size: 12px;
952
+ font-weight: 800;
953
+ }
954
+ .translated-details {
955
+ grid-template-columns: 130px 1fr;
956
+ }
957
+ .fine-print {
958
+ border-top: 1px solid var(--line);
959
+ color: var(--muted) !important;
960
+ margin: 14px 0 0;
961
+ padding-top: 12px;
962
+ font-size: 13px;
963
+ }
964
  .compact { margin-top: 0; }
965
  .gradio-container button.primary,
966
  .gradio-container button[variant="primary"] {
 
978
  .powered { text-align: left; margin-top: 10px; }
979
  .metric-row, .flow, .stock-card, .compare-grid { grid-template-columns: 1fr; }
980
  .details { grid-template-columns: 1fr; }
981
+ .translated-head { align-items: flex-start; flex-direction: column; }
982
  }
983
  """
984
 
 
1034
  candidates = gr.HTML()
1035
  gr.Markdown("### OCR vs Corrected")
1036
  comparison = gr.HTML()
1037
+ translated_prescription = gr.HTML()
1038
  locate_btn = gr.Button("Locate Medicine", variant="primary", size="lg")
1039
  locate_status = gr.Markdown()
1040
 
 
1065
  gauge,
1066
  candidates,
1067
  comparison,
1068
+ translated_prescription,
1069
  result_section,
1070
  locate_btn,
1071
  state,