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