Mohibullah commited on
Commit
8170b9a
Β·
1 Parent(s): 25824e9

Full structured prescription extraction: two-pass OCR (MiniCPM-V + Nemotron), HIPAA JSON schema, controlled substance detection, compliance banners

Browse files
__pycache__/app.cpython-312.pyc ADDED
Binary file (387 Bytes). View file
 
__pycache__/gradio_pharmacopilot_demo.cpython-312.pyc ADDED
Binary file (61.4 kB). View file
 
gradio_pharmacopilot_demo.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
5
  import unicodedata
6
  import time
7
  from difflib import SequenceMatcher, get_close_matches
@@ -50,20 +51,119 @@ BRAND_MAP_PATH = data_path("training/bd_brand_to_generic.json")
50
  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
- LIVE_NEMOTRON = os.getenv("PHARMACOPILOT_LIVE_NEMOTRON", "1").lower() not in {"0", "false", "no"}
55
  NEMOTRON_MODEL_ID = os.getenv("NEMOTRON_MODEL_ID", "nvidia/Nemotron-Mini-4B-Instruct")
56
  NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "")
57
  NVIDIA_BASE_URL = os.getenv("NVIDIA_BASE_URL", "https://integrate.api.nvidia.com/v1")
58
  NVIDIA_NIM_MODEL = os.getenv("NVIDIA_NIM_MODEL", "nvidia/nvidia-nemotron-nano-9b-v2")
59
- DEMO_OCR_TEXT = "Neuoxen"
60
- DEMO_PROMPT = "Read the handwritten medicine name in the image. Return only the text."
61
  ACCEPTANCE_THRESHOLD = int(os.getenv("PHARMACOPILOT_ACCEPTANCE_THRESHOLD", "75"))
62
  OCR_MODEL = None
63
  OCR_TOKENIZER = None
64
  NEMOTRON_MODEL = None
65
  NEMOTRON_TOKENIZER = None
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  def load_json(path: Path, fallback: Any) -> Any:
69
  if not path.exists():
@@ -109,6 +209,7 @@ def normalize(text: str) -> str:
109
 
110
 
111
  def clean_prediction(raw_prediction: str) -> str:
 
112
  text = str(raw_prediction or "").strip()
113
  text = text.replace("\r", "\n")
114
  text = text.split("\n")[0].strip() if "\n" in text else text
@@ -139,7 +240,8 @@ def label_for_medicine(ocr_text: str, medicine: dict[str, Any]) -> str:
139
  return brands[0] if brands else medicine["name"]
140
 
141
 
142
- def find_medicine_from_ocr(ocr_text: str) -> tuple[dict[str, Any], list[dict[str, Any]], str, int]:
 
143
  query = normalize(ocr_text)
144
  corrected_query = query
145
  canonical = BD_BRAND_TO_GENERIC.get(corrected_query, corrected_query)
