Spaces:
Running on Zero
Running on Zero
deploy API
Browse files- requirements.txt +2 -0
- serving/app/main.py +100 -12
- serving/app/schemas.py +59 -1
- serving/app/tools.py +88 -0
- src/epills/core/catalogue/identity.py +45 -0
- src/epills/core/imprint/ocr.py +15 -1
- src/epills/core/label.py +102 -0
- src/epills/core/safety/openfda.py +5 -0
requirements.txt
CHANGED
|
@@ -10,3 +10,5 @@ pillow>=11
|
|
| 10 |
huggingface_hub>=0.24 # downloads the model, catalogue and safety.sqlite from the private assets repo
|
| 11 |
python-multipart>=0.0.9 # image uploads
|
| 12 |
opencv-python-headless>=4.10 # the stand-in pill finder (/v1/scan)
|
|
|
|
|
|
|
|
|
| 10 |
huggingface_hub>=0.24 # downloads the model, catalogue and safety.sqlite from the private assets repo
|
| 11 |
python-multipart>=0.0.9 # image uploads
|
| 12 |
opencv-python-headless>=4.10 # the stand-in pill finder (/v1/scan)
|
| 13 |
+
pypdf>=5.0 # prescriptions as PDF: the text layer
|
| 14 |
+
pypdfium2>=4.30 # scanned PDFs: pages rendered for the text reader
|
serving/app/main.py
CHANGED
|
@@ -43,10 +43,12 @@ from pydantic import BaseModel, Field, ValidationError
|
|
| 43 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 44 |
|
| 45 |
from epills.core.catalogue.confidence import assess
|
|
|
|
| 46 |
from epills.core.catalogue.store import IMPRINT_ALPHA, IMPRINT_TOP_K
|
| 47 |
from epills.core.imprint.match import GALLERY_MIN_WEIGHT, read_hits
|
| 48 |
from epills.core.inference.matching import check_imprint, decide
|
| 49 |
from epills.core.inference.pill_finder import detect_pills
|
|
|
|
| 50 |
from epills.core.inference.preprocess import WINDOW, crop_window
|
| 51 |
from epills.core.safety import openfda
|
| 52 |
from epills.core.safety.db import SafetyDB
|
|
@@ -54,7 +56,7 @@ from epills.core.safety.rules import SOURCE_SCHEDULE, Alert, check_dose, check_m
|
|
| 54 |
from epills.core.safety.rxnorm import RxNormClient, RxNormError
|
| 55 |
from serving.app import schemas as S
|
| 56 |
|
| 57 |
-
API_VERSION = "0.
|
| 58 |
STARTED_AT = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
| 59 |
LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
|
| 60 |
"embedder": Path("outputs/serving/stage2_model.pt"),
|
|
@@ -66,6 +68,10 @@ LABEL_NOTE = ("Medicines read from the photo. Check the name and strength agains
|
|
| 66 |
VISUAL_LIMITS = ("A visual comparison with reference photos only: it can't confirm what the medicine is, its strength, "
|
| 67 |
"or that it is safe to take.")
|
| 68 |
NO_PILL = "No pill found. Spread the pills apart on a plain background and photograph them again."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
STAND_IN = "stand-in finder (plain backgrounds only)"
|
| 70 |
MATCH_MESSAGES = { # visual evidence only (blueprint D5): "looks like", never "is"
|
| 71 |
"match": "This pill looks like your {name}.",
|
|
@@ -388,8 +394,12 @@ def product_summary(cat, pill_id, **extra):
|
|
| 388 |
return {
|
| 389 |
"pill_id": pill_id, "name": p["name"], "rxnorm_name": p["rxnorm_name"], "generic_name": p["generic_name"],
|
| 390 |
"manufacturer": p["manufacturer"], "ingredients": [i["name"] for i in p["ingredients"]],
|
|
|
|
|
|
|
|
|
|
| 391 |
"shape": p["shape"], "colour": p["colour"], "size_mm": p["size_mm"], "imprint": p["imprint"],
|
| 392 |
"images": [{"url": f"/v1/pills/{pill_id}/image/{i['n']}", "kind": i["kind"]} for i in p["images"]],
|
|
|
|
| 393 |
**extra,
|
| 394 |
}
|
| 395 |
|
|
@@ -437,6 +447,16 @@ def health():
|
|
| 437 |
}
|
| 438 |
|
| 439 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
@app.get("/v1/model-info", response_model=S.ModelInfo)
|
| 441 |
def model_info():
|
| 442 |
"""
|
|
@@ -639,28 +659,79 @@ def advisory_dose(sched, pills, detection, meds):
|
|
| 639 |
|
| 640 |
|
| 641 |
@app.post("/v1/read-label", response_model=S.ReadLabelResponse)
|
| 642 |
-
async def read_label(image: UploadFile = File(description="A photo of a medicine box
|
| 643 |
-
"
|
|
|
|
|
|
|
|
|
|
| 644 |
"""
|
| 645 |
-
**Which medicine is on this box or
|
| 646 |
-
ingredient and brand names, international names such as paracetamol too), with strength,
|
| 647 |
-
code
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
| 649 |
"""
|
| 650 |
-
|
| 651 |
reader = asset("reader")
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
try:
|
| 654 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
except RxNormError as error:
|
| 656 |
raise HTTPException(502, f"RxNorm didn't answer: {error}")
|
| 657 |
-
result = {"candidates": candidates, "note": LABEL_NOTE, "ocr_model": reader.name}
|
| 658 |
if not candidates:
|
| 659 |
result["message"] = ("No medicine name found. Photograph the label closer, straight on and in good light, "
|
| 660 |
"or type the name.")
|
| 661 |
return result
|
| 662 |
|
| 663 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
@app.get("/v1/pills/search", response_model=S.SearchResponse)
|
| 665 |
def pills_search(name: str = Query(min_length=2, description="Brand or generic name, e.g. 'metformin', 'Glucophage'"),
|
| 666 |
strength: str | None = Query(None, description="e.g. '500 MG'"),
|
|
@@ -746,7 +817,8 @@ def safety_medications(request: MedicationsRequest):
|
|
| 746 |
"""
|
| 747 |
Run when a medication is added or changed: interactions between every pair of the patient's medications
|
| 748 |
(DDInter severity) and therapeutic duplications (the same active ingredient twice). Medications that can't be
|
| 749 |
-
identified are reported as alerts
|
|
|
|
| 750 |
"""
|
| 751 |
db = asset("safety_db")
|
| 752 |
resolved = [resolve_medication(m.model_dump(), app.state.rxnorm) for m in request.medications]
|
|
@@ -755,11 +827,27 @@ def safety_medications(request: MedicationsRequest):
|
|
| 755 |
"alerts": [a.to_dict() for a in alerts],
|
| 756 |
"medications": per_med,
|
| 757 |
"all_identified": all(m.resolved_by != "unresolved" for m in resolved),
|
|
|
|
|
|
|
| 758 |
"sources": {"interactions": db.meta.get("source"), "licence": db.meta.get("licence"),
|
| 759 |
"built_at": db.meta.get("built_at")},
|
| 760 |
}
|
| 761 |
|
| 762 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
@app.post("/v1/safety/dose", response_model=S.DoseResponse)
|
| 764 |
def safety_dose(request: DoseRequest):
|
| 765 |
"""
|
|
|
|
| 43 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 44 |
|
| 45 |
from epills.core.catalogue.confidence import assess
|
| 46 |
+
from epills.core.catalogue.identity import product_identity
|
| 47 |
from epills.core.catalogue.store import IMPRINT_ALPHA, IMPRINT_TOP_K
|
| 48 |
from epills.core.imprint.match import GALLERY_MIN_WEIGHT, read_hits
|
| 49 |
from epills.core.inference.matching import check_imprint, decide
|
| 50 |
from epills.core.inference.pill_finder import detect_pills
|
| 51 |
+
from epills.core.label import STRENGTH, compare_with_list, document_fields
|
| 52 |
from epills.core.inference.preprocess import WINDOW, crop_window
|
| 53 |
from epills.core.safety import openfda
|
| 54 |
from epills.core.safety.db import SafetyDB
|
|
|
|
| 56 |
from epills.core.safety.rxnorm import RxNormClient, RxNormError
|
| 57 |
from serving.app import schemas as S
|
| 58 |
|
| 59 |
+
API_VERSION = "0.7.0"
|
| 60 |
STARTED_AT = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
| 61 |
LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
|
| 62 |
"embedder": Path("outputs/serving/stage2_model.pt"),
|
|
|
|
| 68 |
VISUAL_LIMITS = ("A visual comparison with reference photos only: it can't confirm what the medicine is, its strength, "
|
| 69 |
"or that it is safe to take.")
|
| 70 |
NO_PILL = "No pill found. Spread the pills apart on a plain background and photograph them again."
|
| 71 |
+
SAFETY_NOTE = ("No alert doesn't mean these medicines are safe together: only interactions listed in DDInter 2.0 "
|
| 72 |
+
"between identified ingredients, and the same ingredient twice, are checked. Check with your pharmacist.")
|
| 73 |
+
IMAGE_LICENCE = ("NLM C3PI pill images: for research and development use. A catalogue picture may not show the product "
|
| 74 |
+
"the patient holds.")
|
| 75 |
STAND_IN = "stand-in finder (plain backgrounds only)"
|
| 76 |
MATCH_MESSAGES = { # visual evidence only (blueprint D5): "looks like", never "is"
|
| 77 |
"match": "This pill looks like your {name}.",
|
|
|
|
| 394 |
return {
|
| 395 |
"pill_id": pill_id, "name": p["name"], "rxnorm_name": p["rxnorm_name"], "generic_name": p["generic_name"],
|
| 396 |
"manufacturer": p["manufacturer"], "ingredients": [i["name"] for i in p["ingredients"]],
|
| 397 |
+
# identity fields (blueprint B13): never merge products across strength, dose form or release
|
| 398 |
+
**product_identity(p["rxnorm_name"]), "ndc9": p["ndc9"], "ndc11": p["ndc11"], "rxcui": p["rxcui"],
|
| 399 |
+
"rxnorm_status": p["rxnorm_status"], "market": "US",
|
| 400 |
"shape": p["shape"], "colour": p["colour"], "size_mm": p["size_mm"], "imprint": p["imprint"],
|
| 401 |
"images": [{"url": f"/v1/pills/{pill_id}/image/{i['n']}", "kind": i["kind"]} for i in p["images"]],
|
| 402 |
+
"image_licence": IMAGE_LICENCE,
|
| 403 |
**extra,
|
| 404 |
}
|
| 405 |
|
|
|
|
| 447 |
}
|
| 448 |
|
| 449 |
|
| 450 |
+
@app.get("/v1/tools")
|
| 451 |
+
def tools():
|
| 452 |
+
"""
|
| 453 |
+
Typed tool definitions for the Medication Care Agent (blueprint B14), generated from this API's OpenAPI document:
|
| 454 |
+
name, description for the agent, input and output schema, and how to call each. See docs/agent_tools.md.
|
| 455 |
+
"""
|
| 456 |
+
from serving.app.tools import build_tools
|
| 457 |
+
return build_tools(app.openapi())
|
| 458 |
+
|
| 459 |
+
|
| 460 |
@app.get("/v1/model-info", response_model=S.ModelInfo)
|
| 461 |
def model_info():
|
| 462 |
"""
|
|
|
|
| 659 |
|
| 660 |
|
| 661 |
@app.post("/v1/read-label", response_model=S.ReadLabelResponse)
|
| 662 |
+
async def read_label(image: UploadFile = File(description="A photo of a medicine box, a pharmacy label or a "
|
| 663 |
+
"prescription (close up, straight on), or a PDF"),
|
| 664 |
+
medications: str | None = Form(None, description="Optional JSON list of the patient's "
|
| 665 |
+
"medications, [{id, name, rxcui?, strength?}]: "
|
| 666 |
+
"each medicine read is compared with it")):
|
| 667 |
"""
|
| 668 |
+
**Which medicine is on this box, label or prescription?** Reads the printed text and finds the medicine names in
|
| 669 |
+
it (RxNorm's ingredient and brand names, international names such as paracetamol too), with strength,
|
| 670 |
+
ingredients, RxNorm code, and what is printed about each (directions, quantity, dose form, dates), every value
|
| 671 |
+
with the line it was read from and `null` when not printed (blueprint B7). With `medications`, each medicine is
|
| 672 |
+
compared with the patient's list: same medicine, different strength, or not on the list (B9). Send a confirmed
|
| 673 |
+
candidate to `/v1/safety/medications` as `{"id", "name", "rxcui"}`. Always ask the user to confirm.
|
| 674 |
+
Nothing is stored; only the lines that name or describe a medicine are returned (labels show the patient's name).
|
| 675 |
"""
|
| 676 |
+
data = await image.read()
|
| 677 |
reader = asset("reader")
|
| 678 |
+
if data[:5] == b"%PDF-" or (image.filename or "").lower().endswith(".pdf"):
|
| 679 |
+
lines, source = await run_in_threadpool(pdf_lines, data, reader)
|
| 680 |
+
else:
|
| 681 |
+
try:
|
| 682 |
+
photo = ImageOps.exif_transpose(Image.open(io.BytesIO(data))).convert("RGB")
|
| 683 |
+
except (UnidentifiedImageError, OSError) as error:
|
| 684 |
+
raise HTTPException(422, f"{image.filename or 'image'} is not a readable image or PDF: {error}")
|
| 685 |
+
lines, source = await run_in_threadpool(reader.read_text, photo), "photo"
|
| 686 |
+
meds = parse_json(medications, "medications") if medications else None
|
| 687 |
try:
|
| 688 |
+
label_reader = asset("label_reader")
|
| 689 |
+
candidates = await run_in_threadpool(label_reader.read, lines)
|
| 690 |
+
document_fields(lines, candidates)
|
| 691 |
+
if meds is not None:
|
| 692 |
+
listed = await run_in_threadpool(resolve_list, meds)
|
| 693 |
+
for c in candidates:
|
| 694 |
+
c["list_check"] = compare_with_list(c, listed)
|
| 695 |
except RxNormError as error:
|
| 696 |
raise HTTPException(502, f"RxNorm didn't answer: {error}")
|
| 697 |
+
result = {"candidates": candidates, "note": LABEL_NOTE, "ocr_model": reader.name, "source": source}
|
| 698 |
if not candidates:
|
| 699 |
result["message"] = ("No medicine name found. Photograph the label closer, straight on and in good light, "
|
| 700 |
"or type the name.")
|
| 701 |
return result
|
| 702 |
|
| 703 |
|
| 704 |
+
def pdf_lines(data, reader, max_pages=3):
|
| 705 |
+
"""A PDF's lines: its text layer when it has one, else its pages rendered and read like photos."""
|
| 706 |
+
from pypdf import PdfReader
|
| 707 |
+
try:
|
| 708 |
+
pages = PdfReader(io.BytesIO(data)).pages[:max_pages]
|
| 709 |
+
lines = [line.strip() for page in pages for line in (page.extract_text() or "").splitlines() if line.strip()]
|
| 710 |
+
except Exception as error:
|
| 711 |
+
raise HTTPException(422, f"not a readable PDF: {error}")
|
| 712 |
+
if sum(len(line) for line in lines) >= 20:
|
| 713 |
+
return lines, "pdf_text"
|
| 714 |
+
import pypdfium2
|
| 715 |
+
pdf = pypdfium2.PdfDocument(data)
|
| 716 |
+
lines = []
|
| 717 |
+
for i in range(min(len(pdf), max_pages)):
|
| 718 |
+
lines += reader.read_text(pdf[i].render(scale=2).to_pil())
|
| 719 |
+
return lines, "pdf_scan"
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def resolve_list(meds):
|
| 723 |
+
"""The patient's list for the comparison: each medication's ingredients (RxNorm) and printed strength."""
|
| 724 |
+
out = []
|
| 725 |
+
for m in meds:
|
| 726 |
+
if not isinstance(m, dict) or not m.get("id"):
|
| 727 |
+
raise HTTPException(422, "medications: each needs an id (and a name or rxcui)")
|
| 728 |
+
r = resolve_medication({"id": str(m["id"]), "name": m.get("name"), "rxcui": m.get("rxcui")}, app.state.rxnorm)
|
| 729 |
+
strength = m.get("strength") or next((x.group() for x in STRENGTH.finditer(m.get("name") or "")), None)
|
| 730 |
+
out.append({"id": str(m["id"]), "name": m.get("name") or r.display_name,
|
| 731 |
+
"ingredients": {i.rxcui for i in r.ingredients if i.rxcui}, "strength": strength})
|
| 732 |
+
return out
|
| 733 |
+
|
| 734 |
+
|
| 735 |
@app.get("/v1/pills/search", response_model=S.SearchResponse)
|
| 736 |
def pills_search(name: str = Query(min_length=2, description="Brand or generic name, e.g. 'metformin', 'Glucophage'"),
|
| 737 |
strength: str | None = Query(None, description="e.g. '500 MG'"),
|
|
|
|
| 817 |
"""
|
| 818 |
Run when a medication is added or changed: interactions between every pair of the patient's medications
|
| 819 |
(DDInter severity) and therapeutic duplications (the same active ingredient twice). Medications that can't be
|
| 820 |
+
identified are reported as alerts. `coverage` says what could actually be screened: **no alert does not mean the
|
| 821 |
+
medicines are safe together** (blueprint B10).
|
| 822 |
"""
|
| 823 |
db = asset("safety_db")
|
| 824 |
resolved = [resolve_medication(m.model_dump(), app.state.rxnorm) for m in request.medications]
|
|
|
|
| 827 |
"alerts": [a.to_dict() for a in alerts],
|
| 828 |
"medications": per_med,
|
| 829 |
"all_identified": all(m.resolved_by != "unresolved" for m in resolved),
|
| 830 |
+
"coverage": coverage(per_med),
|
| 831 |
+
"note": SAFETY_NOTE,
|
| 832 |
"sources": {"interactions": db.meta.get("source"), "licence": db.meta.get("licence"),
|
| 833 |
"built_at": db.meta.get("built_at")},
|
| 834 |
}
|
| 835 |
|
| 836 |
|
| 837 |
+
def coverage(per_med):
|
| 838 |
+
"""What the interaction check could screen: medications with at least one ingredient in DDInter, and pairs."""
|
| 839 |
+
screened = {m["id"] for m in per_med
|
| 840 |
+
if len(m["ingredients"]) > len(m["not_in_interaction_database"])}
|
| 841 |
+
ids = [m["id"] for m in per_med]
|
| 842 |
+
pairs = [(a, b) for i, a in enumerate(ids) for b in ids[i + 1:]]
|
| 843 |
+
checked = sum(a in screened and b in screened for a, b in pairs)
|
| 844 |
+
return {"medications": len(per_med), "screened": len(screened),
|
| 845 |
+
"not_screened": [m["name"] for m in per_med if m["id"] not in screened],
|
| 846 |
+
"ingredients_not_in_interaction_database": sorted({n for m in per_med
|
| 847 |
+
for n in m["not_in_interaction_database"]}),
|
| 848 |
+
"pairs": len(pairs), "pairs_checked": checked, "pairs_not_checkable": len(pairs) - checked}
|
| 849 |
+
|
| 850 |
+
|
| 851 |
@app.post("/v1/safety/dose", response_model=S.DoseResponse)
|
| 852 |
def safety_dose(request: DoseRequest):
|
| 853 |
"""
|
serving/app/schemas.py
CHANGED
|
@@ -121,6 +121,11 @@ class Picture(Out):
|
|
| 121 |
kind: str
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
class Product(Out):
|
| 125 |
pill_id: str
|
| 126 |
name: str | None
|
|
@@ -128,11 +133,24 @@ class Product(Out):
|
|
| 128 |
generic_name: str | None
|
| 129 |
manufacturer: str | None
|
| 130 |
ingredients: list[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
shape: str | None
|
| 132 |
colour: str | None
|
| 133 |
size_mm: float | None
|
| 134 |
imprint: str | None
|
| 135 |
images: list[Picture]
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
class Candidate(Product):
|
|
@@ -230,6 +248,25 @@ class Ingredient(Out):
|
|
| 230 |
name: str
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
class LabelCandidate(Out):
|
| 234 |
name: str
|
| 235 |
brand: str | None
|
|
@@ -238,12 +275,17 @@ class LabelCandidate(Out):
|
|
| 238 |
rxcui: str | None
|
| 239 |
rxnorm_name: str | None
|
| 240 |
found_in: list[str] = Field(description="Only the lines that name the medicine")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
|
| 243 |
class ReadLabelResponse(Out):
|
| 244 |
candidates: list[LabelCandidate]
|
| 245 |
note: str
|
| 246 |
ocr_model: str
|
|
|
|
| 247 |
message: str | None = None
|
| 248 |
|
| 249 |
|
|
@@ -259,20 +301,36 @@ class SearchResponse(Out):
|
|
| 259 |
|
| 260 |
|
| 261 |
# ---- Stage 3 and feedback --------------------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
class MedicationsResponse(Out):
|
| 263 |
alerts: list[Alert]
|
| 264 |
medications: list[dict]
|
| 265 |
all_identified: bool
|
|
|
|
|
|
|
| 266 |
sources: dict
|
| 267 |
|
| 268 |
|
| 269 |
class LabelResponse(Out):
|
| 270 |
brand_name: str | None
|
| 271 |
generic_name: str | None
|
| 272 |
-
label_date: str | None
|
| 273 |
matched_by: str
|
| 274 |
sections: dict[str, str]
|
| 275 |
disclaimer: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
|
| 278 |
class FeedbackStored(Out):
|
|
|
|
| 121 |
kind: str
|
| 122 |
|
| 123 |
|
| 124 |
+
class Strength(Out):
|
| 125 |
+
ingredient: str
|
| 126 |
+
strength: str
|
| 127 |
+
|
| 128 |
+
|
| 129 |
class Product(Out):
|
| 130 |
pill_id: str
|
| 131 |
name: str | None
|
|
|
|
| 133 |
generic_name: str | None
|
| 134 |
manufacturer: str | None
|
| 135 |
ingredients: list[str]
|
| 136 |
+
strength: str | None = Field(None, description="e.g. '500 MG', or '40 MG / 1100 MG' for a combination")
|
| 137 |
+
strengths: list[Strength] = []
|
| 138 |
+
dose_form: str | None = Field(None, description="e.g. 'Oral Tablet', 'Extended Release Oral Capsule', 'Pack'")
|
| 139 |
+
release: Literal["IR", "ER", "DR"] | None = Field(None, description="Immediate, extended or delayed release")
|
| 140 |
+
brand: str | None = None
|
| 141 |
+
brand_or_generic: Literal["brand", "generic"] | None = None
|
| 142 |
+
pack: list[dict] | None = Field(None, description="For packs: each kind of pill, with count and strength")
|
| 143 |
+
ndc9: str | None = None
|
| 144 |
+
ndc11: str | None = None
|
| 145 |
+
rxcui: str | None = None
|
| 146 |
+
rxnorm_status: str | None = None
|
| 147 |
+
market: Literal["US"] = "US"
|
| 148 |
shape: str | None
|
| 149 |
colour: str | None
|
| 150 |
size_mm: float | None
|
| 151 |
imprint: str | None
|
| 152 |
images: list[Picture]
|
| 153 |
+
image_licence: str | None = None
|
| 154 |
|
| 155 |
|
| 156 |
class Candidate(Product):
|
|
|
|
| 248 |
name: str
|
| 249 |
|
| 250 |
|
| 251 |
+
class Printed(Out):
|
| 252 |
+
value: str
|
| 253 |
+
line: str = Field(description="The line(s) it was read from")
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class DocumentFields(Out):
|
| 257 |
+
directions: Printed | None = Field(None, description="As printed, e.g. 'Take 1 tablet twice a day after meals'")
|
| 258 |
+
quantity: Printed | None = None
|
| 259 |
+
dose_form: Printed | None = None
|
| 260 |
+
date: Printed | None = Field(None, description="Dispensing or prescription date, as printed")
|
| 261 |
+
expiry_date: Printed | None = None
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
class ListCheck(Out):
|
| 265 |
+
status: Literal["same_medicine", "different_strength", "contains_listed", "not_on_list"]
|
| 266 |
+
medication_id: str | None
|
| 267 |
+
message: str
|
| 268 |
+
|
| 269 |
+
|
| 270 |
class LabelCandidate(Out):
|
| 271 |
name: str
|
| 272 |
brand: str | None
|
|
|
|
| 275 |
rxcui: str | None
|
| 276 |
rxnorm_name: str | None
|
| 277 |
found_in: list[str] = Field(description="Only the lines that name the medicine")
|
| 278 |
+
first_line: int | None = None
|
| 279 |
+
fields: DocumentFields | None = Field(None, description="What is printed about this medicine; null when not "
|
| 280 |
+
"printed, never inferred")
|
| 281 |
+
list_check: ListCheck | None = Field(None, description="With `medications`: compared with the patient's list")
|
| 282 |
|
| 283 |
|
| 284 |
class ReadLabelResponse(Out):
|
| 285 |
candidates: list[LabelCandidate]
|
| 286 |
note: str
|
| 287 |
ocr_model: str
|
| 288 |
+
source: Literal["photo", "pdf_text", "pdf_scan"] = "photo"
|
| 289 |
message: str | None = None
|
| 290 |
|
| 291 |
|
|
|
|
| 301 |
|
| 302 |
|
| 303 |
# ---- Stage 3 and feedback --------------------------------------------------------------------------------------------
|
| 304 |
+
class Coverage(Out):
|
| 305 |
+
medications: int
|
| 306 |
+
screened: int = Field(description="Medications with at least one ingredient in the interaction database")
|
| 307 |
+
not_screened: list[str]
|
| 308 |
+
ingredients_not_in_interaction_database: list[str]
|
| 309 |
+
pairs: int
|
| 310 |
+
pairs_checked: int
|
| 311 |
+
pairs_not_checkable: int
|
| 312 |
+
|
| 313 |
+
|
| 314 |
class MedicationsResponse(Out):
|
| 315 |
alerts: list[Alert]
|
| 316 |
medications: list[dict]
|
| 317 |
all_identified: bool
|
| 318 |
+
coverage: Coverage
|
| 319 |
+
note: str = Field(description="No alert doesn't mean the medicines are safe together")
|
| 320 |
sources: dict
|
| 321 |
|
| 322 |
|
| 323 |
class LabelResponse(Out):
|
| 324 |
brand_name: str | None
|
| 325 |
generic_name: str | None
|
| 326 |
+
label_date: str | None = Field(description="The label's effective date (YYYYMMDD)")
|
| 327 |
matched_by: str
|
| 328 |
sections: dict[str, str]
|
| 329 |
disclaimer: str
|
| 330 |
+
source: str | None = None
|
| 331 |
+
set_id: str | None = None
|
| 332 |
+
label_id: str | None = None
|
| 333 |
+
url: str | None = Field(None, description="The label on DailyMed, for citation")
|
| 334 |
|
| 335 |
|
| 336 |
class FeedbackStored(Out):
|
serving/app/tools.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Typed tool definitions for the Medication Care Agent (blueprint job B14), generated from the API's own OpenAPI
|
| 3 |
+
document so they can't drift from it. Served at GET /v1/tools; usage notes and example traces in docs/agent_tools.md.
|
| 4 |
+
|
| 5 |
+
Each tool: name (the blueprint's where it names one), description written for the agent (when to call it, what it
|
| 6 |
+
must not conclude), input_schema (JSON Schema; an uploaded file becomes an image reference the app's tool layer
|
| 7 |
+
resolves), output_schema ($refs into `components`), and how to call it (method, path, content type).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
TOOLS = [
|
| 11 |
+
("detect_pills", "post", "/v1/detect",
|
| 12 |
+
"Find each pill in a photo and say whether the count can be trusted. Call before counting pills or embedding "
|
| 13 |
+
"several pills. If count_reliable is false, don't count: ask for a new photo (pills apart, plain surface)."),
|
| 14 |
+
("embed_pill", "post", "/v1/embed",
|
| 15 |
+
"Turn one loose-pill photo (or a photo + box) into a 256-number embedding. Use at enrolment (store it with "
|
| 16 |
+
"model_version and embedding_space_id) and before match_gallery. Never embed boxes or blister packs."),
|
| 17 |
+
("match_gallery", "post", "/v1/match",
|
| 18 |
+
"Compare pill embeddings with the patient's gallery: match / unknown / ambiguous per pill. Visual evidence only: "
|
| 19 |
+
"a match means 'looks like', not 'is' or 'safe to take'. ambiguous: ask for another photo. The gallery's "
|
| 20 |
+
"model_version is required; on 409 re-embed the enrolment photos."),
|
| 21 |
+
("identify_pill", "post", "/v1/identify",
|
| 22 |
+
"Possible catalogue matches (US products) for a pill that isn't in the patient's list. Report the verdict and "
|
| 23 |
+
"confidence, never a definite identity; no_confident_match means the pill may not be a catalogue product."),
|
| 24 |
+
("scan_photo", "post", "/v1/scan",
|
| 25 |
+
"Diagnostics in one call: detection plus each pill's visual decision (with a gallery) or catalogue candidates. "
|
| 26 |
+
"Don't use its dose check (deprecated): apply the backend's dose rules to the pills it returns."),
|
| 27 |
+
("extract_medication_document", "post", "/v1/read-label",
|
| 28 |
+
"Read a medicine box, pharmacy label or prescription (photo or PDF): medicine names, strength, RxNorm code and "
|
| 29 |
+
"what is printed (directions, quantity, dates), each with its source line. Fields not printed are null; never "
|
| 30 |
+
"fill them in. With the patient's medications, says same medicine / different strength / not on the list. "
|
| 31 |
+
"Always have the patient confirm."),
|
| 32 |
+
("find_medication_product", "get", "/v1/pills/search",
|
| 33 |
+
"Products and pictures for a medicine name (brand or generic), with strength, dose form and release as fields. "
|
| 34 |
+
"Never treat products of another strength or form as the same."),
|
| 35 |
+
("screen_medication_list", "post", "/v1/safety/medications",
|
| 36 |
+
"Interaction and duplicate-ingredient alerts for the whole medication list (DDInter 2.0). Read coverage: no "
|
| 37 |
+
"alert does not mean safe. Advisory; defer to the pharmacist."),
|
| 38 |
+
("evaluate_dose_reference", "post", "/v1/safety/dose",
|
| 39 |
+
"The reference dose rules on visual decisions (stateless). The backend's own dose policy is authoritative."),
|
| 40 |
+
("retrieve_medication_info", "get", "/v1/safety/label",
|
| 41 |
+
"Warning sections of the official US label, with the label's date and DailyMed link for citation. General "
|
| 42 |
+
"information, not the patient's instructions."),
|
| 43 |
+
("get_model_info", "get", "/v1/model-info",
|
| 44 |
+
"The served model: version, embedding space, decision rule and known limits. Check after a 409."),
|
| 45 |
+
]
|
| 46 |
+
FILE_NOTE = "An image reference: the app's tool layer uploads the file (photo, or PDF for documents)"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _resolve(schema, components):
|
| 50 |
+
ref = schema.get("$ref", "") if isinstance(schema, dict) else ""
|
| 51 |
+
return components.get(ref.rsplit("/", 1)[-1], {}) if ref else schema
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _input_schema(op, components):
|
| 55 |
+
props, required = {}, []
|
| 56 |
+
for p in op.get("parameters", []):
|
| 57 |
+
props[p["name"]] = {**p.get("schema", {}), "description": p.get("description", "")}
|
| 58 |
+
if p.get("required"):
|
| 59 |
+
required.append(p["name"])
|
| 60 |
+
content = op.get("requestBody", {}).get("content", {})
|
| 61 |
+
for kind in ("application/json", "multipart/form-data"):
|
| 62 |
+
if kind in content:
|
| 63 |
+
body = _resolve(content[kind]["schema"], components)
|
| 64 |
+
for name, s in body.get("properties", {}).items():
|
| 65 |
+
is_file = s.get("format") == "binary" or s.get("contentMediaType") or \
|
| 66 |
+
(s.get("type") == "array" and (s.get("items", {}).get("format") == "binary"
|
| 67 |
+
or s.get("items", {}).get("contentMediaType")))
|
| 68 |
+
props[name] = ({"type": "array", "items": {"type": "string"}, "description": FILE_NOTE}
|
| 69 |
+
if is_file and s.get("type") == "array" else
|
| 70 |
+
{"type": "string", "description": FILE_NOTE} if is_file else s)
|
| 71 |
+
required += body.get("required", [])
|
| 72 |
+
return {"type": "object", "properties": props, "required": sorted(set(required))}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_tools(openapi):
|
| 76 |
+
components = openapi.get("components", {}).get("schemas", {})
|
| 77 |
+
tools = []
|
| 78 |
+
for name, method, path, description in TOOLS:
|
| 79 |
+
op = openapi["paths"][path][method]
|
| 80 |
+
ok = op.get("responses", {}).get("200", {}).get("content", {}).get("application/json", {})
|
| 81 |
+
kind = next(iter(op.get("requestBody", {}).get("content", {"none": None})))
|
| 82 |
+
tools.append({"name": name, "description": description, "input_schema": _input_schema(op, components),
|
| 83 |
+
"output_schema": ok.get("schema", {}),
|
| 84 |
+
"call": {"method": method.upper(), "path": path,
|
| 85 |
+
"content_type": None if kind == "none" else kind}})
|
| 86 |
+
return {"tools": tools, "components": {"schemas": components},
|
| 87 |
+
"notes": "Stage 2 answers are visual evidence only. The backend's safety policy decides actions; the "
|
| 88 |
+
"agent reports evidence with its limits and asks the patient to confirm."}
|
src/epills/core/catalogue/identity.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
A catalogue product's identity, from its RxNorm name (blueprint job B13): products must never be merged across
|
| 3 |
+
strength, dose form or release, so these are returned as fields, not left inside a name.
|
| 4 |
+
|
| 5 |
+
RxNorm clinical and branded drug names follow one pattern:
|
| 6 |
+
[12 HR | 24 HR] <ingredient> <amount> <unit> [/ <ingredient> <amount> <unit> ...] <dose form> [<Brand>]
|
| 7 |
+
e.g. "24 HR metformin hydrochloride 500 MG Extended Release Oral Tablet [Glucophage]".
|
| 8 |
+
"""
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
UNIT = r"(?:MG|MCG|UNT|MEQ|ML|%|MG/ML|MG/MG|MCG/ML|UNT/ML|MEQ/ML|MG/HR|MCG/HR|MG/ACTUAT|MCG/ACTUAT|ACTUAT)"
|
| 12 |
+
AMOUNT = re.compile(rf"(\d+(?:\.\d+)?)\s+({UNIT})(?=\s|$)")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def product_identity(rxnorm_name, name=None):
|
| 16 |
+
"""-> strength, strengths (per ingredient), dose_form, release (IR / ER / DR), brand, brand_or_generic."""
|
| 17 |
+
out = {"strength": None, "strengths": [], "dose_form": None, "release": None, "brand": None,
|
| 18 |
+
"brand_or_generic": None}
|
| 19 |
+
text = (rxnorm_name or "").strip()
|
| 20 |
+
if not text:
|
| 21 |
+
return out
|
| 22 |
+
brand = re.search(r"\s*\[([^\]]+)\]\s*$", text)
|
| 23 |
+
if brand:
|
| 24 |
+
out["brand"], text = brand.group(1), text[:brand.start()]
|
| 25 |
+
out["brand_or_generic"] = "brand" if brand else "generic"
|
| 26 |
+
if text.startswith("{"): # a pack, e.g. "{7 (... Oral Tablet) / 7 (... Oral Tablet) } Pack": several pills
|
| 27 |
+
out["dose_form"], out["release"] = "Pack", "IR"
|
| 28 |
+
out["pack"] = [{"count": int(n), **{k: v for k, v in product_identity(re.sub(r"\s*\[[^\]]*\]", "", c)).items()
|
| 29 |
+
if k in ("strength", "dose_form", "release")}}
|
| 30 |
+
for n, c in re.findall(r"(\d+) \(([^()]*)\)", text)]
|
| 31 |
+
return out
|
| 32 |
+
upper = text.upper()
|
| 33 |
+
out["release"] = ("ER" if re.match(r"^\d+ HR ", text) or "EXTENDED RELEASE" in upper else
|
| 34 |
+
"DR" if "DELAYED RELEASE" in upper else "IR")
|
| 35 |
+
amounts = list(AMOUNT.finditer(text))
|
| 36 |
+
if amounts:
|
| 37 |
+
out["dose_form"] = text[amounts[-1].end():].strip() or None
|
| 38 |
+
body = re.sub(r"^\d+ HR ", "", text[:amounts[-1].end()])
|
| 39 |
+
for part in body.split(" / "):
|
| 40 |
+
m = AMOUNT.search(part)
|
| 41 |
+
if m:
|
| 42 |
+
out["strengths"].append({"ingredient": part[:m.start()].strip(),
|
| 43 |
+
"strength": f"{m.group(1)} {m.group(2)}"})
|
| 44 |
+
out["strength"] = " / ".join(s["strength"] for s in out["strengths"]) or None
|
| 45 |
+
return out
|
src/epills/core/imprint/ocr.py
CHANGED
|
@@ -52,8 +52,22 @@ class ImprintReader:
|
|
| 52 |
return [texts[i:i + n] for i in range(0, len(texts), n)]
|
| 53 |
|
| 54 |
def read_text(self, image, max_new_tokens=512):
|
| 55 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 56 |
image = image.convert("RGB")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
x = self.processor(text=["<OCR_WITH_REGION>"], images=[image], return_tensors="pt")
|
| 58 |
raw = self.processor.batch_decode(self.generate(x["input_ids"], x["pixel_values"], max_new_tokens),
|
| 59 |
skip_special_tokens=False)[0]
|
|
|
|
| 52 |
return [texts[i:i + n] for i in range(0, len(texts), n)]
|
| 53 |
|
| 54 |
def read_text(self, image, max_new_tokens=512):
|
| 55 |
+
"""
|
| 56 |
+
A photo of a box, label or document page -> its lines of text, top to bottom (Florence-2's OCR with
|
| 57 |
+
regions). Florence sees every image at 768 x 768, so a tall page is read in overlapping bands.
|
| 58 |
+
"""
|
| 59 |
image = image.convert("RGB")
|
| 60 |
+
w, h = image.size
|
| 61 |
+
if h <= 1.25 * w:
|
| 62 |
+
return self._read_region(image, max_new_tokens)
|
| 63 |
+
band, lines = int(w * 0.8), []
|
| 64 |
+
for top in range(0, max(1, h - band // 6), int(band * 0.85)):
|
| 65 |
+
for line in self._read_region(image.crop((0, top, w, min(h, top + band))), max_new_tokens):
|
| 66 |
+
if line not in lines[-8:]: # the overlap reads some lines twice
|
| 67 |
+
lines.append(line)
|
| 68 |
+
return lines
|
| 69 |
+
|
| 70 |
+
def _read_region(self, image, max_new_tokens):
|
| 71 |
x = self.processor(text=["<OCR_WITH_REGION>"], images=[image], return_tensors="pt")
|
| 72 |
raw = self.processor.batch_decode(self.generate(x["input_ids"], x["pixel_values"], max_new_tokens),
|
| 73 |
skip_special_tokens=False)[0]
|
src/epills/core/label.py
CHANGED
|
@@ -243,7 +243,109 @@ class LabelReader:
|
|
| 243 |
"rxcui": product[0] if product else (lead.rxcui if lead.tty != "INN" else g["ingredients"][0][0]),
|
| 244 |
"rxnorm_name": product[1] if product else None,
|
| 245 |
"found_in": [lines[i] for i in sorted({f.line for f in found})],
|
|
|
|
| 246 |
"_rank": (-(bool(brand) + bool(strengths) + bool(product) + len(found)), found[0].line),
|
| 247 |
})
|
| 248 |
out.sort(key=lambda c: c.pop("_rank"))
|
| 249 |
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
"rxcui": product[0] if product else (lead.rxcui if lead.tty != "INN" else g["ingredients"][0][0]),
|
| 244 |
"rxnorm_name": product[1] if product else None,
|
| 245 |
"found_in": [lines[i] for i in sorted({f.line for f in found})],
|
| 246 |
+
"first_line": found[0].line,
|
| 247 |
"_rank": (-(bool(brand) + bool(strengths) + bool(product) + len(found)), found[0].line),
|
| 248 |
})
|
| 249 |
out.sort(key=lambda c: c.pop("_rank"))
|
| 250 |
return out
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ---- what else a label or prescription says (blueprint job B7) -------------------------------------------------------
|
| 254 |
+
DIRECTION_START = re.compile(r"^\s*(take|apply|use|inhale|instil+|insert|dissolve|chew|swallow|place|spray|give|put|"
|
| 255 |
+
r"rub|gargle|dilute|one|two|1|2)\b", re.I)
|
| 256 |
+
DIRECTION_WORDS = re.compile(r"\b(times? (a|per) day|(once|twice|thrice) (a|per) day|daily|every \d+ hours?|"
|
| 257 |
+
r"when necessary|as needed|at night|in the "
|
| 258 |
+
r"morning|at bedtime|before (meals|food|breakfast)|after (meals|food|breakfast)|"
|
| 259 |
+
r"with (meals|food)|on an empty stomach)\b", re.I)
|
| 260 |
+
QUANTITY = re.compile(r"\b(?:qty|quantity|dispensed)\s*[:.]?\s*(\d+)\s*([a-z]*)|\b(\d+)\s+"
|
| 261 |
+
r"(?:(?!(?:mg|mcg|g|ml|iu)\b)[a-z\-]+\s+)?(tablets?|capsules?|caplets?|tabs?|caps?|sachets?)\b",
|
| 262 |
+
re.I)
|
| 263 |
+
DATE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}|\d{1,2} (?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)"
|
| 264 |
+
r"[a-z]* \d{4}|\d{1,2}[/.\-]\d{4})\b", re.I)
|
| 265 |
+
PERSONAL = re.compile(r"\b(name|patient|nric|fin|address|tel|phone|dob|date of birth)\b", re.I)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _field(value, line):
|
| 269 |
+
return {"value": value, "line": line} if value else None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def document_fields(lines, candidates):
|
| 273 |
+
"""
|
| 274 |
+
Per candidate, what the label prints about it, each value with the line it was read from; None when it isn't
|
| 275 |
+
printed (never inferred). A candidate's block runs from its first line to the next candidate's (the first one
|
| 276 |
+
also gets the header lines above it: the dispensing date). Lines with personal details are never used.
|
| 277 |
+
"""
|
| 278 |
+
starts = sorted({c["first_line"] for c in candidates})
|
| 279 |
+
for c in candidates:
|
| 280 |
+
i = starts.index(c["first_line"])
|
| 281 |
+
lo = 0 if i == 0 else starts[i]
|
| 282 |
+
hi = starts[i + 1] if i + 1 < len(starts) else len(lines)
|
| 283 |
+
block = [(n, lines[n]) for n in range(lo, hi) if not PERSONAL.search(lines[n])]
|
| 284 |
+
directions, quantity, date, expiry = [], None, None, None
|
| 285 |
+
for n, line in block:
|
| 286 |
+
is_direction = n >= c["first_line"] and (DIRECTION_START.search(line) and DIRECTION_WORDS.search(line)
|
| 287 |
+
or (directions and directions[-1][0] == n - 1
|
| 288 |
+
and DIRECTION_WORDS.search(line)))
|
| 289 |
+
if is_direction:
|
| 290 |
+
directions.append((n, line))
|
| 291 |
+
continue
|
| 292 |
+
q = QUANTITY.search(line)
|
| 293 |
+
if q and quantity is None and n >= c["first_line"] and line not in c["found_in"]:
|
| 294 |
+
number, unit = (q.group(1), q.group(2)) if q.group(1) else (q.group(3), q.group(4))
|
| 295 |
+
quantity = _field(f"{number} {unit.lower()}".strip(), line)
|
| 296 |
+
d = DATE.search(line)
|
| 297 |
+
if d:
|
| 298 |
+
if re.search(r"\bexp", line, re.I):
|
| 299 |
+
expiry = expiry or _field(d.group(1), line)
|
| 300 |
+
else:
|
| 301 |
+
date = date or _field(d.group(1), line)
|
| 302 |
+
form = dose_form([line for n, line in block if n >= c["first_line"]])
|
| 303 |
+
c["fields"] = {
|
| 304 |
+
"directions": _field(" ".join(l for _, l in directions), " / ".join(l for _, l in directions)),
|
| 305 |
+
"quantity": quantity,
|
| 306 |
+
"dose_form": _field(form, next((l for n, l in block if n >= c["first_line"] and form and
|
| 307 |
+
re.search(form[:5], l, re.I)), None)),
|
| 308 |
+
"date": date,
|
| 309 |
+
"expiry_date": expiry,
|
| 310 |
+
}
|
| 311 |
+
return candidates
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ---- is this box one of my medicines? (blueprint job B9) -------------------------------------------------------------
|
| 315 |
+
def _amounts(strength):
|
| 316 |
+
"""'500 mg, 65 mg' or '500 MG / 65 MG' -> {(500.0, 'mg'), (65.0, 'mg')}."""
|
| 317 |
+
return {(float(m.group(1).replace(",", ".")), m.group(2).lower().replace("µg", "mcg").replace("ug", "mcg"))
|
| 318 |
+
for m in STRENGTH.finditer(strength or "")}
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def compare_with_list(candidate, medications):
|
| 322 |
+
"""
|
| 323 |
+
medications: the patient's list, each {"id", "name", "ingredients": {rxcui}, "strength": printed or None}.
|
| 324 |
+
-> {"status", "medication_id", "message"}: a deterministic comparison of ingredients and strength, no model score.
|
| 325 |
+
same_medicine same ingredients and strength (or the list gives no strength)
|
| 326 |
+
different_strength same ingredients, another strength
|
| 327 |
+
contains_listed a combination that includes a listed medicine's ingredient
|
| 328 |
+
not_on_list no ingredient in common
|
| 329 |
+
"""
|
| 330 |
+
have = {i["rxcui"] for i in candidate["ingredients"] if i.get("rxcui")}
|
| 331 |
+
best = None
|
| 332 |
+
for med in medications:
|
| 333 |
+
common = have & med["ingredients"]
|
| 334 |
+
if not common:
|
| 335 |
+
continue
|
| 336 |
+
if common == have == med["ingredients"]:
|
| 337 |
+
a, b = _amounts(candidate.get("strength")), _amounts(med.get("strength"))
|
| 338 |
+
if a and b and a != b:
|
| 339 |
+
status = "different_strength"
|
| 340 |
+
message = (f"This says {candidate.get('strength')}; your list has {med['name']}. Check with your "
|
| 341 |
+
"pharmacist.")
|
| 342 |
+
else:
|
| 343 |
+
status = "same_medicine"
|
| 344 |
+
message = f"This matches your {med['name']}: the same medicine" + \
|
| 345 |
+
(" and strength." if a and b else ". Check the strength printed on it.")
|
| 346 |
+
return {"status": status, "medication_id": med["id"], "message": message}
|
| 347 |
+
best = best or {"status": "contains_listed", "medication_id": med["id"],
|
| 348 |
+
"message": f"This medicine shares an ingredient with your {med['name']} but is not the same. "
|
| 349 |
+
"Check with your pharmacist."}
|
| 350 |
+
return best or {"status": "not_on_list", "medication_id": None,
|
| 351 |
+
"message": "This medicine isn't on your list. Check with your pharmacist."}
|
src/epills/core/safety/openfda.py
CHANGED
|
@@ -54,6 +54,7 @@ def label_warnings(rxcui=None, ingredient=None, max_chars=2000, fetch=None):
|
|
| 54 |
text = " ".join(r[name])
|
| 55 |
sections[name] = text[:max_chars] + ("…" if len(text) > max_chars else "")
|
| 56 |
of = r.get("openfda", {})
|
|
|
|
| 57 |
return {
|
| 58 |
"brand_name": (of.get("brand_name") or [None])[0],
|
| 59 |
"generic_name": (of.get("generic_name") or [None])[0],
|
|
@@ -61,5 +62,9 @@ def label_warnings(rxcui=None, ingredient=None, max_chars=2000, fetch=None):
|
|
| 61 |
"matched_by": q.split(":")[0].replace("openfda.", ""),
|
| 62 |
"sections": sections,
|
| 63 |
"disclaimer": DISCLAIMER,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
return None
|
|
|
|
| 54 |
text = " ".join(r[name])
|
| 55 |
sections[name] = text[:max_chars] + ("…" if len(text) > max_chars else "")
|
| 56 |
of = r.get("openfda", {})
|
| 57 |
+
set_id = r.get("set_id")
|
| 58 |
return {
|
| 59 |
"brand_name": (of.get("brand_name") or [None])[0],
|
| 60 |
"generic_name": (of.get("generic_name") or [None])[0],
|
|
|
|
| 62 |
"matched_by": q.split(":")[0].replace("openfda.", ""),
|
| 63 |
"sections": sections,
|
| 64 |
"disclaimer": DISCLAIMER,
|
| 65 |
+
# for citation (the blueprint's Medication Knowledge tool): which label, from where
|
| 66 |
+
"source": "US FDA drug label (structured product labeling), via openFDA",
|
| 67 |
+
"set_id": set_id, "label_id": r.get("id"),
|
| 68 |
+
"url": f"https://dailymed.nlm.nih.gov/dailymed/lookup.cfm?setid={set_id}" if set_id else None,
|
| 69 |
}
|
| 70 |
return None
|