Spaces:
Running on Zero
Running on Zero
deploy API
Browse files- serving/app/evidence.py +22 -4
- serving/app/main.py +12 -6
- serving/app/schemas.py +34 -1
- serving/app/tools.py +96 -95
- src/epills/core/imprint/ocr.py +5 -0
- src/epills/core/label.py +510 -368
serving/app/evidence.py
CHANGED
|
@@ -20,6 +20,10 @@ SIMILARITY = {
|
|
| 20 |
|
| 21 |
MODEL_VERSION = "stage2_run4@2026-10-09" # the rates below hold for this served model only (its version string)
|
| 22 |
RUN4 = "outputs/runs/stage2_run4/eval/"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
GALLERY = {"correct_match": "correct_match", "wrong_match": "wrong_match", "own_flip": "own_flip",
|
| 24 |
"own_rejected": "own_rejected", "unknown_matched": "unknown_matched", "unknown_rejected": "unknown_rejected"}
|
| 25 |
|
|
@@ -44,6 +48,14 @@ ERROR_RATES = [
|
|
| 44 |
"source": RUN4 + "imprint_gallery_large_c92_distinct.json",
|
| 45 |
"keys": {k: "test/with_check/" + k for k in ("correct_match", "wrong_match", "own_flip", "unknown_matched")},
|
| 46 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
{
|
| 48 |
"id": "personal_gallery_other_phones",
|
| 49 |
"what": "As personal_gallery on MEDISEG: 32 Hong Kong products, other phones, lighting and backgrounds",
|
|
@@ -87,10 +99,16 @@ ERROR_RATES = [
|
|
| 87 |
"rates": {"top1_right": 0.802, "possible_match_share": 0.758, "possible_match_right": 0.867,
|
| 88 |
"not_in_catalogue_called_possible_match": 0.07},
|
| 89 |
"source": RUN4 + "identify_confidence_large_c92.json",
|
| 90 |
-
"keys":
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
},
|
| 95 |
{
|
| 96 |
"id": "pill_count",
|
|
|
|
| 20 |
|
| 21 |
MODEL_VERSION = "stage2_run4@2026-10-09" # the rates below hold for this served model only (its version string)
|
| 22 |
RUN4 = "outputs/runs/stage2_run4/eval/"
|
| 23 |
+
IDENTIFY_KEYS = {"top1_right": "test/top1_right",
|
| 24 |
+
"possible_match_share": "test/catalogue_pills_by_verdict/possible_match/share",
|
| 25 |
+
"possible_match_right": "test/catalogue_pills_by_verdict/possible_match/top1_right",
|
| 26 |
+
"not_in_catalogue_called_possible_match": "test/other_pills_by_verdict/possible_match"}
|
| 27 |
GALLERY = {"correct_match": "correct_match", "wrong_match": "wrong_match", "own_flip": "own_flip",
|
| 28 |
"own_rejected": "own_rejected", "unknown_matched": "unknown_matched", "unknown_rejected": "unknown_rejected"}
|
| 29 |
|
|
|
|
| 48 |
"source": RUN4 + "imprint_gallery_large_c92_distinct.json",
|
| 49 |
"keys": {k: "test/with_check/" + k for k in ("correct_match", "wrong_match", "own_flip", "unknown_matched")},
|
| 50 |
},
|
| 51 |
+
{
|
| 52 |
+
"id": "personal_gallery_with_imprint_cpu",
|
| 53 |
+
"what": "As personal_gallery_with_imprint, read by the CPU reader (Florence-2-base, framed as served): most "
|
| 54 |
+
"live calls, since the free GPU quota is per caller; the pill's own imprint is read on 43% of photos",
|
| 55 |
+
"rates": {"correct_match": 0.7731, "wrong_match": 0.0006, "own_flip": 0.0815, "unknown_matched": 0.0351},
|
| 56 |
+
"source": RUN4 + "imprint_gallery_base_c92_distinct.json",
|
| 57 |
+
"keys": {k: "test/with_check/" + k for k in ("correct_match", "wrong_match", "own_flip", "unknown_matched")},
|
| 58 |
+
},
|
| 59 |
{
|
| 60 |
"id": "personal_gallery_other_phones",
|
| 61 |
"what": "As personal_gallery on MEDISEG: 32 Hong Kong products, other phones, lighting and backgrounds",
|
|
|
|
| 99 |
"rates": {"top1_right": 0.802, "possible_match_share": 0.758, "possible_match_right": 0.867,
|
| 100 |
"not_in_catalogue_called_possible_match": 0.07},
|
| 101 |
"source": RUN4 + "identify_confidence_large_c92.json",
|
| 102 |
+
"keys": IDENTIFY_KEYS,
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"id": "catalogue_identify_cpu",
|
| 106 |
+
"what": "As catalogue_identify with imprints read by the CPU reader (most live calls), scored with the served "
|
| 107 |
+
"confidence rule as it is",
|
| 108 |
+
"rates": {"top1_right": 0.792, "possible_match_share": 0.751, "possible_match_right": 0.864,
|
| 109 |
+
"not_in_catalogue_called_possible_match": 0.074},
|
| 110 |
+
"source": RUN4 + "identify_confidence_base_c92_served.json",
|
| 111 |
+
"keys": IDENTIFY_KEYS,
|
| 112 |
},
|
| 113 |
{
|
| 114 |
"id": "pill_count",
|
serving/app/main.py
CHANGED
|
@@ -62,7 +62,7 @@ from epills.core.safety.rxnorm import RxNormClient, RxNormError
|
|
| 62 |
from serving.app import evidence, schemas as S
|
| 63 |
from serving.app.vision import Vision
|
| 64 |
|
| 65 |
-
API_VERSION = "0.
|
| 66 |
STARTED_AT = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
| 67 |
LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
|
| 68 |
"embedder": Path("outputs/serving/stage2_model.pt"),
|
|
@@ -889,10 +889,12 @@ async def read_label(image: UploadFile = File(description="A photo of a medicine
|
|
| 889 |
"medications, [{id, name, rxcui?, strength?}]: "
|
| 890 |
"each medicine read is compared with it")):
|
| 891 |
"""
|
| 892 |
-
**
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
|
|
|
|
|
|
| 896 |
compared with the patient's list: same medicine, different strength, or not on the list (B9). Send a confirmed
|
| 897 |
candidate to `/v1/safety/medications` as `{"id", "name", "rxcui"}`. Always ask the user to confirm.
|
| 898 |
Nothing is stored; only the lines that name or describe a medicine are returned (labels show the patient's name).
|
|
@@ -926,7 +928,11 @@ async def read_label(image: UploadFile = File(description="A photo of a medicine
|
|
| 926 |
c["list_check"] = compare_with_list(c, listed)
|
| 927 |
except RxNormError as error:
|
| 928 |
raise HTTPException(502, f"RxNorm didn't answer: {error}")
|
| 929 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
if not candidates:
|
| 931 |
result["message"] = ("No medicine name found. Photograph the label closer, straight on and in good light, "
|
| 932 |
"or type the name.")
|
|
|
|
| 62 |
from serving.app import evidence, schemas as S
|
| 63 |
from serving.app.vision import Vision
|
| 64 |
|
| 65 |
+
API_VERSION = "0.10.0"
|
| 66 |
STARTED_AT = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
| 67 |
LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
|
| 68 |
"embedder": Path("outputs/serving/stage2_model.pt"),
|
|
|
|
| 889 |
"medications, [{id, name, rxcui?, strength?}]: "
|
| 890 |
"each medicine read is compared with it")):
|
| 891 |
"""
|
| 892 |
+
**What does the pharmacy sticker say?** Made for Singapore dispensing labels (the sticker on the medicine bag or
|
| 893 |
+
box), also reads prescriptions and manufacturer boxes. Per medicine: name, strength per unit, ingredients and
|
| 894 |
+
RxNorm code; the quantity supplied (amount and unit); the instructions parsed into dose, times a day or every N
|
| 895 |
+
hours, as needed, time of day, before/after food, duration and daily maximum; what it is for; cautions; dispensing
|
| 896 |
+
and expiry dates. Every value comes with the line it was read from and is `null` when not printed, never inferred
|
| 897 |
+
(blueprint B7). `label_kind` says whether it is a dispensing label (instructions for the patient) or a package. With `medications`, each medicine is
|
| 898 |
compared with the patient's list: same medicine, different strength, or not on the list (B9). Send a confirmed
|
| 899 |
candidate to `/v1/safety/medications` as `{"id", "name", "rxcui"}`. Always ask the user to confirm.
|
| 900 |
Nothing is stored; only the lines that name or describe a medicine are returned (labels show the patient's name).
|
|
|
|
| 928 |
c["list_check"] = compare_with_list(c, listed)
|
| 929 |
except RxNormError as error:
|
| 930 |
raise HTTPException(502, f"RxNorm didn't answer: {error}")
|
| 931 |
+
kind = ("none" if not candidates else
|
| 932 |
+
"dispensing" if any(c["fields"]["instructions"] or c["fields"]["quantity"] for c in candidates) else
|
| 933 |
+
"package")
|
| 934 |
+
result = {"candidates": candidates, "label_kind": kind, "note": LABEL_NOTE, "ocr_model": ocr_model,
|
| 935 |
+
"source": source}
|
| 936 |
if not candidates:
|
| 937 |
result["message"] = ("No medicine name found. Photograph the label closer, straight on and in good light, "
|
| 938 |
"or type the name.")
|
serving/app/schemas.py
CHANGED
|
@@ -304,9 +304,39 @@ class Printed(Out):
|
|
| 304 |
line: str = Field(description="The line(s) it was read from")
|
| 305 |
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
class DocumentFields(Out):
|
| 308 |
directions: Printed | None = Field(None, description="As printed, e.g. 'Take 1 tablet twice a day after meals'")
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
dose_form: Printed | None = None
|
| 311 |
date: Printed | None = Field(None, description="Dispensing or prescription date, as printed")
|
| 312 |
expiry_date: Printed | None = None
|
|
@@ -334,6 +364,9 @@ class LabelCandidate(Out):
|
|
| 334 |
|
| 335 |
class ReadLabelResponse(Out):
|
| 336 |
candidates: list[LabelCandidate]
|
|
|
|
|
|
|
|
|
|
| 337 |
note: str
|
| 338 |
ocr_model: str
|
| 339 |
source: Literal["photo", "pdf_text", "pdf_scan"] = "photo"
|
|
|
|
| 304 |
line: str = Field(description="The line(s) it was read from")
|
| 305 |
|
| 306 |
|
| 307 |
+
class Dose(Out):
|
| 308 |
+
amount: float = Field(description="Per dose, e.g. 1, 0.5")
|
| 309 |
+
amount_max: float | None = Field(None, description="For a range: 'Take 1-2 tablets' -> 2")
|
| 310 |
+
unit: str | None = Field(None, description="tablet, capsule, ml, puff, drop, sachet, ...")
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
class Instructions(Out):
|
| 314 |
+
text: str = Field(description="As printed, e.g. 'Take ONE tablet TWICE a day AFTER FOOD'")
|
| 315 |
+
line: str = Field(description="The line(s) it was read from")
|
| 316 |
+
dose: Dose | None = None
|
| 317 |
+
times_per_day: int | None = Field(None, description="Only when printed ('twice a day', 'BD', 'daily')")
|
| 318 |
+
every_hours: float | None = Field(None, description="'every 6 hours'; never turned into times a day")
|
| 319 |
+
as_needed: bool = Field(False, description="'when necessary', 'as needed', 'PRN'")
|
| 320 |
+
times_of_day: list[Literal["morning", "afternoon", "evening", "night", "bedtime"]] = []
|
| 321 |
+
food: Literal["before_food", "after_food", "with_food", "with_or_without_food", "empty_stomach"] | None = None
|
| 322 |
+
duration: str | None = Field(None, description="'7 days'")
|
| 323 |
+
max_per_day: str | None = Field(None, description="'8 tablets in 24 hours'")
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
class Quantity(Printed):
|
| 327 |
+
amount: float | None = Field(None, description="e.g. 60")
|
| 328 |
+
unit: str | None = Field(None, description="e.g. tablet, capsule, ml")
|
| 329 |
+
|
| 330 |
+
|
| 331 |
class DocumentFields(Out):
|
| 332 |
directions: Printed | None = Field(None, description="As printed, e.g. 'Take 1 tablet twice a day after meals'")
|
| 333 |
+
instructions: Instructions | None = Field(None, description="The directions, parsed into their parts; a part "
|
| 334 |
+
"that isn't printed is null")
|
| 335 |
+
indication: Printed | None = Field(None, description="What the medicine is for, as printed ('For high blood "
|
| 336 |
+
"pressure' -> 'high blood pressure')")
|
| 337 |
+
cautions: list[Printed] = Field([], description="Cautionary notes as printed ('May cause drowsiness', 'Avoid "
|
| 338 |
+
"alcohol', 'Swallow whole')")
|
| 339 |
+
quantity: Quantity | None = Field(None, description="Total supplied, e.g. 'Qty: 60 TAB' -> 60 tablet")
|
| 340 |
dose_form: Printed | None = None
|
| 341 |
date: Printed | None = Field(None, description="Dispensing or prescription date, as printed")
|
| 342 |
expiry_date: Printed | None = None
|
|
|
|
| 364 |
|
| 365 |
class ReadLabelResponse(Out):
|
| 366 |
candidates: list[LabelCandidate]
|
| 367 |
+
label_kind: Literal["dispensing", "package", "none"] = Field(
|
| 368 |
+
"none", description="dispensing: a pharmacy sticker or prescription with instructions for the patient; "
|
| 369 |
+
"package: a manufacturer's box or bottle (no instructions); none: no medicine found")
|
| 370 |
note: str
|
| 371 |
ocr_model: str
|
| 372 |
source: Literal["photo", "pdf_text", "pdf_scan"] = "photo"
|
serving/app/tools.py
CHANGED
|
@@ -1,95 +1,96 @@
|
|
| 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 |
-
("route_medication_image", "post", "/v1/check-input",
|
| 12 |
-
"Before any other photo tool when you don't know what the photo shows: loose pills, blister pack, box or bottle, "
|
| 13 |
-
"printed label or document, and which tools fit (next_tools). Prefer asking the user; when type is unchecked or "
|
| 14 |
-
"next_tools is empty, ask for the box, label or prescription, or suggest the pharmacist."),
|
| 15 |
-
("detect_pills", "post", "/v1/detect",
|
| 16 |
-
"Find each pill in a photo and say whether the count can be trusted. Call before counting pills or embedding "
|
| 17 |
-
"several pills. If count_reliable is false, don't count: ask for a new photo (pills apart, plain surface)."),
|
| 18 |
-
("embed_pill", "post", "/v1/embed",
|
| 19 |
-
"Turn one loose-pill photo (or a photo + box) into a 256-number embedding. Use at enrolment (store it with "
|
| 20 |
-
"model_version and embedding_space_id) and before match_gallery. Never embed boxes or blister packs."),
|
| 21 |
-
("match_gallery", "post", "/v1/match",
|
| 22 |
-
"Compare pill embeddings with the patient's gallery: match / unknown / ambiguous per pill. Visual evidence only: "
|
| 23 |
-
"a match means 'looks like', not 'is' or 'safe to take'. ambiguous: ask for another photo. The gallery's "
|
| 24 |
-
"model_version is required; on 409 re-embed the enrolment photos."),
|
| 25 |
-
("identify_pill", "post", "/v1/identify",
|
| 26 |
-
"Possible catalogue matches (US products) for a pill that isn't in the patient's list. Report the verdict and "
|
| 27 |
-
"confidence, never a definite identity; no_confident_match means the pill may not be a catalogue product."),
|
| 28 |
-
("search_by_embedding", "post", "/v1/search-by-embedding",
|
| 29 |
-
"identify_pill for a pill you already embedded (e.g. match_gallery said unknown): send its embeddings and "
|
| 30 |
-
"imprint_reads from embed_pill. Same answer, no second model run. Possible matches only, never an identity."),
|
| 31 |
-
("scan_photo", "post", "/v1/scan",
|
| 32 |
-
"Diagnostics in one call: detection plus each pill's visual decision (with a gallery) or catalogue candidates. "
|
| 33 |
-
"Don't use its dose check (deprecated): apply the backend's dose rules to the pills it returns."),
|
| 34 |
-
("extract_medication_document", "post", "/v1/read-label",
|
| 35 |
-
"Read a
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
"
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
| 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 |
+
("route_medication_image", "post", "/v1/check-input",
|
| 12 |
+
"Before any other photo tool when you don't know what the photo shows: loose pills, blister pack, box or bottle, "
|
| 13 |
+
"printed label or document, and which tools fit (next_tools). Prefer asking the user; when type is unchecked or "
|
| 14 |
+
"next_tools is empty, ask for the box, label or prescription, or suggest the pharmacist."),
|
| 15 |
+
("detect_pills", "post", "/v1/detect",
|
| 16 |
+
"Find each pill in a photo and say whether the count can be trusted. Call before counting pills or embedding "
|
| 17 |
+
"several pills. If count_reliable is false, don't count: ask for a new photo (pills apart, plain surface)."),
|
| 18 |
+
("embed_pill", "post", "/v1/embed",
|
| 19 |
+
"Turn one loose-pill photo (or a photo + box) into a 256-number embedding. Use at enrolment (store it with "
|
| 20 |
+
"model_version and embedding_space_id) and before match_gallery. Never embed boxes or blister packs."),
|
| 21 |
+
("match_gallery", "post", "/v1/match",
|
| 22 |
+
"Compare pill embeddings with the patient's gallery: match / unknown / ambiguous per pill. Visual evidence only: "
|
| 23 |
+
"a match means 'looks like', not 'is' or 'safe to take'. ambiguous: ask for another photo. The gallery's "
|
| 24 |
+
"model_version is required; on 409 re-embed the enrolment photos."),
|
| 25 |
+
("identify_pill", "post", "/v1/identify",
|
| 26 |
+
"Possible catalogue matches (US products) for a pill that isn't in the patient's list. Report the verdict and "
|
| 27 |
+
"confidence, never a definite identity; no_confident_match means the pill may not be a catalogue product."),
|
| 28 |
+
("search_by_embedding", "post", "/v1/search-by-embedding",
|
| 29 |
+
"identify_pill for a pill you already embedded (e.g. match_gallery said unknown): send its embeddings and "
|
| 30 |
+
"imprint_reads from embed_pill. Same answer, no second model run. Possible matches only, never an identity."),
|
| 31 |
+
("scan_photo", "post", "/v1/scan",
|
| 32 |
+
"Diagnostics in one call: detection plus each pill's visual decision (with a gallery) or catalogue candidates. "
|
| 33 |
+
"Don't use its dose check (deprecated): apply the backend's dose rules to the pills it returns."),
|
| 34 |
+
("extract_medication_document", "post", "/v1/read-label",
|
| 35 |
+
"Read a Singapore pharmacy sticker (or a prescription or box; photo or PDF): per medicine the name, strength, "
|
| 36 |
+
"RxNorm code, quantity, the instructions in parts (dose, times a day or every N hours, as needed, time of day, "
|
| 37 |
+
"before/after food), what it is for, cautions and dates, each with its source line. Parts not printed are "
|
| 38 |
+
"null; never fill them in. With the patient's medications, says same medicine / different strength / not on "
|
| 39 |
+
"the list. Always have the patient confirm before anything is scheduled."),
|
| 40 |
+
("find_medication_product", "get", "/v1/pills/search",
|
| 41 |
+
"Products and pictures for a medicine name (brand or generic), with strength, dose form and release as fields. "
|
| 42 |
+
"Never treat products of another strength or form as the same."),
|
| 43 |
+
("screen_medication_list", "post", "/v1/safety/medications",
|
| 44 |
+
"Interaction and duplicate-ingredient alerts for the whole medication list (DDInter 2.0). Read coverage: no "
|
| 45 |
+
"alert does not mean safe. Advisory; defer to the pharmacist."),
|
| 46 |
+
("evaluate_dose_reference", "post", "/v1/safety/dose",
|
| 47 |
+
"The reference dose rules on visual decisions (stateless). The backend's own dose policy is authoritative."),
|
| 48 |
+
("retrieve_medication_info", "get", "/v1/safety/label",
|
| 49 |
+
"Warning sections of the official US label, with the label's date and DailyMed link for citation. General "
|
| 50 |
+
"information, not the patient's instructions."),
|
| 51 |
+
("get_model_info", "get", "/v1/model-info",
|
| 52 |
+
"The served model: version, embedding space, decision rule and known limits. Check after a 409."),
|
| 53 |
+
]
|
| 54 |
+
FILE_NOTE = "An image reference: the app's tool layer uploads the file (photo, or PDF for documents)"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _resolve(schema, components):
|
| 58 |
+
ref = schema.get("$ref", "") if isinstance(schema, dict) else ""
|
| 59 |
+
return components.get(ref.rsplit("/", 1)[-1], {}) if ref else schema
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _input_schema(op, components):
|
| 63 |
+
props, required = {}, []
|
| 64 |
+
for p in op.get("parameters", []):
|
| 65 |
+
props[p["name"]] = {**p.get("schema", {}), "description": p.get("description", "")}
|
| 66 |
+
if p.get("required"):
|
| 67 |
+
required.append(p["name"])
|
| 68 |
+
content = op.get("requestBody", {}).get("content", {})
|
| 69 |
+
for kind in ("application/json", "multipart/form-data"):
|
| 70 |
+
if kind in content:
|
| 71 |
+
body = _resolve(content[kind]["schema"], components)
|
| 72 |
+
for name, s in body.get("properties", {}).items():
|
| 73 |
+
is_file = s.get("format") == "binary" or s.get("contentMediaType") or \
|
| 74 |
+
(s.get("type") == "array" and (s.get("items", {}).get("format") == "binary"
|
| 75 |
+
or s.get("items", {}).get("contentMediaType")))
|
| 76 |
+
props[name] = ({"type": "array", "items": {"type": "string"}, "description": FILE_NOTE}
|
| 77 |
+
if is_file and s.get("type") == "array" else
|
| 78 |
+
{"type": "string", "description": FILE_NOTE} if is_file else s)
|
| 79 |
+
required += body.get("required", [])
|
| 80 |
+
return {"type": "object", "properties": props, "required": sorted(set(required))}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_tools(openapi):
|
| 84 |
+
components = openapi.get("components", {}).get("schemas", {})
|
| 85 |
+
tools = []
|
| 86 |
+
for name, method, path, description in TOOLS:
|
| 87 |
+
op = openapi["paths"][path][method]
|
| 88 |
+
ok = op.get("responses", {}).get("200", {}).get("content", {}).get("application/json", {})
|
| 89 |
+
kind = next(iter(op.get("requestBody", {}).get("content", {"none": None})))
|
| 90 |
+
tools.append({"name": name, "description": description, "input_schema": _input_schema(op, components),
|
| 91 |
+
"output_schema": ok.get("schema", {}),
|
| 92 |
+
"call": {"method": method.upper(), "path": path,
|
| 93 |
+
"content_type": None if kind == "none" else kind}})
|
| 94 |
+
return {"tools": tools, "components": {"schemas": components},
|
| 95 |
+
"notes": "Stage 2 answers are visual evidence only. The backend's safety policy decides actions; the "
|
| 96 |
+
"agent reports evidence with its limits and asks the patient to confirm."}
|
src/epills/core/imprint/ocr.py
CHANGED
|
@@ -19,9 +19,14 @@ GPU_MODEL, CPU_MODEL = "florence-community/Florence-2-large", "florence-communit
|
|
| 19 |
PRESETS = { # for the evaluation caches (scripts/read_imprints_cache.py)
|
| 20 |
"base_c70": {"model": CPU_MODEL, "centre": 0.7, "served_framing": False},
|
| 21 |
"large_c92": {"model": GPU_MODEL, "centre": READ_CENTRE, "served_framing": True},
|
|
|
|
|
|
|
| 22 |
# large with the imprint adapter trained by scripts/train_imprint_reader.py (imprint reads only)
|
| 23 |
"large_lora_v1": {"model": GPU_MODEL, "centre": READ_CENTRE, "served_framing": True,
|
| 24 |
"adapter": "outputs/imprint_reader/large_lora_v1/adapter.pt"},
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
TOKENS = {"read": 20, "caption": 40, "text": 512}
|
| 27 |
|
|
|
|
| 19 |
PRESETS = { # for the evaluation caches (scripts/read_imprints_cache.py)
|
| 20 |
"base_c70": {"model": CPU_MODEL, "centre": 0.7, "served_framing": False},
|
| 21 |
"large_c92": {"model": GPU_MODEL, "centre": READ_CENTRE, "served_framing": True},
|
| 22 |
+
# the CPU reader as served (most live calls: the free GPU quota is per caller); read here on the GPU in fp16
|
| 23 |
+
"base_c92": {"model": CPU_MODEL, "centre": READ_CENTRE, "served_framing": True},
|
| 24 |
# large with the imprint adapter trained by scripts/train_imprint_reader.py (imprint reads only)
|
| 25 |
"large_lora_v1": {"model": GPU_MODEL, "centre": READ_CENTRE, "served_framing": True,
|
| 26 |
"adapter": "outputs/imprint_reader/large_lora_v1/adapter.pt"},
|
| 27 |
+
# v2 also learnt to write nothing (unreadable photos, wrong rotations); the better epoch on validation identify
|
| 28 |
+
"large_lora_v2": {"model": GPU_MODEL, "centre": READ_CENTRE, "served_framing": True,
|
| 29 |
+
"adapter": "outputs/imprint_reader/large_lora_v2/adapter_best.pt"},
|
| 30 |
}
|
| 31 |
TOKENS = {"read": 20, "caption": 40, "text": 512}
|
| 32 |
|
src/epills/core/label.py
CHANGED
|
@@ -1,368 +1,510 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Medicines on a box or pharmacy label (feature F8): OCR lines (imprint.ocr.ImprintReader.read_text) -> candidate
|
| 3 |
-
medicines with strength and RxNorm codes, to fill the medication list (and so the Stage 3 checks).
|
| 4 |
-
|
| 5 |
-
1. Names: the lines are searched for RxNorm's ingredient and brand names (IN, PIN, BN: ~23,500, fetched once), plus
|
| 6 |
-
international names RxNorm lacks (INN_TO_US: paracetamol -> acetaminophen). Phrases of 1-4 words match exactly
|
| 7 |
-
(case and punctuation ignored), longest first; single words of 6+ letters also match with one OCR error (two from
|
| 8 |
-
9 letters). Everyday words that happen to be names ("water", "relief") are skipped (COMMON).
|
| 9 |
-
2. Strength: amounts with units after the name on its line, else the line's only amount, else the next line's when
|
| 10 |
-
that line names nothing ("10 mg" printed under "atorvastatin calcium").
|
| 11 |
-
3. RxNorm: name + strength -> the product concept (e.g. "atorvastatin 10 MG Oral Tablet [Lipitor]"), kept only if it
|
| 12 |
-
has the name's ingredients and its strength; the ingredients always come from RxNorm.
|
| 13 |
-
4. Names whose ingredients are the same, or a subset of a brand's (paracetamol and caffeine under Panadol Extra),
|
| 14 |
-
merge into one candidate. Best first: most evidence, then the order on the label.
|
| 15 |
-
RxNorm's own match scores are not used to find names: there, "Take 2 tablets 4 times a day" scores higher than
|
| 16 |
-
"PARACETAMOL 500MG TABLET".
|
| 17 |
-
"""
|
| 18 |
-
import re
|
| 19 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 20 |
-
from dataclasses import dataclass, field
|
| 21 |
-
|
| 22 |
-
# international (INN/BAN, as printed in Singapore) -> RxNorm's US names; a list for combinations
|
| 23 |
-
INN_TO_US = {
|
| 24 |
-
"paracetamol": ["acetaminophen"], "salbutamol": ["albuterol"], "adrenaline": ["epinephrine"],
|
| 25 |
-
"noradrenaline": ["norepinephrine"], "frusemide": ["furosemide"], "glibenclamide": ["glyburide"],
|
| 26 |
-
"lignocaine": ["lidocaine"], "amoxycillin": ["amoxicillin"], "cefalexin": ["cephalexin"],
|
| 27 |
-
"clavulanic acid": ["clavulanate"], "potassium clavulanate": ["clavulanate"],
|
| 28 |
-
"chlorphenamine": ["chlorpheniramine"], "mesalazine": ["mesalamine"], "pethidine": ["meperidine"],
|
| 29 |
-
"thyroxine": ["levothyroxine"], "sodium valproate": ["valproate"], "isoprenaline": ["isoproterenol"],
|
| 30 |
-
"aciclovir": ["acyclovir"], "ciclosporin": ["cyclosporine"], "colecalciferol": ["cholecalciferol"],
|
| 31 |
-
"rifampicin": ["rifampin"], "beclometasone": ["beclomethasone"], "dexamfetamine": ["dextroamphetamine"],
|
| 32 |
-
"glyceryl trinitrate": ["nitroglycerin"], "phenobarbitone": ["phenobarbital"], "oestradiol": ["estradiol"],
|
| 33 |
-
"eformoterol": ["formoterol"], "colestyramine": ["cholestyramine"], "clomifene": ["clomiphene"],
|
| 34 |
-
"orciprenaline": ["metaproterenol"], "oxpentifylline": ["pentoxifylline"], "dothiepin": ["dosulepin"],
|
| 35 |
-
"co-amoxiclav": ["amoxicillin", "clavulanate"], "co-trimoxazole": ["sulfamethoxazole", "trimethoprim"],
|
| 36 |
-
"co-codamol": ["acetaminophen", "codeine"],
|
| 37 |
-
}
|
| 38 |
-
# words on boxes and labels that are also RxNorm names, never a medicine on their own
|
| 39 |
-
COMMON = {
|
| 40 |
-
"water", "sugar", "salt", "relief", "extra", "fast", "film", "coated", "tablet", "tablets", "capsule", "capsules",
|
| 41 |
-
"oral", "daily", "night", "day", "care", "plus", "forte", "max", "ultra", "rapid", "soft", "children", "adult",
|
| 42 |
-
"adults", "junior", "baby", "cold", "flu", "pain", "fever", "allergy", "sleep", "cough", "clear", "active",
|
| 43 |
-
"original", "advance", "advanced", "complete", "health", "natural", "gold", "silver", "oxygen", "air", "alcohol",
|
| 44 |
-
"glycerin", "talc", "starch", "lactose", "gelatin", "cellulose", "iron", "calcium", "sodium", "potassium",
|
| 45 |
-
"magnesium", "zinc", "acid", "hydrochloride", "clinic", "pharmacy", "medicine", "tan", "lim", "lee", "ong", "ng",
|
| 46 |
-
}
|
| 47 |
-
UNIT = r"(?:mg|mcg|µg|ug|g|ml|iu|units?|%)"
|
| 48 |
-
STRENGTH = re.compile(rf"(\d+(?:[.,]\d+)?)\s*({UNIT})(?:\s*/\s*(\d+(?:[.,]\d+)?)?\s*({UNIT}|tab|cap)s?)?(?![a-z])",
|
| 49 |
-
re.I)
|
| 50 |
-
# dose forms as printed -> as RxNorm names them
|
| 51 |
-
FORMS = {"tablet": "tablet", "tab": "tablet", "caplet": "tablet", "capsule": "capsule", "cap": "capsule",
|
| 52 |
-
"syrup": "solution", "solution": "solution", "suspension": "suspension", "cream": "cream",
|
| 53 |
-
"ointment": "ointment", "gel": "gel", "injection": "injection", "drops": "drops", "inhaler": "inhal",
|
| 54 |
-
"patch": "patch", "spray": "spray", "suppository": "suppository"}
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def norm(text):
|
| 58 |
-
return " ".join(re.sub(r"[^a-z0-9]+", " ", str(text).lower()).split())
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
INN = {norm(k): v for k, v in INN_TO_US.items()}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def dose_form(lines):
|
| 65 |
-
"""The first dose form printed anywhere on the label, as RxNorm names it, or None."""
|
| 66 |
-
for line in lines:
|
| 67 |
-
for word in re.findall(r"[a-z]+", line.lower()):
|
| 68 |
-
word = word[:-1] if word.endswith("s") and word[:-1] in FORMS else word
|
| 69 |
-
if word in FORMS:
|
| 70 |
-
return FORMS[word]
|
| 71 |
-
return None
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def _edits(a, b, limit):
|
| 75 |
-
"""Levenshtein distance, or limit + 1 once it exceeds limit."""
|
| 76 |
-
if abs(len(a) - len(b)) > limit:
|
| 77 |
-
return limit + 1
|
| 78 |
-
prev = list(range(len(b) + 1))
|
| 79 |
-
for i, ca in enumerate(a, 1):
|
| 80 |
-
cur = [i] + [0] * len(b)
|
| 81 |
-
for j, cb in enumerate(b, 1):
|
| 82 |
-
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))
|
| 83 |
-
if min(cur) > limit:
|
| 84 |
-
return limit + 1
|
| 85 |
-
prev = cur
|
| 86 |
-
return prev[-1]
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _strength_text(m):
|
| 90 |
-
amount, unit, per_amount, per_unit = m.groups()
|
| 91 |
-
unit = unit.lower().replace("µg", "mcg").replace("ug", "mcg")
|
| 92 |
-
text = f"{amount.replace(',', '.')}{'' if unit == '%' else ' '}{unit}"
|
| 93 |
-
if per_unit:
|
| 94 |
-
text += f"/{per_amount + ' ' if per_amount else ''}{per_unit.lower()}"
|
| 95 |
-
return text
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
@dataclass
|
| 99 |
-
class Found:
|
| 100 |
-
line: int
|
| 101 |
-
start: int
|
| 102 |
-
end: int
|
| 103 |
-
text: str # as printed
|
| 104 |
-
rxcui: str
|
| 105 |
-
tty: str # IN, PIN, BN, or INN for a mapped international name
|
| 106 |
-
us_names: list = field(default_factory=list)
|
| 107 |
-
spelled: str | None = None # RxNorm's spelling when the printed word was misread (fuzzy match)
|
| 108 |
-
strength: str | None = None
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
class LabelReader:
|
| 112 |
-
def __init__(self, rxnorm):
|
| 113 |
-
self.rxnorm = rxnorm
|
| 114 |
-
self._vocab = None
|
| 115 |
-
|
| 116 |
-
@property
|
| 117 |
-
def vocab(self):
|
| 118 |
-
"""normalised name -> (rxcui, tty), and single words by length for the fuzzy match; fetched once."""
|
| 119 |
-
if self._vocab is None:
|
| 120 |
-
concepts = self.rxnorm.get("allconcepts.json", tty="IN PIN BN")["minConceptGroup"]["minConcept"]
|
| 121 |
-
names = {}
|
| 122 |
-
for c in concepts:
|
| 123 |
-
key = norm(c["name"])
|
| 124 |
-
if key and key not in COMMON and len(key) >= 4 and (key not in names or c["tty"] == "IN"):
|
| 125 |
-
names[key] = (c["rxcui"], c["tty"], c["name"])
|
| 126 |
-
by_len = {}
|
| 127 |
-
for key in names:
|
| 128 |
-
if " " not in key and len(key) >= 6 and not key.isdigit():
|
| 129 |
-
by_len.setdefault(len(key), []).append(key)
|
| 130 |
-
self._vocab = (names, by_len)
|
| 131 |
-
return self._vocab
|
| 132 |
-
|
| 133 |
-
def _lookup(self, phrase):
|
| 134 |
-
names, by_len = self.vocab
|
| 135 |
-
if phrase in INN:
|
| 136 |
-
first = names.get(INN[phrase][0])
|
| 137 |
-
return (first[0] if first else None), "INN", INN[phrase], None
|
| 138 |
-
if phrase in names:
|
| 139 |
-
return names[phrase][0], names[phrase][1], [], None
|
| 140 |
-
if " " not in phrase and len(phrase) >= 6 and not any(ch.isdigit() for ch in phrase):
|
| 141 |
-
limit = 1 if len(phrase) < 9 else 2
|
| 142 |
-
for n in range(len(phrase) - limit, len(phrase) + limit + 1):
|
| 143 |
-
for key in by_len.get(n, ()):
|
| 144 |
-
if key[0] == phrase[0] and _edits(phrase, key, limit) <= limit:
|
| 145 |
-
return names[key][0], names[key][1], [], names[key][2]
|
| 146 |
-
return None
|
| 147 |
-
|
| 148 |
-
def find_names(self, lines):
|
| 149 |
-
"""Each medicine name in the lines, with its strength (steps 1 and 2)."""
|
| 150 |
-
found = []
|
| 151 |
-
for li, line in enumerate(lines):
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
i +
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
f.strength = _strength_text(
|
| 174 |
-
elif
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
return
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
for f in found
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
"
|
| 259 |
-
"
|
| 260 |
-
"
|
| 261 |
-
"
|
| 262 |
-
"
|
| 263 |
-
"
|
| 264 |
-
"
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
r"
|
| 275 |
-
|
| 276 |
-
r"
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
for
|
| 350 |
-
|
| 351 |
-
if
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Medicines on a box or pharmacy label (feature F8): OCR lines (imprint.ocr.ImprintReader.read_text) -> candidate
|
| 3 |
+
medicines with strength and RxNorm codes, to fill the medication list (and so the Stage 3 checks).
|
| 4 |
+
|
| 5 |
+
1. Names: the lines are searched for RxNorm's ingredient and brand names (IN, PIN, BN: ~23,500, fetched once), plus
|
| 6 |
+
international names RxNorm lacks (INN_TO_US: paracetamol -> acetaminophen). Phrases of 1-4 words match exactly
|
| 7 |
+
(case and punctuation ignored), longest first; single words of 6+ letters also match with one OCR error (two from
|
| 8 |
+
9 letters). Everyday words that happen to be names ("water", "relief") are skipped (COMMON).
|
| 9 |
+
2. Strength: amounts with units after the name on its line, else the line's only amount, else the next line's when
|
| 10 |
+
that line names nothing ("10 mg" printed under "atorvastatin calcium").
|
| 11 |
+
3. RxNorm: name + strength -> the product concept (e.g. "atorvastatin 10 MG Oral Tablet [Lipitor]"), kept only if it
|
| 12 |
+
has the name's ingredients and its strength; the ingredients always come from RxNorm.
|
| 13 |
+
4. Names whose ingredients are the same, or a subset of a brand's (paracetamol and caffeine under Panadol Extra),
|
| 14 |
+
merge into one candidate. Best first: most evidence, then the order on the label.
|
| 15 |
+
RxNorm's own match scores are not used to find names: there, "Take 2 tablets 4 times a day" scores higher than
|
| 16 |
+
"PARACETAMOL 500MG TABLET".
|
| 17 |
+
"""
|
| 18 |
+
import re
|
| 19 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
|
| 22 |
+
# international (INN/BAN, as printed in Singapore) -> RxNorm's US names; a list for combinations
|
| 23 |
+
INN_TO_US = {
|
| 24 |
+
"paracetamol": ["acetaminophen"], "salbutamol": ["albuterol"], "adrenaline": ["epinephrine"],
|
| 25 |
+
"noradrenaline": ["norepinephrine"], "frusemide": ["furosemide"], "glibenclamide": ["glyburide"],
|
| 26 |
+
"lignocaine": ["lidocaine"], "amoxycillin": ["amoxicillin"], "cefalexin": ["cephalexin"],
|
| 27 |
+
"clavulanic acid": ["clavulanate"], "potassium clavulanate": ["clavulanate"],
|
| 28 |
+
"chlorphenamine": ["chlorpheniramine"], "mesalazine": ["mesalamine"], "pethidine": ["meperidine"],
|
| 29 |
+
"thyroxine": ["levothyroxine"], "sodium valproate": ["valproate"], "isoprenaline": ["isoproterenol"],
|
| 30 |
+
"aciclovir": ["acyclovir"], "ciclosporin": ["cyclosporine"], "colecalciferol": ["cholecalciferol"],
|
| 31 |
+
"rifampicin": ["rifampin"], "beclometasone": ["beclomethasone"], "dexamfetamine": ["dextroamphetamine"],
|
| 32 |
+
"glyceryl trinitrate": ["nitroglycerin"], "phenobarbitone": ["phenobarbital"], "oestradiol": ["estradiol"],
|
| 33 |
+
"eformoterol": ["formoterol"], "colestyramine": ["cholestyramine"], "clomifene": ["clomiphene"],
|
| 34 |
+
"orciprenaline": ["metaproterenol"], "oxpentifylline": ["pentoxifylline"], "dothiepin": ["dosulepin"],
|
| 35 |
+
"co-amoxiclav": ["amoxicillin", "clavulanate"], "co-trimoxazole": ["sulfamethoxazole", "trimethoprim"],
|
| 36 |
+
"co-codamol": ["acetaminophen", "codeine"],
|
| 37 |
+
}
|
| 38 |
+
# words on boxes and labels that are also RxNorm names, never a medicine on their own
|
| 39 |
+
COMMON = {
|
| 40 |
+
"water", "sugar", "salt", "relief", "extra", "fast", "film", "coated", "tablet", "tablets", "capsule", "capsules",
|
| 41 |
+
"oral", "daily", "night", "day", "care", "plus", "forte", "max", "ultra", "rapid", "soft", "children", "adult",
|
| 42 |
+
"adults", "junior", "baby", "cold", "flu", "pain", "fever", "allergy", "sleep", "cough", "clear", "active",
|
| 43 |
+
"original", "advance", "advanced", "complete", "health", "natural", "gold", "silver", "oxygen", "air", "alcohol",
|
| 44 |
+
"glycerin", "talc", "starch", "lactose", "gelatin", "cellulose", "iron", "calcium", "sodium", "potassium",
|
| 45 |
+
"magnesium", "zinc", "acid", "hydrochloride", "clinic", "pharmacy", "medicine", "tan", "lim", "lee", "ong", "ng",
|
| 46 |
+
}
|
| 47 |
+
UNIT = r"(?:mg|mcg|µg|ug|g|ml|iu|units?|%)"
|
| 48 |
+
STRENGTH = re.compile(rf"(\d+(?:[.,]\d+)?)\s*({UNIT})(?:\s*/\s*(\d+(?:[.,]\d+)?)?\s*({UNIT}|tab|cap)s?)?(?![a-z])",
|
| 49 |
+
re.I)
|
| 50 |
+
# dose forms as printed -> as RxNorm names them
|
| 51 |
+
FORMS = {"tablet": "tablet", "tab": "tablet", "caplet": "tablet", "capsule": "capsule", "cap": "capsule",
|
| 52 |
+
"syrup": "solution", "solution": "solution", "suspension": "suspension", "cream": "cream",
|
| 53 |
+
"ointment": "ointment", "gel": "gel", "injection": "injection", "drops": "drops", "inhaler": "inhal",
|
| 54 |
+
"patch": "patch", "spray": "spray", "suppository": "suppository"}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def norm(text):
|
| 58 |
+
return " ".join(re.sub(r"[^a-z0-9]+", " ", str(text).lower()).split())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
INN = {norm(k): v for k, v in INN_TO_US.items()}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def dose_form(lines):
|
| 65 |
+
"""The first dose form printed anywhere on the label, as RxNorm names it, or None."""
|
| 66 |
+
for line in lines:
|
| 67 |
+
for word in re.findall(r"[a-z]+", line.lower()):
|
| 68 |
+
word = word[:-1] if word.endswith("s") and word[:-1] in FORMS else word
|
| 69 |
+
if word in FORMS:
|
| 70 |
+
return FORMS[word]
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _edits(a, b, limit):
|
| 75 |
+
"""Levenshtein distance, or limit + 1 once it exceeds limit."""
|
| 76 |
+
if abs(len(a) - len(b)) > limit:
|
| 77 |
+
return limit + 1
|
| 78 |
+
prev = list(range(len(b) + 1))
|
| 79 |
+
for i, ca in enumerate(a, 1):
|
| 80 |
+
cur = [i] + [0] * len(b)
|
| 81 |
+
for j, cb in enumerate(b, 1):
|
| 82 |
+
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))
|
| 83 |
+
if min(cur) > limit:
|
| 84 |
+
return limit + 1
|
| 85 |
+
prev = cur
|
| 86 |
+
return prev[-1]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _strength_text(m):
|
| 90 |
+
amount, unit, per_amount, per_unit = m.groups()
|
| 91 |
+
unit = unit.lower().replace("µg", "mcg").replace("ug", "mcg")
|
| 92 |
+
text = f"{amount.replace(',', '.')}{'' if unit == '%' else ' '}{unit}"
|
| 93 |
+
if per_unit:
|
| 94 |
+
text += f"/{per_amount + ' ' if per_amount else ''}{per_unit.lower()}"
|
| 95 |
+
return text
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@dataclass
|
| 99 |
+
class Found:
|
| 100 |
+
line: int
|
| 101 |
+
start: int
|
| 102 |
+
end: int
|
| 103 |
+
text: str # as printed
|
| 104 |
+
rxcui: str
|
| 105 |
+
tty: str # IN, PIN, BN, or INN for a mapped international name
|
| 106 |
+
us_names: list = field(default_factory=list)
|
| 107 |
+
spelled: str | None = None # RxNorm's spelling when the printed word was misread (fuzzy match)
|
| 108 |
+
strength: str | None = None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class LabelReader:
|
| 112 |
+
def __init__(self, rxnorm):
|
| 113 |
+
self.rxnorm = rxnorm
|
| 114 |
+
self._vocab = None
|
| 115 |
+
|
| 116 |
+
@property
|
| 117 |
+
def vocab(self):
|
| 118 |
+
"""normalised name -> (rxcui, tty), and single words by length for the fuzzy match; fetched once."""
|
| 119 |
+
if self._vocab is None:
|
| 120 |
+
concepts = self.rxnorm.get("allconcepts.json", tty="IN PIN BN")["minConceptGroup"]["minConcept"]
|
| 121 |
+
names = {}
|
| 122 |
+
for c in concepts:
|
| 123 |
+
key = norm(c["name"])
|
| 124 |
+
if key and key not in COMMON and len(key) >= 4 and (key not in names or c["tty"] == "IN"):
|
| 125 |
+
names[key] = (c["rxcui"], c["tty"], c["name"])
|
| 126 |
+
by_len = {}
|
| 127 |
+
for key in names:
|
| 128 |
+
if " " not in key and len(key) >= 6 and not key.isdigit():
|
| 129 |
+
by_len.setdefault(len(key), []).append(key)
|
| 130 |
+
self._vocab = (names, by_len)
|
| 131 |
+
return self._vocab
|
| 132 |
+
|
| 133 |
+
def _lookup(self, phrase):
|
| 134 |
+
names, by_len = self.vocab
|
| 135 |
+
if phrase in INN:
|
| 136 |
+
first = names.get(INN[phrase][0])
|
| 137 |
+
return (first[0] if first else None), "INN", INN[phrase], None
|
| 138 |
+
if phrase in names:
|
| 139 |
+
return names[phrase][0], names[phrase][1], [], None
|
| 140 |
+
if " " not in phrase and len(phrase) >= 6 and not any(ch.isdigit() for ch in phrase):
|
| 141 |
+
limit = 1 if len(phrase) < 9 else 2
|
| 142 |
+
for n in range(len(phrase) - limit, len(phrase) + limit + 1):
|
| 143 |
+
for key in by_len.get(n, ()):
|
| 144 |
+
if key[0] == phrase[0] and _edits(phrase, key, limit) <= limit:
|
| 145 |
+
return names[key][0], names[key][1], [], names[key][2]
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
def find_names(self, lines):
|
| 149 |
+
"""Each medicine name in the lines, with its strength (steps 1 and 2)."""
|
| 150 |
+
found = []
|
| 151 |
+
for li, line in enumerate(lines):
|
| 152 |
+
if (CAUTION.search(line) or CAUTION_START.match(line)) and not STRENGTH.search(line):
|
| 153 |
+
continue # "Avoid grapefruit juice", "Do not take with aspirin": named, but not what was dispensed
|
| 154 |
+
words = [(m.start(), m.end(), m.group().lower()) for m in re.finditer(r"[A-Za-z][A-Za-z\-]*", line)]
|
| 155 |
+
i = 0
|
| 156 |
+
while i < len(words):
|
| 157 |
+
for n in range(min(4, len(words) - i), 0, -1):
|
| 158 |
+
phrase = norm(" ".join(w for _, _, w in words[i:i + n]))
|
| 159 |
+
hit = self._lookup(phrase) if phrase and phrase not in COMMON else None
|
| 160 |
+
if hit and hit[0]:
|
| 161 |
+
start, end = words[i][0], words[i + n - 1][1]
|
| 162 |
+
found.append(Found(li, start, end, line[start:end], hit[0], hit[1], hit[2], hit[3]))
|
| 163 |
+
i += n
|
| 164 |
+
break
|
| 165 |
+
else:
|
| 166 |
+
i += 1
|
| 167 |
+
for f in found: # strengths
|
| 168 |
+
same = [f2 for f2 in found if f2.line == f.line and f2.start > f.start]
|
| 169 |
+
stop = min((f2.start for f2 in same), default=None)
|
| 170 |
+
amounts = list(STRENGTH.finditer(lines[f.line]))
|
| 171 |
+
after = [m for m in amounts if m.start() >= f.end and (stop is None or m.start() < stop)]
|
| 172 |
+
if after:
|
| 173 |
+
f.strength = _strength_text(after[0])
|
| 174 |
+
elif len(amounts) == 1 and len([f2 for f2 in found if f2.line == f.line]) == 1:
|
| 175 |
+
f.strength = _strength_text(amounts[0])
|
| 176 |
+
elif f.line + 1 < len(lines) and not any(f2.line == f.line + 1 for f2 in found):
|
| 177 |
+
nxt = list(STRENGTH.finditer(lines[f.line + 1]))
|
| 178 |
+
if nxt:
|
| 179 |
+
f.strength = _strength_text(nxt[0])
|
| 180 |
+
return found
|
| 181 |
+
|
| 182 |
+
def _ingredients(self, f):
|
| 183 |
+
"""(rxcui, name) of the active ingredients. An ingredient name found in the list is its own answer (no
|
| 184 |
+
RxNorm call: each costs up to a second from Singapore); brands and salts ask RxNorm."""
|
| 185 |
+
names = self.vocab[0]
|
| 186 |
+
if f.tty == "INN":
|
| 187 |
+
known = [names.get(us) for us in f.us_names]
|
| 188 |
+
if all(k and k[1] == "IN" for k in known):
|
| 189 |
+
return sorted({(k[0], us) for k, us in zip(known, f.us_names)}, key=lambda x: x[1])
|
| 190 |
+
out = []
|
| 191 |
+
for us in f.us_names:
|
| 192 |
+
rxcui = self.rxnorm.rxcui_by_name(us)
|
| 193 |
+
out += self.rxnorm.ingredients(rxcui) if rxcui else []
|
| 194 |
+
return sorted(set(out), key=lambda x: x[1])
|
| 195 |
+
if f.tty == "IN":
|
| 196 |
+
return [(f.rxcui, (f.spelled or f.text).lower())] # RxNorm's ingredient name, as printed or corrected
|
| 197 |
+
return self.rxnorm.ingredients(f.rxcui)
|
| 198 |
+
|
| 199 |
+
def _product(self, name, strength, form, ingredients):
|
| 200 |
+
"""RxNorm's concept for name + strength, if it has these ingredients and this strength (step 3)."""
|
| 201 |
+
number = re.match(r"[\d.]+", strength).group()
|
| 202 |
+
want = {r for r, _ in ingredients}
|
| 203 |
+
candidates = [r for r, _ in self.rxnorm.approximate(" ".join(filter(None, (name, strength, form))))]
|
| 204 |
+
with ThreadPoolExecutor(5) as pool: # each candidate's details in parallel, then judged in RxNorm's order
|
| 205 |
+
props = list(pool.map(lambda r: self.rxnorm.properties(r) or {}, candidates))
|
| 206 |
+
fit = [(r, p) for r, p in zip(candidates, props)
|
| 207 |
+
if p.get("tty") in ("SCD", "SBD", "SCDC", "SBDC", "GPCK", "BPCK")
|
| 208 |
+
and re.search(rf"(?<![\d.]){re.escape(number)}(?![\d])", p.get("name", ""))]
|
| 209 |
+
wanted_names = [n.lower() for _, n in ingredients]
|
| 210 |
+
# a clinical drug's name lists its ingredients ("acetaminophen 500 MG Oral Tablet"): no lookup needed
|
| 211 |
+
named = [all(n in p["name"].lower() for n in wanted_names) for _, p in fit]
|
| 212 |
+
ingredients_of = list(pool.map(lambda rp: None if rp[1] else self.rxnorm.ingredients(rp[0][0]),
|
| 213 |
+
zip(fit, named)))
|
| 214 |
+
fallback = None
|
| 215 |
+
for (rxcui, p), is_named, ins in zip(fit, named, ingredients_of):
|
| 216 |
+
if is_named or want <= {r for r, _ in ins}:
|
| 217 |
+
if not form or form in p["name"].lower():
|
| 218 |
+
return rxcui, p["name"]
|
| 219 |
+
fallback = fallback or (rxcui, p["name"]) # right medicine and strength, other dose form
|
| 220 |
+
return fallback
|
| 221 |
+
|
| 222 |
+
def read(self, lines):
|
| 223 |
+
"""OCR lines -> candidate medicines, best first (steps 1-4)."""
|
| 224 |
+
form = dose_form(lines)
|
| 225 |
+
found_all = self.find_names(lines)
|
| 226 |
+
with ThreadPoolExecutor(8) as pool: # RxNorm lookups in parallel (the client keeps to NLM's rate limit)
|
| 227 |
+
ingredient_lists = list(pool.map(self._ingredients, found_all))
|
| 228 |
+
groups = [{"found": [f], "ingredients": ing} for f, ing in zip(found_all, ingredient_lists) if ing]
|
| 229 |
+
groups.sort(key=lambda g: -len(g["ingredients"])) # brands with several ingredients absorb their parts
|
| 230 |
+
merged = []
|
| 231 |
+
for g in groups:
|
| 232 |
+
keys = {r for r, _ in g["ingredients"]}
|
| 233 |
+
home = next((m for m in merged if keys <= {r for r, _ in m["ingredients"]}), None)
|
| 234 |
+
if home:
|
| 235 |
+
home["found"] += g["found"]
|
| 236 |
+
else:
|
| 237 |
+
merged.append(g)
|
| 238 |
+
plans = []
|
| 239 |
+
for g in merged:
|
| 240 |
+
found = sorted(g["found"], key=lambda f: (f.line, f.start))
|
| 241 |
+
brand = next((f for f in found if f.tty == "BN"), None)
|
| 242 |
+
strengths = []
|
| 243 |
+
for f in found:
|
| 244 |
+
if f.strength and f.strength not in strengths:
|
| 245 |
+
strengths.append(f.strength)
|
| 246 |
+
lead = brand or found[0]
|
| 247 |
+
query = (lead.spelled or lead.text) if lead.tty != "INN" else " ".join(lead.us_names)
|
| 248 |
+
plans.append((g, found, brand, strengths, lead, query))
|
| 249 |
+
with ThreadPoolExecutor(8) as pool: # the exact products, in parallel
|
| 250 |
+
products = list(pool.map(lambda p: self._product(p[5], p[3][0], form, p[0]["ingredients"])
|
| 251 |
+
if len(p[3]) == 1 else None, plans))
|
| 252 |
+
out = []
|
| 253 |
+
for (g, found, brand, strengths, lead, _), product in zip(plans, products):
|
| 254 |
+
printed = lead.spelled[:1].upper() + lead.spelled[1:] if lead.spelled else lead.text
|
| 255 |
+
label = printed if not printed.isupper() else printed.capitalize()
|
| 256 |
+
strength = strengths[0] if len(strengths) == 1 else ", ".join(strengths) or None
|
| 257 |
+
out.append({
|
| 258 |
+
"name": f"{label} {strength}" if strength else label,
|
| 259 |
+
"brand": brand.text if brand else None,
|
| 260 |
+
"ingredients": [{"rxcui": r, "name": n} for r, n in g["ingredients"]],
|
| 261 |
+
"strength": strength,
|
| 262 |
+
"rxcui": product[0] if product else (lead.rxcui if lead.tty != "INN" else g["ingredients"][0][0]),
|
| 263 |
+
"rxnorm_name": product[1] if product else None,
|
| 264 |
+
"found_in": [lines[i] for i in sorted({f.line for f in found})],
|
| 265 |
+
"first_line": found[0].line,
|
| 266 |
+
"_rank": (-(bool(brand) + bool(strengths) + bool(product) + len(found)), found[0].line),
|
| 267 |
+
})
|
| 268 |
+
out.sort(key=lambda c: c.pop("_rank"))
|
| 269 |
+
return out
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ---- what else a label or prescription says (blueprint job B7) -------------------------------------------------------
|
| 273 |
+
DIRECTION_START = re.compile(r"^\s*(take|apply|use|inhale|instil+|insert|dissolve|chew|swallow|place|spray|give|put|"
|
| 274 |
+
r"rub|gargle|dilute|one|two|1|2)\b", re.I)
|
| 275 |
+
DIRECTION_WORDS = re.compile(r"\b(times? (a|per) day|times daily|(once|twice|thrice) (a|per) day|daily|"
|
| 276 |
+
r"every \d+ hours?|every (morning|night|evening|day)|when necessary|as needed|if needed|"
|
| 277 |
+
r"as required|at night|in the morning|in the evening|at bedtime|before (bed|sleep)|"
|
| 278 |
+
r"before (meals?|food|breakfast|lunch|dinner)|after (meals?|food|breakfast|lunch|dinner)|"
|
| 279 |
+
r"with (meals?|food)|with or without (food|meals?)|on an empty stomach)\b", re.I)
|
| 280 |
+
# pharmacy shorthand, upper case only ("ON" is also an ordinary word, so only right after the dose: "1 TAB ON")
|
| 281 |
+
ABBREVIATION = re.compile(r"\b(BD|BID|TDS|TID|QDS|QID|OD|OM|PRN)\b|\b(?:TABS?|TABLETS?|CAPS?|CAPSULES?)\s+ON\b")
|
| 282 |
+
QUANTITY = re.compile(r"\b(?:qty|quantity|dispensed)\s*[:.]?\s*(\d+)\s*([a-z]*)|\b(\d+)\s+"
|
| 283 |
+
r"(?:(?!(?:mg|mcg|g|ml|iu)\b)[a-z\-]+\s+)?(tablets?|capsules?|caplets?|tabs?|caps?|sachets?)\b",
|
| 284 |
+
re.I)
|
| 285 |
+
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)"
|
| 286 |
+
r"[a-z]* \d{4}|\d{1,2}[/.\-]\d{4})\b", re.I)
|
| 287 |
+
PERSONAL = re.compile(r"\b(name|patient|nric|fin|address|tel|phone|dob|date of birth)\b", re.I)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _field(value, line):
|
| 291 |
+
return {"value": value, "line": line} if value else None
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# ---- a Singapore dispensing label, field by field --------------------------------------------------------------------
|
| 295 |
+
# The pharmacy sticker on the bag or box: medicine and strength, quantity, then the instructions ("Take 1 tablet twice
|
| 296 |
+
# a day after food"), often what it is for ("For high blood pressure") and cautions ("May cause drowsiness").
|
| 297 |
+
# Everything is parsed from what is printed; a part that isn't printed stays None.
|
| 298 |
+
NUMBER_WORDS = {"half": 0.5, "½": 0.5, "1/2": 0.5, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
|
| 299 |
+
"once": 1, "twice": 2, "thrice": 3}
|
| 300 |
+
DOSE_UNITS = {"tablet": "tablet", "tab": "tablet", "caplet": "tablet", "capsule": "capsule", "cap": "capsule",
|
| 301 |
+
"ml": "ml", "puff": "puff", "drop": "drop", "sachet": "sachet", "teaspoon": "teaspoon", "tsp": "teaspoon",
|
| 302 |
+
"patch": "patch", "spray": "spray", "suppository": "suppository", "lozenge": "lozenge",
|
| 303 |
+
"bottle": "bottle",
|
| 304 |
+
"tube": "tube", "box": "box", "inhaler": "inhaler"}
|
| 305 |
+
_AMT = r"(\d+(?:\.\d+)?|½|1/2|half|one|two|three|four|five|six)"
|
| 306 |
+
_UNIT = r"(tablets?|tabs?|caplets?|capsules?|caps?|ml|puffs?|drops?|sachets?|teaspoons?|tsps?|patch(?:es)?|sprays?|" \
|
| 307 |
+
r"suppositor(?:y|ies)|lozenges?)"
|
| 308 |
+
DOSE = re.compile(rf"\b{_AMT}(?:\s*(?:to|-)\s*{_AMT})?\s*{_UNIT}\b", re.I)
|
| 309 |
+
FREQUENCY = [ # (pattern, times a day); "every 8 hours" is kept as every_hours, never turned into times a day
|
| 310 |
+
(re.compile(r"\b(\d|one|two|three|four|five|six)\s*(?:times?|x)\s*(?:a|per|each|every)?\s*(?:day|daily)\b", re.I),
|
| 311 |
+
None),
|
| 312 |
+
(re.compile(r"\b(once|twice|thrice)\s*(?:a|per|each|every)?\s*(?:day|daily)\b", re.I), None),
|
| 313 |
+
(re.compile(r"\b(?:every|each)\s+day\b|\bdaily\b", re.I), 1),
|
| 314 |
+
(re.compile(r"\bevery\s+(?:morning|night|evening)\b", re.I), 1),
|
| 315 |
+
(re.compile(r"\b(?:OD|QD)\b"), 1), (re.compile(r"\b(?:BD|BID)\b"), 2), (re.compile(r"\b(?:TDS|TID)\b"), 3),
|
| 316 |
+
(re.compile(r"\b(?:QDS|QID)\b"), 4),
|
| 317 |
+
(re.compile(r"\bOM\b|\b(?:TABS?|TABLETS?|CAPS?|CAPSULES?)\s+ON\b"), 1), # every morning, every night
|
| 318 |
+
]
|
| 319 |
+
EVERY_HOURS = re.compile(r"\bevery\s+(\d+(?:\.\d+)?)\s*(?:-\s*\d+\s*)?(?:hours?|hrs?)\b", re.I)
|
| 320 |
+
AS_NEEDED = re.compile(r"\b(when necessary|as needed|if needed|when needed|as required|when required|if required|"
|
| 321 |
+
r"if necessary)\b|\bPRN\b", re.I)
|
| 322 |
+
TIMES_OF_DAY = [("morning", re.compile(r"\bmorning\b|\b(?:tabs?|tablets?|caps?|capsules?)\s+OM\b", re.I)),
|
| 323 |
+
("afternoon", re.compile(r"\bafternoon\b", re.I)), ("evening", re.compile(r"\bevening\b", re.I)),
|
| 324 |
+
("night", re.compile(r"\bnight\b|\b(?:tabs?|tablets?|caps?|capsules?)\s+ON\b", re.I)),
|
| 325 |
+
("bedtime", re.compile(r"\bbedtime\b|\bbefore (?:going to )?(?:bed|sleep)\b", re.I))]
|
| 326 |
+
FOOD = [("with_or_without_food", re.compile(r"\bwith or without (?:food|meals?)\b", re.I)),
|
| 327 |
+
("empty_stomach", re.compile(r"\bon an empty stomach\b", re.I)),
|
| 328 |
+
("before_food", re.compile(r"\bbefore (?:food|meals?|breakfast|lunch|dinner|eating)\b", re.I)),
|
| 329 |
+
("after_food", re.compile(r"\bafter (?:food|meals?|breakfast|lunch|dinner|eating)\b", re.I)),
|
| 330 |
+
("with_food", re.compile(r"\bwith (?:food|meals?|a meal|milk)\b", re.I))]
|
| 331 |
+
DURATION = re.compile(r"\bfor\s+(\d+|one|two|three|four|five|six|seven)\s+(days?|weeks?|months?)\b", re.I)
|
| 332 |
+
MAX_PER_DAY = re.compile(r"\b(?:not more than|no more than|do not (?:take|exceed) more than|max(?:imum)?(?: of)?)\s+"
|
| 333 |
+
r"(\d+)\s*([a-z]+)?\s*(?:in|per|a|within)\s+(?:any\s+)?(24 hours|day)\b", re.I)
|
| 334 |
+
INDICATION = re.compile(r"(?:^|\b)(?:indications?\s*[:\-]|(?:this medicine is |used |it is )?for|to (treat|relieve|"
|
| 335 |
+
r"reduce|lower|prevent|control))\s*:?\s+([a-z][a-z0-9 ,/&()'\-]{2,60})", re.I)
|
| 336 |
+
NOT_INDICATION = re.compile(r"^(?:external|oral|internal|\d|one|two|three|four|five|six|seven|the next|up to|a day|"
|
| 337 |
+
r"a week|days?|weeks?|use only)\b", re.I)
|
| 338 |
+
CAUTION = re.compile(r"\b(drows|alcohol|do not drive|operat\w* machine|complete the (?:whole|full|entire) course|"
|
| 339 |
+
r"finish the course|swallow (?:it )?whole|do not (?:crush|chew|break|cut)|shake well|refrigerat|"
|
| 340 |
+
r"fridge|store (?:below|in|at)|keep (?:in|away|out of)|protect from|external use|avoid|"
|
| 341 |
+
r"do not take (?:with|together)|may (?:cause|colour|color|discolou?r|make)|stop (?:taking|if)|"
|
| 342 |
+
r"sun ?light|sunburn|dizz|grapefruit)", re.I)
|
| 343 |
+
CAUTION_START = re.compile(r"^\s*(caution|warning|note|precaution|important)\b", re.I)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _unit(word):
|
| 347 |
+
"""'capsules' -> 'capsule', 'tabs' -> 'tablet', 'patches' -> 'patch', 'suppositories' -> 'suppository'."""
|
| 348 |
+
w = word.lower()
|
| 349 |
+
for candidate in (w, w[:-1] if w.endswith("s") else w, w[:-2] if w.endswith("es") else w,
|
| 350 |
+
w[:-3] + "y" if w.endswith("ies") else w):
|
| 351 |
+
if candidate in DOSE_UNITS:
|
| 352 |
+
return DOSE_UNITS[candidate]
|
| 353 |
+
return w or None
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _number(word):
|
| 357 |
+
return NUMBER_WORDS.get(word.lower(), None) if not re.match(r"\d", word) else float(word)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def parse_instructions(text):
|
| 361 |
+
"""
|
| 362 |
+
'Take 1 tablet 2 times a day after food' -> the instruction's parts: dose (amount, amount_max for '1-2', unit),
|
| 363 |
+
times_per_day, every_hours, as_needed, times_of_day, food, duration, max_per_day. A part that isn't printed is
|
| 364 |
+
None (or [] / False); nothing is filled in from habit (every 8 hours stays every_hours).
|
| 365 |
+
"""
|
| 366 |
+
t = re.sub(r"\(s\)", "", text or "")
|
| 367 |
+
dose = None
|
| 368 |
+
m = DOSE.search(t)
|
| 369 |
+
if m:
|
| 370 |
+
unit = _unit(m.group(3))
|
| 371 |
+
dose = {"amount": _number(m.group(1)), "amount_max": _number(m.group(2)) if m.group(2) else None,
|
| 372 |
+
"unit": unit}
|
| 373 |
+
times = None
|
| 374 |
+
for pattern, fixed in FREQUENCY:
|
| 375 |
+
f = pattern.search(t)
|
| 376 |
+
if f:
|
| 377 |
+
times = fixed if fixed is not None else int(_number(f.group(1)))
|
| 378 |
+
break
|
| 379 |
+
every = EVERY_HOURS.search(t)
|
| 380 |
+
food = next((name for name, p in FOOD if p.search(t)), None)
|
| 381 |
+
duration = DURATION.search(t)
|
| 382 |
+
most = MAX_PER_DAY.search(t)
|
| 383 |
+
return {
|
| 384 |
+
"dose": dose, "times_per_day": times, "every_hours": float(every.group(1)) if every else None,
|
| 385 |
+
"as_needed": bool(AS_NEEDED.search(t)),
|
| 386 |
+
"times_of_day": [name for name, p in TIMES_OF_DAY if p.search(t)],
|
| 387 |
+
"food": food,
|
| 388 |
+
"duration": f"{duration.group(1)} {duration.group(2).lower()}" if duration else None,
|
| 389 |
+
"max_per_day": " ".join(x for x in (most.group(1), (most.group(2) or "").lower(), "in", most.group(3).lower())
|
| 390 |
+
if x) if most else None,
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def parse_quantity(value):
|
| 395 |
+
"""'60 tab' -> (60.0, 'tablet'); '100 ml' -> (100.0, 'ml')."""
|
| 396 |
+
m = re.match(r"(\d+(?:\.\d+)?)\s*([a-z]*)", value or "", re.I)
|
| 397 |
+
if not m:
|
| 398 |
+
return None, None
|
| 399 |
+
unit = _unit(m.group(2))
|
| 400 |
+
return float(m.group(1)), unit
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def indication_of(line):
|
| 404 |
+
"""'For high blood pressure' -> 'high blood pressure'; None for 'for external use', 'for 5 days', ..."""
|
| 405 |
+
for m in INDICATION.finditer(line):
|
| 406 |
+
what = m.group(2).strip(" .,;:-")
|
| 407 |
+
if what and not NOT_INDICATION.match(what) and not AS_NEEDED.fullmatch(what):
|
| 408 |
+
return f"{m.group(1).lower()} {what}" if m.group(1) else what # 'to lower cholesterol' -> 'lower ...'
|
| 409 |
+
return None
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def document_fields(lines, candidates):
|
| 413 |
+
"""
|
| 414 |
+
Per candidate, what the label prints about it, each value with the line it was read from; None when it isn't
|
| 415 |
+
printed (never inferred). A candidate's block runs from its first line to the next candidate's (the first one
|
| 416 |
+
also gets the header lines above it: the dispensing date). Lines with personal details are never used.
|
| 417 |
+
"""
|
| 418 |
+
starts = sorted({c["first_line"] for c in candidates})
|
| 419 |
+
for c in candidates:
|
| 420 |
+
i = starts.index(c["first_line"])
|
| 421 |
+
lo = 0 if i == 0 else starts[i]
|
| 422 |
+
hi = starts[i + 1] if i + 1 < len(starts) else len(lines)
|
| 423 |
+
block = [(n, lines[n]) for n in range(lo, hi) if not PERSONAL.search(lines[n])]
|
| 424 |
+
directions, quantity, date, expiry, indication, cautions = [], None, None, None, None, []
|
| 425 |
+
for n, line in block:
|
| 426 |
+
is_direction = n >= c["first_line"] and (
|
| 427 |
+
DIRECTION_START.search(line) and (DIRECTION_WORDS.search(line) or ABBREVIATION.search(line)
|
| 428 |
+
or DOSE.search(line))
|
| 429 |
+
or (directions and directions[-1][0] == n - 1
|
| 430 |
+
and (DIRECTION_WORDS.search(line) or ABBREVIATION.search(line) or MAX_PER_DAY.search(line))))
|
| 431 |
+
if n >= c["first_line"] and line not in c["found_in"]:
|
| 432 |
+
indication = indication or _field(indication_of(line), line)
|
| 433 |
+
if is_direction:
|
| 434 |
+
directions.append((n, line))
|
| 435 |
+
cautions += [s.strip() for s in re.split(r"(?<=[.;!])\s+", line) if CAUTION.search(s)]
|
| 436 |
+
continue
|
| 437 |
+
if n >= c["first_line"] and line not in c["found_in"] and \
|
| 438 |
+
(CAUTION.search(line) or CAUTION_START.match(line)) and not DATE.search(line):
|
| 439 |
+
cautions.append(line.strip())
|
| 440 |
+
continue
|
| 441 |
+
q = QUANTITY.search(line)
|
| 442 |
+
# an explicit "Qty:" also counts on the name's own line, or just above it (a tilted photo puts the right
|
| 443 |
+
# end of "METFORMIN 500MG TABLET Qty: 60 TAB" a little higher, so it is read first)
|
| 444 |
+
if q and quantity is None and (n >= c["first_line"] or q.group(1)) and \
|
| 445 |
+
(line not in c["found_in"] or q.group(1)):
|
| 446 |
+
number, unit = (q.group(1), q.group(2)) if q.group(1) else (q.group(3), q.group(4))
|
| 447 |
+
quantity = _field(f"{number} {unit.lower()}".strip(), line)
|
| 448 |
+
d = DATE.search(line)
|
| 449 |
+
if d:
|
| 450 |
+
if re.search(r"\bexp", line, re.I):
|
| 451 |
+
expiry = expiry or _field(d.group(1), line)
|
| 452 |
+
else:
|
| 453 |
+
date = date or _field(d.group(1), line)
|
| 454 |
+
form = dose_form([line for n, line in block if n >= c["first_line"]])
|
| 455 |
+
text = " ".join(l for _, l in directions)
|
| 456 |
+
if quantity:
|
| 457 |
+
quantity["amount"], quantity["unit"] = parse_quantity(quantity["value"])
|
| 458 |
+
c["fields"] = {
|
| 459 |
+
"directions": _field(text, " / ".join(l for _, l in directions)),
|
| 460 |
+
"instructions": {"text": text, "line": " / ".join(l for _, l in directions), **parse_instructions(text)}
|
| 461 |
+
if directions else None,
|
| 462 |
+
"indication": indication,
|
| 463 |
+
"cautions": [{"value": s, "line": s} for s in dict.fromkeys(cautions)],
|
| 464 |
+
"quantity": quantity,
|
| 465 |
+
"dose_form": _field(form, next((l for n, l in block if n >= c["first_line"] and form and
|
| 466 |
+
re.search(form[:5], l, re.I)), None)),
|
| 467 |
+
"date": date,
|
| 468 |
+
"expiry_date": expiry,
|
| 469 |
+
}
|
| 470 |
+
return candidates
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
# ---- is this box one of my medicines? (blueprint job B9) -------------------------------------------------------------
|
| 474 |
+
def _amounts(strength):
|
| 475 |
+
"""'500 mg, 65 mg' or '500 MG / 65 MG' -> {(500.0, 'mg'), (65.0, 'mg')}."""
|
| 476 |
+
return {(float(m.group(1).replace(",", ".")), m.group(2).lower().replace("µg", "mcg").replace("ug", "mcg"))
|
| 477 |
+
for m in STRENGTH.finditer(strength or "")}
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def compare_with_list(candidate, medications):
|
| 481 |
+
"""
|
| 482 |
+
medications: the patient's list, each {"id", "name", "ingredients": {rxcui}, "strength": printed or None}.
|
| 483 |
+
-> {"status", "medication_id", "message"}: a deterministic comparison of ingredients and strength, no model score.
|
| 484 |
+
same_medicine same ingredients and strength (or the list gives no strength)
|
| 485 |
+
different_strength same ingredients, another strength
|
| 486 |
+
contains_listed a combination that includes a listed medicine's ingredient
|
| 487 |
+
not_on_list no ingredient in common
|
| 488 |
+
"""
|
| 489 |
+
have = {i["rxcui"] for i in candidate["ingredients"] if i.get("rxcui")}
|
| 490 |
+
best = None
|
| 491 |
+
for med in medications:
|
| 492 |
+
common = have & med["ingredients"]
|
| 493 |
+
if not common:
|
| 494 |
+
continue
|
| 495 |
+
if common == have == med["ingredients"]:
|
| 496 |
+
a, b = _amounts(candidate.get("strength")), _amounts(med.get("strength"))
|
| 497 |
+
if a and b and a != b:
|
| 498 |
+
status = "different_strength"
|
| 499 |
+
message = (f"This says {candidate.get('strength')}; your list has {med['name']}. Check with your "
|
| 500 |
+
"pharmacist.")
|
| 501 |
+
else:
|
| 502 |
+
status = "same_medicine"
|
| 503 |
+
message = f"This matches your {med['name']}: the same medicine" + \
|
| 504 |
+
(" and strength." if a and b else ". Check the strength printed on it.")
|
| 505 |
+
return {"status": status, "medication_id": med["id"], "message": message}
|
| 506 |
+
best = best or {"status": "contains_listed", "medication_id": med["id"],
|
| 507 |
+
"message": f"This medicine shares an ingredient with your {med['name']} but is not the same. "
|
| 508 |
+
"Check with your pharmacist."}
|
| 509 |
+
return best or {"status": "not_on_list", "medication_id": None,
|
| 510 |
+
"message": "This medicine isn't on your list. Check with your pharmacist."}
|