@@ -159,6 +261,10 @@ def find_medicine_from_ocr(ocr_text: str) -> tuple[dict[str, Any], list[dict[str
159
  mapped = BD_BRAND_TO_GENERIC.get(normalize(name), normalize(name))
160
  med = MED_BY_NAME.get(mapped) or MED_BY_NAME.get(normalize(name))
161
  if med:
 
 
 
 
162
  scored.append({"label": name, "medicine": med, "score": score})
163
 
164
  scored.sort(key=lambda item: item["score"], reverse=True)
@@ -172,7 +278,7 @@ def find_medicine_from_ocr(ocr_text: str) -> tuple[dict[str, Any], list[dict[str
172
  display_name = best["label"]
173
  primary_score = best["score"]
174
  else:
175
- medicine = MEDICINES[0]
176
  display_name = clean_prediction(ocr_text) or "Needs review"
177
  primary_score = 0.0
178
 
@@ -279,19 +385,99 @@ def extract_json_object(text: str) -> dict[str, Any]:
279
  return json.loads(cleaned)
280
 
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  def build_validation_prompt(
283
  ocr_text: str,
 
284
  medicine: dict[str, Any],
285
  display_name: str,
286
  confidence: int,
287
  retrieval_candidates: list[dict[str, Any]],
288
  ) -> str:
289
  validation_payload = {
290
- "ocr_text": ocr_text,
 
 
 
 
 
291
  "retrieved_display_name": display_name,
292
  "retrieved_canonical_name": medicine.get("name", "Unknown"),
293
  "retrieval_confidence": confidence,
294
- "strength": first_strength(medicine.get("strength", "")),
295
  "category": medicine.get("category", "Unknown"),
296
  "top_candidates": [
297
  {
@@ -302,17 +488,31 @@ def build_validation_prompt(
302
  for item in retrieval_candidates[:3]
303
  ],
304
  }
305
- return f"""
306
- You are a pharmacy prescription validation assistant.
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  Input JSON:
309
  {json.dumps(validation_payload, ensure_ascii=False)}
310
 
311
  Task:
312
- 1. Decide whether the retrieved medicine is safe to accept.
313
  2. Translate the prescription into a clean pharmacy instruction row.
314
- 3. Do not invent dose/timing/duration if it is not visible or inferable.
315
  4. If OCR and retrieved medicine clearly disagree, return needs_review.
 
 
316
 
317
  Return ONLY valid JSON with these keys:
318
  status: one of validated, needs_review
@@ -326,6 +526,7 @@ duration
326
  instructions
327
  validation_note
328
  ocr_text
 
329
  """
330
 
331
 
@@ -353,7 +554,7 @@ def validate_with_nvidia_nim(
353
  messages=[{"role": "user", "content": prompt}],
354
  temperature=0,
355
  top_p=1,
356
- max_tokens=320,
357
  )
358
  content = response.choices[0].message.content or ""
359
  plan = extract_json_object(content)
@@ -384,8 +585,88 @@ def validate_with_nvidia_nim(
384
  )
385
 
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  def validate_with_nemotron(
388
  ocr_text: str,
 
389
  medicine: dict[str, Any],
390
  display_name: str,
391
  confidence: int,
@@ -393,48 +674,9 @@ def validate_with_nemotron(
393
  ) -> dict[str, Any]:
394
  global NEMOTRON_MODEL, NEMOTRON_TOKENIZER
395
 
396
- if not LIVE_NEMOTRON:
397
- return fallback_prescription_plan(
398
- ocr_text, medicine, display_name, confidence, "Local Nemotron validation is disabled"
399
- )
400
-
401
- prompt = build_validation_prompt(ocr_text, medicine, display_name, confidence, retrieval_candidates)
402
  try:
403
- import torch
404
- from transformers import AutoModelForCausalLM, AutoTokenizer
405
-
406
- if NEMOTRON_MODEL is None or NEMOTRON_TOKENIZER is None:
407
- NEMOTRON_TOKENIZER = AutoTokenizer.from_pretrained(NEMOTRON_MODEL_ID, trust_remote_code=True)
408
- NEMOTRON_MODEL = AutoModelForCausalLM.from_pretrained(
409
- NEMOTRON_MODEL_ID,
410
- trust_remote_code=True,
411
- torch_dtype=torch.bfloat16,
412
- device_map="auto",
413
- ).eval()
414
-
415
- messages = [{"role": "user", "content": prompt}]
416
- if hasattr(NEMOTRON_TOKENIZER, "apply_chat_template"):
417
- input_ids = NEMOTRON_TOKENIZER.apply_chat_template(
418
- messages,
419
- add_generation_prompt=True,
420
- return_tensors="pt",
421
- )
422
- else:
423
- input_ids = NEMOTRON_TOKENIZER(prompt, return_tensors="pt").input_ids
424
-
425
- device = next(NEMOTRON_MODEL.parameters()).device
426
- input_ids = input_ids.to(device)
427
- with torch.inference_mode():
428
- output_ids = NEMOTRON_MODEL.generate(
429
- input_ids,
430
- do_sample=False,
431
- temperature=0.0,
432
- top_p=1.0,
433
- max_new_tokens=320,
434
- pad_token_id=NEMOTRON_TOKENIZER.eos_token_id,
435
- )
436
- generated = output_ids[0][input_ids.shape[-1] :]
437
- content = NEMOTRON_TOKENIZER.decode(generated, skip_special_tokens=True).strip()
438
  plan = extract_json_object(content)
439
  if plan.get("status") not in {"validated", "needs_review"}:
440
  plan["status"] = "needs_review"
@@ -474,7 +716,6 @@ def load_kpi_metrics(searches: int = 0) -> str:
474
  elif fallback_path.exists():
475
  text = fallback_path.read_text(encoding="utf-8", errors="ignore")
476
  if "ocr_accuracy" in text:
477
- # Keep a conservative fallback tied to the checked-in report values.
478
  ocr_accuracy = 0.37888446215139443
479
  retrieval_accuracy = 0.6055776892430279
480
 
@@ -530,10 +771,10 @@ def pipeline_html(stage: int = 0, validation_status: str = "waiting") -> str:
530
  }.get(validation_status, "Nemotron Review")
531
  steps = [
532
  ("Prescription", "uploaded"),
533
- ("MiniCPM OCR", "ran on image"),
 
534
  ("Retrieval Engine", "ranked candidates"),
535
  (validation_label, "returned a decision"),
536
- ("Pharmacy View", "prepared"),
537
  ]
538
  cards = []
539
  logs = []
@@ -581,7 +822,7 @@ def medicine_details_html(
581
  )
582
  return f"""
583
  <div class="result-card">
584
- <h3>Prescription Details</h3>
585
  <dl class="details">
586
  <dt>Medicine</dt><dd>{medicine_label}</dd>
587
  <dt>Generic</dt><dd>{generic_label}</dd>
@@ -593,7 +834,7 @@ def medicine_details_html(
593
  </dl>
594
  <div class="explain">
595
  <h4>AI Explanation</h4>
596
- <p><b>OCR detected:</b> "{ocr_text}"</p>
597
  <p><b>Retrieved:</b> {display_name} ({medicine.get('name', 'Unknown')})</p>
598
  <p><b>Validation:</b> {validation_label}</p>
599
  <p><b>Inventory:</b> {inventory_label}</p>
@@ -602,6 +843,111 @@ def medicine_details_html(
602
  """
603
 
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  def translated_prescription_html(plan: dict[str, Any]) -> str:
606
  rows = [
607
  ("Medicine", plan.get("medicine_name") or "Not confirmed"),
@@ -615,12 +961,19 @@ def translated_prescription_html(plan: dict[str, Any]) -> str:
615
  ]
616
  row_html = "".join(f"<dt>{label}</dt><dd>{value}</dd>" for label, value in rows)
617
  status = plan.get("status", "needs_review").replace("_", " ").title()
 
 
 
 
 
 
618
  return f"""
619
  <div class="translated-card">
620
  <div class="translated-head">
621
  <h3>Translated Prescription</h3>
622
  <span class="status-pill">{status}</span>
623
  </div>
 
624
  <dl class="details translated-details">{row_html}</dl>
625
  <p class="fine-print">Generated from OCR text and retrieval candidates. Confirm before dispensing.</p>
626
  </div>
@@ -670,19 +1023,22 @@ def ocr_compare_html(
670
  plan: dict[str, Any],
671
  ) -> str:
672
  corrected = display_name if plan.get("status") == "validated" else f"Needs review: {display_name}"
 
 
673
  return f"""
674
  <div class="compare-grid">
675
- <div><span>OCR Output</span><strong>{ocr_text}</strong></div>
676
  <div><span>AI Corrected</span><strong>{corrected}</strong></div>
677
  <div><span>Canonical</span><strong>{medicine['name'] if plan.get('status') == 'validated' else 'Not confirmed'}</strong></div>
678
  </div>
679
  """
680
 
681
 
 
 
682
  def run_minicpm_ocr(pil_image: Image.Image) -> str:
 
683
  global OCR_MODEL, OCR_TOKENIZER
684
- if not LIVE_GPU_OCR:
685
- return DEMO_OCR_TEXT
686
 
687
  try:
688
  import torch
@@ -701,14 +1057,14 @@ def run_minicpm_ocr(pil_image: Image.Image) -> str:
701
  if torch.cuda.is_available():
702
  OCR_MODEL = OCR_MODEL.cuda()
703
 
704
- messages = [{"role": "user", "content": [pil_image.convert("RGB"), DEMO_PROMPT]}]
705
  kwargs = {
706
  "image": None,
707
  "msgs": messages,
708
  "tokenizer": OCR_TOKENIZER,
709
  "sampling": False,
710
  "stream": False,
711
- "max_new_tokens": 20,
712
  "enable_thinking": False,
713
  "temperature": 0.0,
714
  "top_p": 0.1,
@@ -722,8 +1078,10 @@ def run_minicpm_ocr(pil_image: Image.Image) -> str:
722
 
723
  if not isinstance(raw_prediction, str):
724
  raw_prediction = "".join(list(raw_prediction))
725
- return clean_prediction(raw_prediction) or raw_prediction.strip()
 
726
 
 
727
 
728
  @spaces.GPU(duration=300)
729
  def analyze_prescription(image, progress=gr.Progress()):
@@ -731,31 +1089,36 @@ def analyze_prescription(image, progress=gr.Progress()):
731
  if image is None:
732
  raise gr.Error("Upload or capture a prescription image first.")
733
 
734
- for pct, label in [
735
- (0.20, "Prescription uploaded"),
736
- (0.35, "MiniCPM OCR reading handwriting"),
737
- ]:
738
- progress(pct, desc=label)
739
- time.sleep(0.15)
740
 
 
 
741
  ocr_text = run_minicpm_ocr(image)
742
  unload_ocr_model()
743
 
744
- for pct, label in [
745
- (0.70, "Retrieval search over medicine aliases"),
746
- (0.88, "Nemotron prescription validation"),
747
- (1.00, "Result prepared"),
748
- ]:
749
- progress(pct, desc=label)
750
- time.sleep(0.25)
751
 
752
- medicine, candidates, display_name, confidence = find_medicine_from_ocr(ocr_text)
753
- plan = validate_with_nemotron(ocr_text, medicine, display_name, confidence, candidates)
 
 
 
 
 
 
 
754
  unload_nemotron_model()
 
 
 
755
  accepted = plan.get("status") == "validated" and confidence >= ACCEPTANCE_THRESHOLD
756
  inventory = get_inventory(medicine)
757
  image_path = resolve_asset_path(medicine.get("image_path"))
758
- package_image = str(image_path) if image_path and accepted else None
759
 
760
  state = {
761
  "medicine_id": medicine["id"],
@@ -770,8 +1133,10 @@ def analyze_prescription(image, progress=gr.Progress()):
770
  return (
771
  load_kpi_metrics(SESSION_SEARCHES),
772
  pipeline_html(5, plan.get("status", "needs_review")),
 
 
773
  medicine_details_html(medicine, inventory, ocr_text, display_name, confidence, plan),
774
- package_image,
775
  package_status_html(inventory, accepted),
776
  confidence_gauge(confidence),
777
  candidates_html(candidates),
@@ -1029,6 +1394,94 @@ CSS = """
1029
  font-size: 13px;
1030
  }
1031
  .compact { margin-top: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  .gradio-container button.primary,
1033
  .gradio-container button[variant="primary"] {
1034
  background: var(--green) !important;
@@ -1045,6 +1498,7 @@ CSS = """
1045
  .powered { text-align: left; margin-top: 10px; }
1046
  .metric-row, .flow, .stock-card, .compare-grid { grid-template-columns: 1fr; }
1047
  .details { grid-template-columns: 1fr; }
 
1048
  .translated-head { align-items: flex-start; flex-direction: column; }
1049
  }
1050
  """
@@ -1089,7 +1543,9 @@ with gr.Blocks(title="PharmaCopilot") as demo:
1089
  pipeline = gr.HTML(pipeline_html(0))
1090
 
1091
  with gr.Group(visible=False, elem_classes=["app-shell"]) as result_section:
1092
- gr.Markdown("## Medicine Result")
 
 
1093
  with gr.Row():
1094
  with gr.Column(scale=5):
1095
  details = gr.HTML()
@@ -1126,6 +1582,8 @@ with gr.Blocks(title="PharmaCopilot") as demo:
1126
  outputs=[
1127
  live_metrics,
1128
  pipeline,
 
 
1129
  details,
1130
  package_image,
1131
  stock,
 
2
 
3
  import json
4
  import os
5
+ import re
6
  import unicodedata
7
  import time
8
  from difflib import SequenceMatcher, get_close_matches
 
51
  INVENTORY_PATH = data_path("inventory.json")
52
 
53
  MODEL_ID = os.getenv("PHARMACOPILOT_MODEL_ID", "openbmb/MiniCPM-V-4_5")
 
 
54
  NEMOTRON_MODEL_ID = os.getenv("NEMOTRON_MODEL_ID", "nvidia/Nemotron-Mini-4B-Instruct")
55
  NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "")
56
  NVIDIA_BASE_URL = os.getenv("NVIDIA_BASE_URL", "https://integrate.api.nvidia.com/v1")
57
  NVIDIA_NIM_MODEL = os.getenv("NVIDIA_NIM_MODEL", "nvidia/nvidia-nemotron-nano-9b-v2")
 
 
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
+ # ── Controlled substance lookup (DEA Schedules II-V) ─────────────────────────
65
+ CONTROLLED_SUBSTANCES = {
66
+ # Schedule II
67
+ "oxycodone", "oxycontin", "hydrocodone", "vicodin", "morphine", "fentanyl",
68
+ "methadone", "amphetamine", "adderall", "dextroamphetamine", "methamphetamine",
69
+ "methylphenidate", "ritalin", "concerta", "codeine", "hydromorphone",
70
+ "meperidine", "demerol", "tapentadol", "lisdexamfetamine", "vyvanse",
71
+ # Schedule III
72
+ "testosterone", "ketamine", "buprenorphine", "suboxone", "anabolic steroids",
73
+ # Schedule IV
74
+ "alprazolam", "xanax", "diazepam", "valium", "lorazepam", "ativan",
75
+ "clonazepam", "klonopin", "zolpidem", "ambien", "tramadol", "carisoprodol",
76
+ "midazolam", "temazepam", "triazolam", "phenobarbital",
77
+ # Schedule V
78
+ "pregabalin", "lyrica", "lacosamide", "ezogabine",
79
+ }
80
+
81
+
82
+ def is_controlled_substance(drug_name: str) -> bool:
83
+ """Check if a drug name matches a known controlled substance."""
84
+ if not drug_name:
85
+ return False
86
+ normalized = drug_name.strip().lower()
87
+ for substance in CONTROLLED_SUBSTANCES:
88
+ if substance in normalized or normalized in substance:
89
+ return True
90
+ return False
91
+
92
+
93
+ # ── Prompts ──────────────────────────────────────────────────────────────────
94
+ # Pass 1: MiniCPM-V reads ALL text from the prescription image
95
+ FULL_OCR_PROMPT = """You are an OCR engine for medical prescriptions.
96
+
97
+ Read ALL text visible in this prescription image. Include everything:
98
+ - Printed headers, clinic names, hospital names, logo text
99
+ - Patient information: name, address, date of birth, phone number
100
+ - Prescriber/Doctor information: name, credentials, address, DEA number, NPI number, phone number, signature presence
101
+ - Date of the prescription
102
+ - ALL drug/medication names with their strengths and dosage forms
103
+ - Directions for use (Sig) exactly as written - do NOT translate abbreviations
104
+ - Quantity prescribed (numeric and written)
105
+ - Number of refills authorized
106
+ - Whether "Dispense As Written" or "No Substitution" is checked
107
+ - Any other stamps, markings, or text
108
+
109
+ Rules:
110
+ - Output ALL text exactly as written on the prescription
111
+ - Preserve the layout structure using line breaks
112
+ - Do NOT interpret, correct spelling, or translate medical abbreviations
113
+ - If text is illegible, write [ILLEGIBLE] in its place
114
+ - If a section appears to be a signature, note it as [SIGNATURE PRESENT]
115
+ - Include field labels (e.g., "Patient:", "Rx:", "Sig:") if visible
116
+
117
+ Return the complete text extraction now."""
118
+
119
+ # Pass 2: Nemotron structures the raw OCR into the clinical JSON schema
120
+ STRUCTURING_PROMPT_TEMPLATE = """You are a HIPAA-compliant Clinical Data Extraction Agent.
121
+
122
+ You have been given raw OCR text extracted from a medical prescription image. Your task is to parse this text into a structured JSON format.
123
+
124
+ STRICT RULES:
125
+ 1. ZERO HALLUCINATION: This is life-critical medical data. If a field is not found in the text, output null for its value. Do NOT guess or infer.
126
+ 2. NO CLINICAL TRANSLATION: Extract the Sig (directions) EXACTLY as written. Do not expand abbreviations.
127
+ 3. Assign a confidence_score (0.00 to 1.00) to every field based on how clearly it appeared in the OCR text.
128
+ 4. Determine if the drug is a Controlled Substance (DEA Schedules II-V).
129
+
130
+ RAW OCR TEXT:
131
+ ---
132
+ {ocr_text}
133
+ ---
134
+
135
+ Return ONLY a valid JSON object with this exact structure (no markdown, no explanation):
136
+ {{
137
+ "document_metadata": {{
138
+ "is_controlled_substance": false,
139
+ "overall_legibility_score": 0.0
140
+ }},
141
+ "patient_info": {{
142
+ "name": {{ "value": null, "confidence": 0.0 }},
143
+ "address": {{ "value": null, "confidence": 0.0 }},
144
+ "date_of_birth": {{ "value": null, "confidence": 0.0 }},
145
+ "phone_number": {{ "value": null, "confidence": 0.0 }}
146
+ }},
147
+ "prescriber_info": {{
148
+ "name": {{ "value": null, "confidence": 0.0 }},
149
+ "signature_present": {{ "value": false, "confidence": 0.0 }},
150
+ "address": {{ "value": null, "confidence": 0.0 }},
151
+ "dea_number": {{ "value": null, "confidence": 0.0 }},
152
+ "npi_number": {{ "value": null, "confidence": 0.0 }},
153
+ "phone_number": {{ "value": null, "confidence": 0.0 }}
154
+ }},
155
+ "prescription_details": {{
156
+ "date_of_issuance": {{ "value": null, "confidence": 0.0 }},
157
+ "drug_name": {{ "value": null, "confidence": 0.0 }},
158
+ "strength": {{ "value": null, "confidence": 0.0 }},
159
+ "dosage_form": {{ "value": null, "confidence": 0.0 }},
160
+ "quantity": {{ "value": null, "confidence": 0.0 }},
161
+ "directions_sig": {{ "value": null, "confidence": 0.0 }},
162
+ "refills_authorized": {{ "value": null, "confidence": 0.0 }},
163
+ "dispense_as_written": {{ "value": null, "confidence": 0.0 }}
164
+ }}
165
+ }}"""
166
+
167
 
168
  def load_json(path: Path, fallback: Any) -> Any:
169
  if not path.exists():
 
209
 
210
 
211
  def clean_prediction(raw_prediction: str) -> str:
212
+ """Clean a raw OCR prediction for single-name extraction (legacy helper)."""
213
  text = str(raw_prediction or "").strip()
214
  text = text.replace("\r", "\n")
215
  text = text.split("\n")[0].strip() if "\n" in text else text
 
240
  return brands[0] if brands else medicine["name"]
241
 
242
 
243
+ def find_medicine_from_ocr(ocr_text: str, strength_hint: str | None = None) -> tuple[dict[str, Any], list[dict[str, Any]], str, int]:
244
+ """Find medicine from OCR text with optional strength disambiguation."""
245
  query = normalize(ocr_text)
246
  corrected_query = query
247
  canonical = BD_BRAND_TO_GENERIC.get(corrected_query, corrected_query)
 
261
  mapped = BD_BRAND_TO_GENERIC.get(normalize(name), normalize(name))
262
  med = MED_BY_NAME.get(mapped) or MED_BY_NAME.get(normalize(name))
263
  if med:
264
+ # Boost score if strength matches
265
+ if strength_hint and med.get("strength"):
266
+ if normalize(strength_hint) in normalize(med["strength"]):
267
+ score = min(1.0, score + 0.1)
268
  scored.append({"label": name, "medicine": med, "score": score})
269
 
270
  scored.sort(key=lambda item: item["score"], reverse=True)
 
278
  display_name = best["label"]
279
  primary_score = best["score"]
280
  else:
281
+ medicine = MEDICINES[0] if MEDICINES else {"id": "unknown", "name": "Unknown"}
282
  display_name = clean_prediction(ocr_text) or "Needs review"
283
  primary_score = 0.0
284
 
 
385
  return json.loads(cleaned)
386
 
387
 
388
+ # ── Structured Extraction Parsing ────────────────────────────────────────────
389
+
390
+ def _field(value: Any = None, confidence: float = 0.0) -> dict:
391
+ return {"value": value, "confidence": confidence}
392
+
393
+
394
+ def empty_extraction() -> dict[str, Any]:
395
+ """Return a blank extraction schema."""
396
+ return {
397
+ "document_metadata": {
398
+ "is_controlled_substance": False,
399
+ "overall_legibility_score": 0.0,
400
+ },
401
+ "patient_info": {
402
+ "name": _field(), "address": _field(),
403
+ "date_of_birth": _field(), "phone_number": _field(),
404
+ },
405
+ "prescriber_info": {
406
+ "name": _field(), "signature_present": _field(False),
407
+ "address": _field(), "dea_number": _field(),
408
+ "npi_number": _field(), "phone_number": _field(),
409
+ },
410
+ "prescription_details": {
411
+ "date_of_issuance": _field(), "drug_name": _field(),
412
+ "strength": _field(), "dosage_form": _field(),
413
+ "quantity": _field(), "directions_sig": _field(),
414
+ "refills_authorized": _field(), "dispense_as_written": _field(None),
415
+ },
416
+ }
417
+
418
+
419
+ def parse_structured_extraction(raw_text: str, ocr_text: str = "") -> dict[str, Any]:
420
+ """Parse Nemotron output into the structured extraction schema.
421
+ Falls back gracefully if JSON is malformed."""
422
+ extraction = empty_extraction()
423
+ try:
424
+ parsed = extract_json_object(raw_text)
425
+ # Merge parsed data into extraction, preserving schema structure
426
+ if "document_metadata" in parsed:
427
+ extraction["document_metadata"].update(parsed["document_metadata"])
428
+ for section in ("patient_info", "prescriber_info", "prescription_details"):
429
+ if section in parsed:
430
+ for key, val in parsed[section].items():
431
+ if key in extraction[section]:
432
+ if isinstance(val, dict) and "value" in val:
433
+ extraction[section][key] = val
434
+ else:
435
+ extraction[section][key] = _field(val, 0.5)
436
+ except (json.JSONDecodeError, KeyError, TypeError):
437
+ # Fallback: try to extract drug name from raw text
438
+ drug_guess = clean_prediction(ocr_text or raw_text)
439
+ if drug_guess:
440
+ extraction["prescription_details"]["drug_name"] = _field(drug_guess, 0.3)
441
+
442
+ # Apply controlled substance check using our lookup
443
+ drug_val = extraction["prescription_details"]["drug_name"].get("value")
444
+ if drug_val and is_controlled_substance(drug_val):
445
+ extraction["document_metadata"]["is_controlled_substance"] = True
446
+
447
+ return extraction
448
+
449
+
450
+ def get_field_value(extraction: dict, section: str, field: str) -> Any:
451
+ """Safely get a field value from the extraction dict."""
452
+ return extraction.get(section, {}).get(field, {}).get("value")
453
+
454
+
455
+ def get_field_confidence(extraction: dict, section: str, field: str) -> float:
456
+ """Safely get a field confidence from the extraction dict."""
457
+ return extraction.get(section, {}).get(field, {}).get("confidence", 0.0)
458
+
459
+
460
+ # ── Validation Prompt (enhanced) ─────────────────────────────────────────────
461
+
462
  def build_validation_prompt(
463
  ocr_text: str,
464
+ extraction: dict[str, Any],
465
  medicine: dict[str, Any],
466
  display_name: str,
467
  confidence: int,
468
  retrieval_candidates: list[dict[str, Any]],
469
  ) -> str:
470
  validation_payload = {
471
+ "raw_ocr_text": ocr_text,
472
+ "extracted_drug_name": get_field_value(extraction, "prescription_details", "drug_name"),
473
+ "extracted_strength": get_field_value(extraction, "prescription_details", "strength"),
474
+ "extracted_sig": get_field_value(extraction, "prescription_details", "directions_sig"),
475
+ "extracted_quantity": get_field_value(extraction, "prescription_details", "quantity"),
476
+ "is_controlled_substance": extraction.get("document_metadata", {}).get("is_controlled_substance", False),
477
  "retrieved_display_name": display_name,
478
  "retrieved_canonical_name": medicine.get("name", "Unknown"),
479
  "retrieval_confidence": confidence,
480
+ "retrieved_strength": first_strength(medicine.get("strength", "")),
481
  "category": medicine.get("category", "Unknown"),
482
  "top_candidates": [
483
  {
 
488
  for item in retrieval_candidates[:3]
489
  ],
490
  }
491
+
492
+ # Check for compliance issues
493
+ compliance_flags = []
494
+ is_controlled = extraction.get("document_metadata", {}).get("is_controlled_substance", False)
495
+ if is_controlled:
496
+ if not get_field_value(extraction, "patient_info", "address"):
497
+ compliance_flags.append("MISSING_PATIENT_ADDRESS_FOR_CONTROLLED")
498
+ if not get_field_value(extraction, "prescriber_info", "address"):
499
+ compliance_flags.append("MISSING_PRESCRIBER_ADDRESS_FOR_CONTROLLED")
500
+ if not get_field_value(extraction, "prescriber_info", "dea_number"):
501
+ compliance_flags.append("MISSING_DEA_NUMBER_FOR_CONTROLLED")
502
+ validation_payload["compliance_flags"] = compliance_flags
503
+
504
+ return f"""You are a pharmacy prescription validation assistant.
505
 
506
  Input JSON:
507
  {json.dumps(validation_payload, ensure_ascii=False)}
508
 
509
  Task:
510
+ 1. Decide whether the retrieved medicine is safe to accept based on the OCR extraction and retrieval match.
511
  2. Translate the prescription into a clean pharmacy instruction row.
512
+ 3. Do NOT invent dose/timing/duration if not visible in the extracted data.
513
  4. If OCR and retrieved medicine clearly disagree, return needs_review.
514
+ 5. If this is a controlled substance and mandatory fields are missing, note it in validation_note.
515
+ 6. Check if extracted strength matches retrieved medicine strength.
516
 
517
  Return ONLY valid JSON with these keys:
518
  status: one of validated, needs_review
 
526
  instructions
527
  validation_note
528
  ocr_text
529
+ flags: list of any compliance or safety flags
530
  """
531
 
532
 
 
554
  messages=[{"role": "user", "content": prompt}],
555
  temperature=0,
556
  top_p=1,
557
+ max_tokens=512,
558
  )
559
  content = response.choices[0].message.content or ""
560
  plan = extract_json_object(content)
 
585
  )
586
 
587
 
588
+ def run_nemotron_inference(prompt: str) -> str:
589
+ """Run Nemotron inference locally, returning the raw generated text."""
590
+ global NEMOTRON_MODEL, NEMOTRON_TOKENIZER
591
+ import torch
592
+ from transformers import AutoModelForCausalLM, AutoTokenizer
593
+
594
+ if NEMOTRON_MODEL is None or NEMOTRON_TOKENIZER is None:
595
+ NEMOTRON_TOKENIZER = AutoTokenizer.from_pretrained(NEMOTRON_MODEL_ID, trust_remote_code=True)
596
+ NEMOTRON_MODEL = AutoModelForCausalLM.from_pretrained(
597
+ NEMOTRON_MODEL_ID,
598
+ trust_remote_code=True,
599
+ torch_dtype=torch.bfloat16,
600
+ device_map="auto",
601
+ ).eval()
602
+
603
+ messages = [{"role": "user", "content": prompt}]
604
+ if hasattr(NEMOTRON_TOKENIZER, "apply_chat_template"):
605
+ input_ids = NEMOTRON_TOKENIZER.apply_chat_template(
606
+ messages,
607
+ add_generation_prompt=True,
608
+ return_tensors="pt",
609
+ )
610
+ else:
611
+ input_ids = NEMOTRON_TOKENIZER(prompt, return_tensors="pt").input_ids
612
+
613
+ device = next(NEMOTRON_MODEL.parameters()).device
614
+ input_ids = input_ids.to(device)
615
+ with torch.inference_mode():
616
+ output_ids = NEMOTRON_MODEL.generate(
617
+ input_ids,
618
+ do_sample=False,
619
+ temperature=0.0,
620
+ top_p=1.0,
621
+ max_new_tokens=1024,
622
+ pad_token_id=NEMOTRON_TOKENIZER.eos_token_id,
623
+ )
624
+ generated = output_ids[0][input_ids.shape[-1]:]
625
+ return NEMOTRON_TOKENIZER.decode(generated, skip_special_tokens=True).strip()
626
+
627
+
628
+ def run_nemotron_nim_inference(prompt: str) -> str:
629
+ """Run Nemotron inference via NVIDIA NIM API, returning raw text."""
630
+ from openai import OpenAI
631
+ client = OpenAI(base_url=NVIDIA_BASE_URL, api_key=NVIDIA_API_KEY)
632
+ response = client.chat.completions.create(
633
+ model=NVIDIA_NIM_MODEL,
634
+ messages=[{"role": "user", "content": prompt}],
635
+ temperature=0,
636
+ top_p=1,
637
+ max_tokens=1024,
638
+ )
639
+ return response.choices[0].message.content or ""
640
+
641
+
642
+ def structure_ocr_with_nemotron(ocr_text: str) -> dict[str, Any]:
643
+ """Pass 2: Use Nemotron to structure raw OCR text into the clinical JSON schema."""
644
+ prompt = STRUCTURING_PROMPT_TEMPLATE.format(ocr_text=ocr_text)
645
+ try:
646
+ content = run_nemotron_inference(prompt)
647
+ return parse_structured_extraction(content, ocr_text)
648
+ except Exception as exc_local:
649
+ # Fallback to NVIDIA NIM API
650
+ if NVIDIA_API_KEY:
651
+ try:
652
+ content = run_nemotron_nim_inference(prompt)
653
+ return parse_structured_extraction(content, ocr_text)
654
+ except Exception:
655
+ pass
656
+ # Last resort: return extraction with just the drug name parsed from OCR
657
+ extraction = empty_extraction()
658
+ drug_guess = clean_prediction(ocr_text)
659
+ if drug_guess:
660
+ extraction["prescription_details"]["drug_name"] = _field(drug_guess, 0.3)
661
+ if is_controlled_substance(drug_guess):
662
+ extraction["document_metadata"]["is_controlled_substance"] = True
663
+ extraction["document_metadata"]["overall_legibility_score"] = 0.2
664
+ return extraction
665
+
666
+
667
  def validate_with_nemotron(
668
  ocr_text: str,
669
+ extraction: dict[str, Any],
670
  medicine: dict[str, Any],
671
  display_name: str,
672
  confidence: int,
 
674
  ) -> dict[str, Any]:
675
  global NEMOTRON_MODEL, NEMOTRON_TOKENIZER
676
 
677
+ prompt = build_validation_prompt(ocr_text, extraction, medicine, display_name, confidence, retrieval_candidates)
 
 
 
 
 
678
  try:
679
+ content = run_nemotron_inference(prompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  plan = extract_json_object(content)
681
  if plan.get("status") not in {"validated", "needs_review"}:
682
  plan["status"] = "needs_review"
 
716
  elif fallback_path.exists():
717
  text = fallback_path.read_text(encoding="utf-8", errors="ignore")
718
  if "ocr_accuracy" in text:
 
719
  ocr_accuracy = 0.37888446215139443
720
  retrieval_accuracy = 0.6055776892430279
721
 
 
771
  }.get(validation_status, "Nemotron Review")
772
  steps = [
773
  ("Prescription", "uploaded"),
774
+ ("MiniCPM OCR", "full text extraction"),
775
+ ("Nemotron Parse", "structured JSON"),
776
  ("Retrieval Engine", "ranked candidates"),
777
  (validation_label, "returned a decision"),
 
778
  ]
779
  cards = []
780
  logs = []
 
822
  )
823
  return f"""
824
  <div class="result-card">
825
+ <h3>Medicine Match</h3>
826
  <dl class="details">
827
  <dt>Medicine</dt><dd>{medicine_label}</dd>
828
  <dt>Generic</dt><dd>{generic_label}</dd>
 
834
  </dl>
835
  <div class="explain">
836
  <h4>AI Explanation</h4>
837
+ <p><b>OCR detected:</b> \"{ocr_text[:200]}{'...' if len(ocr_text) > 200 else ''}\"</p>
838
  <p><b>Retrieved:</b> {display_name} ({medicine.get('name', 'Unknown')})</p>
839
  <p><b>Validation:</b> {validation_label}</p>
840
  <p><b>Inventory:</b> {inventory_label}</p>
 
843
  """
844
 
845
 
846
+ def _confidence_badge(conf: float) -> str:
847
+ """Return a colored confidence badge."""
848
+ if conf >= 0.85:
849
+ color, bg = "#065f46", "#d1fae5"
850
+ elif conf >= 0.50:
851
+ color, bg = "#92400e", "#fef3c7"
852
+ elif conf > 0:
853
+ color, bg = "#991b1b", "#fee2e2"
854
+ else:
855
+ color, bg = "#6b7280", "#f3f4f6"
856
+ pct = f"{conf * 100:.0f}%"
857
+ return f'<span style="background:{bg};color:{color};padding:2px 8px;border-radius:12px;font-size:11px;font-weight:700;">{pct}</span>'
858
+
859
+
860
+ def _display_value(val: Any) -> str:
861
+ """Format a field value for display."""
862
+ if val is None:
863
+ return '<span style="color:#9ca3af;font-style:italic;">Not detected</span>'
864
+ if isinstance(val, bool):
865
+ return "Yes" if val else "No"
866
+ return str(val)
867
+
868
+
869
+ def extraction_card_html(extraction: dict[str, Any]) -> str:
870
+ """Build the full structured extraction card showing all extracted fields."""
871
+ sections = [
872
+ ("Patient Information", "patient_info", [
873
+ ("Name", "name"), ("Address", "address"),
874
+ ("Date of Birth", "date_of_birth"), ("Phone", "phone_number"),
875
+ ]),
876
+ ("Prescriber Information", "prescriber_info", [
877
+ ("Name", "name"), ("Signature Present", "signature_present"),
878
+ ("Address", "address"), ("DEA Number", "dea_number"),
879
+ ("NPI Number", "npi_number"), ("Phone", "phone_number"),
880
+ ]),
881
+ ("Prescription Details", "prescription_details", [
882
+ ("Date Issued", "date_of_issuance"), ("Drug Name", "drug_name"),
883
+ ("Strength", "strength"), ("Dosage Form", "dosage_form"),
884
+ ("Quantity", "quantity"), ("Directions (Sig)", "directions_sig"),
885
+ ("Refills", "refills_authorized"), ("Dispense As Written", "dispense_as_written"),
886
+ ]),
887
+ ]
888
+
889
+ legibility = extraction.get("document_metadata", {}).get("overall_legibility_score", 0)
890
+ html_parts = [f'<div class="extraction-card">']
891
+ html_parts.append(f'<div class="extraction-header"><h3>Full Prescription Extraction</h3>')
892
+ html_parts.append(f'<span class="legibility-badge">Legibility: {_confidence_badge(legibility)}</span></div>')
893
+
894
+ for section_title, section_key, fields in sections:
895
+ html_parts.append(f'<div class="extraction-section">')
896
+ html_parts.append(f'<h4>{section_title}</h4>')
897
+ html_parts.append('<dl class="extraction-fields">')
898
+ for label, field_key in fields:
899
+ field = extraction.get(section_key, {}).get(field_key, {})
900
+ val = field.get("value")
901
+ conf = field.get("confidence", 0.0)
902
+ html_parts.append(
903
+ f'<dt>{label}</dt>'
904
+ f'<dd>{_display_value(val)} {_confidence_badge(conf)}</dd>'
905
+ )
906
+ html_parts.append('</dl></div>')
907
+
908
+ html_parts.append('</div>')
909
+ return "\n".join(html_parts)
910
+
911
+
912
+ def compliance_banner_html(extraction: dict[str, Any]) -> str:
913
+ """Show controlled substance compliance status."""
914
+ is_controlled = extraction.get("document_metadata", {}).get("is_controlled_substance", False)
915
+ drug_name = get_field_value(extraction, "prescription_details", "drug_name") or "Unknown"
916
+
917
+ if not is_controlled:
918
+ return f"""
919
+ <div class="compliance-banner compliance-ok">
920
+ <strong>βœ“ Non-Controlled Substance</strong>
921
+ <span>Drug: {drug_name} β€” Patient address, prescriber DEA, and prescriber address are optional.</span>
922
+ </div>
923
+ """
924
+
925
+ # Check for missing mandatory fields
926
+ missing = []
927
+ if not get_field_value(extraction, "patient_info", "address"):
928
+ missing.append("Patient Address")
929
+ if not get_field_value(extraction, "prescriber_info", "address"):
930
+ missing.append("Prescriber Address")
931
+ if not get_field_value(extraction, "prescriber_info", "dea_number"):
932
+ missing.append("DEA Number")
933
+
934
+ if missing:
935
+ missing_list = ", ".join(missing)
936
+ return f"""
937
+ <div class="compliance-banner compliance-alert">
938
+ <strong>⚠ CONTROLLED SUBSTANCE β€” MISSING MANDATORY FIELDS</strong>
939
+ <span>Drug: {drug_name} β€” Missing: {missing_list}. Federal law requires these for DEA Schedule II-V drugs.</span>
940
+ </div>
941
+ """
942
+ else:
943
+ return f"""
944
+ <div class="compliance-banner compliance-warn">
945
+ <strong>⚑ Controlled Substance Detected</strong>
946
+ <span>Drug: {drug_name} β€” All mandatory fields (patient address, prescriber address, DEA) are present. Verify before dispensing.</span>
947
+ </div>
948
+ """
949
+
950
+
951
  def translated_prescription_html(plan: dict[str, Any]) -> str:
952
  rows = [
953
  ("Medicine", plan.get("medicine_name") or "Not confirmed"),
 
961
  ]
962
  row_html = "".join(f"<dt>{label}</dt><dd>{value}</dd>" for label, value in rows)
963
  status = plan.get("status", "needs_review").replace("_", " ").title()
964
+ flags = plan.get("flags", [])
965
+ flags_html = ""
966
+ if flags:
967
+ flags_html = '<div class="validation-flags">' + " ".join(
968
+ f'<span class="flag-pill">{f}</span>' for f in flags
969
+ ) + '</div>'
970
  return f"""
971
  <div class="translated-card">
972
  <div class="translated-head">
973
  <h3>Translated Prescription</h3>
974
  <span class="status-pill">{status}</span>
975
  </div>
976
+ {flags_html}
977
  <dl class="details translated-details">{row_html}</dl>
978
  <p class="fine-print">Generated from OCR text and retrieval candidates. Confirm before dispensing.</p>
979
  </div>
 
1023
  plan: dict[str, Any],
1024
  ) -> str:
1025
  corrected = display_name if plan.get("status") == "validated" else f"Needs review: {display_name}"
1026
+ # Truncate long OCR text for display
1027
+ ocr_display = ocr_text[:150] + "..." if len(ocr_text) > 150 else ocr_text
1028
  return f"""
1029
  <div class="compare-grid">
1030
+ <div><span>Raw OCR Output</span><strong>{ocr_display}</strong></div>
1031
  <div><span>AI Corrected</span><strong>{corrected}</strong></div>
1032
  <div><span>Canonical</span><strong>{medicine['name'] if plan.get('status') == 'validated' else 'Not confirmed'}</strong></div>
1033
  </div>
1034
  """
1035
 
1036
 
1037
+ # ── OCR Function (Pass 1: MiniCPM-V full text extraction) ────────────────────
1038
+
1039
  def run_minicpm_ocr(pil_image: Image.Image) -> str:
1040
+ """Pass 1: Use MiniCPM-V to read ALL text from the prescription image."""
1041
  global OCR_MODEL, OCR_TOKENIZER
 
 
1042
 
1043
  try:
1044
  import torch
 
1057
  if torch.cuda.is_available():
1058
  OCR_MODEL = OCR_MODEL.cuda()
1059
 
1060
+ messages = [{"role": "user", "content": [pil_image.convert("RGB"), FULL_OCR_PROMPT]}]
1061
  kwargs = {
1062
  "image": None,
1063
  "msgs": messages,
1064
  "tokenizer": OCR_TOKENIZER,
1065
  "sampling": False,
1066
  "stream": False,
1067
+ "max_new_tokens": 1024,
1068
  "enable_thinking": False,
1069
  "temperature": 0.0,
1070
  "top_p": 0.1,
 
1078
 
1079
  if not isinstance(raw_prediction, str):
1080
  raw_prediction = "".join(list(raw_prediction))
1081
+ return raw_prediction.strip()
1082
+
1083
 
1084
+ # ── Main Analysis Pipeline ───────────────────────────────────────────────────
1085
 
1086
  @spaces.GPU(duration=300)
1087
  def analyze_prescription(image, progress=gr.Progress()):
 
1089
  if image is None:
1090
  raise gr.Error("Upload or capture a prescription image first.")
1091
 
1092
+ # Step 1: Upload
1093
+ progress(0.10, desc="Prescription uploaded")
1094
+ time.sleep(0.1)
 
 
 
1095
 
1096
+ # Step 2: MiniCPM-V full text OCR
1097
+ progress(0.20, desc="MiniCPM-V reading full prescription text...")
1098
  ocr_text = run_minicpm_ocr(image)
1099
  unload_ocr_model()
1100
 
1101
+ # Step 3: Nemotron structuring
1102
+ progress(0.45, desc="Nemotron structuring extracted text into clinical JSON...")
1103
+ extraction = structure_ocr_with_nemotron(ocr_text)
 
 
 
 
1104
 
1105
+ # Step 4: Retrieval
1106
+ progress(0.65, desc="Retrieval search over medicine aliases...")
1107
+ drug_name = get_field_value(extraction, "prescription_details", "drug_name") or clean_prediction(ocr_text)
1108
+ strength_hint = get_field_value(extraction, "prescription_details", "strength")
1109
+ medicine, candidates, display_name, confidence = find_medicine_from_ocr(drug_name, strength_hint)
1110
+
1111
+ # Step 5: Validation
1112
+ progress(0.80, desc="Nemotron validating prescription...")
1113
+ plan = validate_with_nemotron(ocr_text, extraction, medicine, display_name, confidence, candidates)
1114
  unload_nemotron_model()
1115
+
1116
+ progress(1.00, desc="Result prepared")
1117
+
1118
  accepted = plan.get("status") == "validated" and confidence >= ACCEPTANCE_THRESHOLD
1119
  inventory = get_inventory(medicine)
1120
  image_path = resolve_asset_path(medicine.get("image_path"))
1121
+ package_image_val = str(image_path) if image_path and accepted else None
1122
 
1123
  state = {
1124
  "medicine_id": medicine["id"],
 
1133
  return (
1134
  load_kpi_metrics(SESSION_SEARCHES),
1135
  pipeline_html(5, plan.get("status", "needs_review")),
1136
+ compliance_banner_html(extraction),
1137
+ extraction_card_html(extraction),
1138
  medicine_details_html(medicine, inventory, ocr_text, display_name, confidence, plan),
1139
+ package_image_val,
1140
  package_status_html(inventory, accepted),
1141
  confidence_gauge(confidence),
1142
  candidates_html(candidates),
 
1394
  font-size: 13px;
1395
  }
1396
  .compact { margin-top: 0; }
1397
+
1398
+ /* ── Extraction Card Styles ──────────────────────────────────────────────── */
1399
+ .extraction-card {
1400
+ border: 1px solid var(--line);
1401
+ background: #ffffff;
1402
+ border-radius: 8px;
1403
+ padding: 20px;
1404
+ margin-top: 12px;
1405
+ box-shadow: 0 10px 26px rgba(15, 23, 42, 0.045);
1406
+ }
1407
+ .extraction-header {
1408
+ display: flex;
1409
+ justify-content: space-between;
1410
+ align-items: center;
1411
+ margin-bottom: 16px;
1412
+ }
1413
+ .extraction-header h3 { color: var(--ink) !important; margin: 0; font-size: 20px; }
1414
+ .legibility-badge { font-size: 13px; color: var(--muted); }
1415
+ .extraction-section {
1416
+ border-top: 1px solid var(--line);
1417
+ padding-top: 14px;
1418
+ margin-top: 14px;
1419
+ }
1420
+ .extraction-section h4 {
1421
+ color: var(--ink) !important;
1422
+ margin: 0 0 10px;
1423
+ font-size: 15px;
1424
+ font-weight: 700;
1425
+ }
1426
+ .extraction-fields {
1427
+ display: grid;
1428
+ grid-template-columns: 160px 1fr;
1429
+ gap: 6px 14px;
1430
+ margin: 0;
1431
+ }
1432
+ .extraction-fields dt { color: var(--muted) !important; font-size: 13px; }
1433
+ .extraction-fields dd { color: var(--ink) !important; margin: 0; font-weight: 600; font-size: 14px; }
1434
+
1435
+ /* ── Compliance Banner Styles ────────────────────────────────────────────── */
1436
+ .compliance-banner {
1437
+ border-radius: 8px;
1438
+ padding: 14px 18px;
1439
+ margin-bottom: 12px;
1440
+ display: flex;
1441
+ flex-direction: column;
1442
+ gap: 4px;
1443
+ }
1444
+ .compliance-banner strong { font-size: 14px; }
1445
+ .compliance-banner span { font-size: 13px; }
1446
+ .compliance-ok {
1447
+ background: #ecfdf5;
1448
+ border: 1px solid #86efac;
1449
+ color: #065f46;
1450
+ }
1451
+ .compliance-ok strong { color: #065f46; }
1452
+ .compliance-ok span { color: #047857; }
1453
+ .compliance-warn {
1454
+ background: #fffbeb;
1455
+ border: 1px solid #fcd34d;
1456
+ color: #92400e;
1457
+ }
1458
+ .compliance-warn strong { color: #92400e; }
1459
+ .compliance-warn span { color: #b45309; }
1460
+ .compliance-alert {
1461
+ background: #fef2f2;
1462
+ border: 1px solid #fca5a5;
1463
+ color: #991b1b;
1464
+ }
1465
+ .compliance-alert strong { color: #991b1b; }
1466
+ .compliance-alert span { color: #b91c1c; }
1467
+
1468
+ /* ── Validation Flags ────────���───────────────────────────────────────────── */
1469
+ .validation-flags {
1470
+ display: flex;
1471
+ flex-wrap: wrap;
1472
+ gap: 6px;
1473
+ margin-bottom: 10px;
1474
+ }
1475
+ .flag-pill {
1476
+ background: #fef3c7;
1477
+ border: 1px solid #fcd34d;
1478
+ color: #92400e;
1479
+ border-radius: 999px;
1480
+ padding: 3px 10px;
1481
+ font-size: 11px;
1482
+ font-weight: 700;
1483
+ }
1484
+
1485
  .gradio-container button.primary,
1486
  .gradio-container button[variant="primary"] {
1487
  background: var(--green) !important;
 
1498
  .powered { text-align: left; margin-top: 10px; }
1499
  .metric-row, .flow, .stock-card, .compare-grid { grid-template-columns: 1fr; }
1500
  .details { grid-template-columns: 1fr; }
1501
+ .extraction-fields { grid-template-columns: 1fr; }
1502
  .translated-head { align-items: flex-start; flex-direction: column; }
1503
  }
1504
  """
 
1543
  pipeline = gr.HTML(pipeline_html(0))
1544
 
1545
  with gr.Group(visible=False, elem_classes=["app-shell"]) as result_section:
1546
+ gr.Markdown("## Prescription Analysis Result")
1547
+ compliance_banner = gr.HTML()
1548
+ extraction_card = gr.HTML()
1549
  with gr.Row():
1550
  with gr.Column(scale=5):
1551
  details = gr.HTML()
 
1582
  outputs=[
1583
  live_metrics,
1584
  pipeline,
1585
+ compliance_banner,
1586
+ extraction_card,
1587
  details,
1588
  package_image,
1589
  stock,