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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
"retrieved_display_name": display_name,
|
| 292 |
"retrieved_canonical_name": medicine.get("name", "Unknown"),
|
| 293 |
"retrieval_confidence": confidence,
|
| 294 |
-
"
|
| 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 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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=
|
| 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 |
-
|
| 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 |
-
|
| 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", "
|
|
|
|
| 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>
|
| 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>{
|
| 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"),
|
| 705 |
kwargs = {
|
| 706 |
"image": None,
|
| 707 |
"msgs": messages,
|
| 708 |
"tokenizer": OCR_TOKENIZER,
|
| 709 |
"sampling": False,
|
| 710 |
"stream": False,
|
| 711 |
-
"max_new_tokens":
|
| 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
|
|
|
|
| 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 |
-
|
| 735 |
-
|
| 736 |
-
|
| 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 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
(1.00, "Result prepared"),
|
| 748 |
-
]:
|
| 749 |
-
progress(pct, desc=label)
|
| 750 |
-
time.sleep(0.25)
|
| 751 |
|
| 752 |
-
|
| 753 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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("##
|
|
|
|
|
|
|
| 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,
|