DavidPanSX commited on
Commit
414ecc0
·
verified ·
1 Parent(s): 9780f57

deploy API

Browse files
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": {"top1_right": "test/top1_right",
91
- "possible_match_share": "test/catalogue_pills_by_verdict/possible_match/share",
92
- "possible_match_right": "test/catalogue_pills_by_verdict/possible_match/top1_right",
93
- "not_in_catalogue_called_possible_match": "test/other_pills_by_verdict/possible_match"},
 
 
 
 
 
 
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.9.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
- **Which medicine is on this box, label or prescription?** Reads the printed text and finds the medicine names in
893
- it (RxNorm's ingredient and brand names, international names such as paracetamol too), with strength,
894
- ingredients, RxNorm code, and what is printed about each (directions, quantity, dose form, dates), every value
895
- with the line it was read from and `null` when not printed (blueprint B7). With `medications`, each medicine is
 
 
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
- result = {"candidates": candidates, "note": LABEL_NOTE, "ocr_model": ocr_model, "source": source}
 
 
 
 
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
- quantity: Printed | None = None
 
 
 
 
 
 
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 medicine box, pharmacy label or prescription (photo or PDF): medicine names, strength, RxNorm code and "
36
- "what is printed (directions, quantity, dates), each with its source line. Fields not printed are null; never "
37
- "fill them in. With the patient's medications, says same medicine / different strength / not on the list. "
38
- "Always have the patient confirm."),
39
- ("find_medication_product", "get", "/v1/pills/search",
40
- "Products and pictures for a medicine name (brand or generic), with strength, dose form and release as fields. "
41
- "Never treat products of another strength or form as the same."),
42
- ("screen_medication_list", "post", "/v1/safety/medications",
43
- "Interaction and duplicate-ingredient alerts for the whole medication list (DDInter 2.0). Read coverage: no "
44
- "alert does not mean safe. Advisory; defer to the pharmacist."),
45
- ("evaluate_dose_reference", "post", "/v1/safety/dose",
46
- "The reference dose rules on visual decisions (stateless). The backend's own dose policy is authoritative."),
47
- ("retrieve_medication_info", "get", "/v1/safety/label",
48
- "Warning sections of the official US label, with the label's date and DailyMed link for citation. General "
49
- "information, not the patient's instructions."),
50
- ("get_model_info", "get", "/v1/model-info",
51
- "The served model: version, embedding space, decision rule and known limits. Check after a 409."),
52
- ]
53
- FILE_NOTE = "An image reference: the app's tool layer uploads the file (photo, or PDF for documents)"
54
-
55
-
56
- def _resolve(schema, components):
57
- ref = schema.get("$ref", "") if isinstance(schema, dict) else ""
58
- return components.get(ref.rsplit("/", 1)[-1], {}) if ref else schema
59
-
60
-
61
- def _input_schema(op, components):
62
- props, required = {}, []
63
- for p in op.get("parameters", []):
64
- props[p["name"]] = {**p.get("schema", {}), "description": p.get("description", "")}
65
- if p.get("required"):
66
- required.append(p["name"])
67
- content = op.get("requestBody", {}).get("content", {})
68
- for kind in ("application/json", "multipart/form-data"):
69
- if kind in content:
70
- body = _resolve(content[kind]["schema"], components)
71
- for name, s in body.get("properties", {}).items():
72
- is_file = s.get("format") == "binary" or s.get("contentMediaType") or \
73
- (s.get("type") == "array" and (s.get("items", {}).get("format") == "binary"
74
- or s.get("items", {}).get("contentMediaType")))
75
- props[name] = ({"type": "array", "items": {"type": "string"}, "description": FILE_NOTE}
76
- if is_file and s.get("type") == "array" else
77
- {"type": "string", "description": FILE_NOTE} if is_file else s)
78
- required += body.get("required", [])
79
- return {"type": "object", "properties": props, "required": sorted(set(required))}
80
-
81
-
82
- def build_tools(openapi):
83
- components = openapi.get("components", {}).get("schemas", {})
84
- tools = []
85
- for name, method, path, description in TOOLS:
86
- op = openapi["paths"][path][method]
87
- ok = op.get("responses", {}).get("200", {}).get("content", {}).get("application/json", {})
88
- kind = next(iter(op.get("requestBody", {}).get("content", {"none": None})))
89
- tools.append({"name": name, "description": description, "input_schema": _input_schema(op, components),
90
- "output_schema": ok.get("schema", {}),
91
- "call": {"method": method.upper(), "path": path,
92
- "content_type": None if kind == "none" else kind}})
93
- return {"tools": tools, "components": {"schemas": components},
94
- "notes": "Stage 2 answers are visual evidence only. The backend's safety policy decides actions; the "
95
- "agent reports evidence with its limits and asks the patient to confirm."}
 
 
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
- words = [(m.start(), m.end(), m.group().lower()) for m in re.finditer(r"[A-Za-z][A-Za-z\-]*", line)]
153
- i = 0
154
- while i < len(words):
155
- for n in range(min(4, len(words) - i), 0, -1):
156
- phrase = norm(" ".join(w for _, _, w in words[i:i + n]))
157
- hit = self._lookup(phrase) if phrase and phrase not in COMMON else None
158
- if hit and hit[0]:
159
- start, end = words[i][0], words[i + n - 1][1]
160
- found.append(Found(li, start, end, line[start:end], hit[0], hit[1], hit[2], hit[3]))
161
- i += n
162
- break
163
- else:
164
- i += 1
165
- for f in found: # strengths
166
- same = [f2 for f2 in found if f2.line == f.line and f2.start > f.start]
167
- stop = min((f2.start for f2 in same), default=None)
168
- amounts = list(STRENGTH.finditer(lines[f.line]))
169
- after = [m for m in amounts if m.start() >= f.end and (stop is None or m.start() < stop)]
170
- if after:
171
- f.strength = _strength_text(after[0])
172
- elif len(amounts) == 1 and len([f2 for f2 in found if f2.line == f.line]) == 1:
173
- f.strength = _strength_text(amounts[0])
174
- elif f.line + 1 < len(lines) and not any(f2.line == f.line + 1 for f2 in found):
175
- nxt = list(STRENGTH.finditer(lines[f.line + 1]))
176
- if nxt:
177
- f.strength = _strength_text(nxt[0])
178
- return found
179
-
180
- def _ingredients(self, f):
181
- """(rxcui, name) of the active ingredients. An ingredient name found in the list is its own answer (no
182
- RxNorm call: each costs up to a second from Singapore); brands and salts ask RxNorm."""
183
- names = self.vocab[0]
184
- if f.tty == "INN":
185
- known = [names.get(us) for us in f.us_names]
186
- if all(k and k[1] == "IN" for k in known):
187
- return sorted({(k[0], us) for k, us in zip(known, f.us_names)}, key=lambda x: x[1])
188
- out = []
189
- for us in f.us_names:
190
- rxcui = self.rxnorm.rxcui_by_name(us)
191
- out += self.rxnorm.ingredients(rxcui) if rxcui else []
192
- return sorted(set(out), key=lambda x: x[1])
193
- if f.tty == "IN":
194
- return [(f.rxcui, (f.spelled or f.text).lower())] # RxNorm's ingredient name, as printed or corrected
195
- return self.rxnorm.ingredients(f.rxcui)
196
-
197
- def _product(self, name, strength, form, ingredients):
198
- """RxNorm's concept for name + strength, if it has these ingredients and this strength (step 3)."""
199
- number = re.match(r"[\d.]+", strength).group()
200
- want = {r for r, _ in ingredients}
201
- candidates = [r for r, _ in self.rxnorm.approximate(" ".join(filter(None, (name, strength, form))))]
202
- with ThreadPoolExecutor(5) as pool: # each candidate's details in parallel, then judged in RxNorm's order
203
- props = list(pool.map(lambda r: self.rxnorm.properties(r) or {}, candidates))
204
- fit = [(r, p) for r, p in zip(candidates, props)
205
- if p.get("tty") in ("SCD", "SBD", "SCDC", "SBDC", "GPCK", "BPCK")
206
- and re.search(rf"(?<![\d.]){re.escape(number)}(?![\d])", p.get("name", ""))]
207
- wanted_names = [n.lower() for _, n in ingredients]
208
- # a clinical drug's name lists its ingredients ("acetaminophen 500 MG Oral Tablet"): no lookup needed
209
- named = [all(n in p["name"].lower() for n in wanted_names) for _, p in fit]
210
- ingredients_of = list(pool.map(lambda rp: None if rp[1] else self.rxnorm.ingredients(rp[0][0]),
211
- zip(fit, named)))
212
- fallback = None
213
- for (rxcui, p), is_named, ins in zip(fit, named, ingredients_of):
214
- if is_named or want <= {r for r, _ in ins}:
215
- if not form or form in p["name"].lower():
216
- return rxcui, p["name"]
217
- fallback = fallback or (rxcui, p["name"]) # right medicine and strength, other dose form
218
- return fallback
219
-
220
- def read(self, lines):
221
- """OCR lines -> candidate medicines, best first (steps 1-4)."""
222
- form = dose_form(lines)
223
- found_all = self.find_names(lines)
224
- with ThreadPoolExecutor(8) as pool: # RxNorm lookups in parallel (the client keeps to NLM's rate limit)
225
- ingredient_lists = list(pool.map(self._ingredients, found_all))
226
- groups = [{"found": [f], "ingredients": ing} for f, ing in zip(found_all, ingredient_lists) if ing]
227
- groups.sort(key=lambda g: -len(g["ingredients"])) # brands with several ingredients absorb their parts
228
- merged = []
229
- for g in groups:
230
- keys = {r for r, _ in g["ingredients"]}
231
- home = next((m for m in merged if keys <= {r for r, _ in m["ingredients"]}), None)
232
- if home:
233
- home["found"] += g["found"]
234
- else:
235
- merged.append(g)
236
- plans = []
237
- for g in merged:
238
- found = sorted(g["found"], key=lambda f: (f.line, f.start))
239
- brand = next((f for f in found if f.tty == "BN"), None)
240
- strengths = []
241
- for f in found:
242
- if f.strength and f.strength not in strengths:
243
- strengths.append(f.strength)
244
- lead = brand or found[0]
245
- query = (lead.spelled or lead.text) if lead.tty != "INN" else " ".join(lead.us_names)
246
- plans.append((g, found, brand, strengths, lead, query))
247
- with ThreadPoolExecutor(8) as pool: # the exact products, in parallel
248
- products = list(pool.map(lambda p: self._product(p[5], p[3][0], form, p[0]["ingredients"])
249
- if len(p[3]) == 1 else None, plans))
250
- out = []
251
- for (g, found, brand, strengths, lead, _), product in zip(plans, products):
252
- printed = lead.spelled[:1].upper() + lead.spelled[1:] if lead.spelled else lead.text
253
- label = printed if not printed.isupper() else printed.capitalize()
254
- strength = strengths[0] if len(strengths) == 1 else ", ".join(strengths) or None
255
- out.append({
256
- "name": f"{label} {strength}" if strength else label,
257
- "brand": brand.text if brand else None,
258
- "ingredients": [{"rxcui": r, "name": n} for r, n in g["ingredients"]],
259
- "strength": strength,
260
- "rxcui": product[0] if product else (lead.rxcui if lead.tty != "INN" else g["ingredients"][0][0]),
261
- "rxnorm_name": product[1] if product else None,
262
- "found_in": [lines[i] for i in sorted({f.line for f in found})],
263
- "first_line": found[0].line,
264
- "_rank": (-(bool(brand) + bool(strengths) + bool(product) + len(found)), found[0].line),
265
- })
266
- out.sort(key=lambda c: c.pop("_rank"))
267
- return out
268
-
269
-
270
- # ---- what else a label or prescription says (blueprint job B7) -------------------------------------------------------
271
- DIRECTION_START = re.compile(r"^\s*(take|apply|use|inhale|instil+|insert|dissolve|chew|swallow|place|spray|give|put|"
272
- r"rub|gargle|dilute|one|two|1|2)\b", re.I)
273
- DIRECTION_WORDS = re.compile(r"\b(times? (a|per) day|(once|twice|thrice) (a|per) day|daily|every \d+ hours?|"
274
- r"when necessary|as needed|at night|in the "
275
- r"morning|at bedtime|before (meals|food|breakfast)|after (meals|food|breakfast)|"
276
- r"with (meals|food)|on an empty stomach)\b", re.I)
277
- QUANTITY = re.compile(r"\b(?:qty|quantity|dispensed)\s*[:.]?\s*(\d+)\s*([a-z]*)|\b(\d+)\s+"
278
- r"(?:(?!(?:mg|mcg|g|ml|iu)\b)[a-z\-]+\s+)?(tablets?|capsules?|caplets?|tabs?|caps?|sachets?)\b",
279
- re.I)
280
- 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)"
281
- r"[a-z]* \d{4}|\d{1,2}[/.\-]\d{4})\b", re.I)
282
- PERSONAL = re.compile(r"\b(name|patient|nric|fin|address|tel|phone|dob|date of birth)\b", re.I)
283
-
284
-
285
- def _field(value, line):
286
- return {"value": value, "line": line} if value else None
287
-
288
-
289
- def document_fields(lines, candidates):
290
- """
291
- Per candidate, what the label prints about it, each value with the line it was read from; None when it isn't
292
- printed (never inferred). A candidate's block runs from its first line to the next candidate's (the first one
293
- also gets the header lines above it: the dispensing date). Lines with personal details are never used.
294
- """
295
- starts = sorted({c["first_line"] for c in candidates})
296
- for c in candidates:
297
- i = starts.index(c["first_line"])
298
- lo = 0 if i == 0 else starts[i]
299
- hi = starts[i + 1] if i + 1 < len(starts) else len(lines)
300
- block = [(n, lines[n]) for n in range(lo, hi) if not PERSONAL.search(lines[n])]
301
- directions, quantity, date, expiry = [], None, None, None
302
- for n, line in block:
303
- is_direction = n >= c["first_line"] and (DIRECTION_START.search(line) and DIRECTION_WORDS.search(line)
304
- or (directions and directions[-1][0] == n - 1
305
- and DIRECTION_WORDS.search(line)))
306
- if is_direction:
307
- directions.append((n, line))
308
- continue
309
- q = QUANTITY.search(line)
310
- if q and quantity is None and n >= c["first_line"] and line not in c["found_in"]:
311
- number, unit = (q.group(1), q.group(2)) if q.group(1) else (q.group(3), q.group(4))
312
- quantity = _field(f"{number} {unit.lower()}".strip(), line)
313
- d = DATE.search(line)
314
- if d:
315
- if re.search(r"\bexp", line, re.I):
316
- expiry = expiry or _field(d.group(1), line)
317
- else:
318
- date = date or _field(d.group(1), line)
319
- form = dose_form([line for n, line in block if n >= c["first_line"]])
320
- c["fields"] = {
321
- "directions": _field(" ".join(l for _, l in directions), " / ".join(l for _, l in directions)),
322
- "quantity": quantity,
323
- "dose_form": _field(form, next((l for n, l in block if n >= c["first_line"] and form and
324
- re.search(form[:5], l, re.I)), None)),
325
- "date": date,
326
- "expiry_date": expiry,
327
- }
328
- return candidates
329
-
330
-
331
- # ---- is this box one of my medicines? (blueprint job B9) -------------------------------------------------------------
332
- def _amounts(strength):
333
- """'500 mg, 65 mg' or '500 MG / 65 MG' -> {(500.0, 'mg'), (65.0, 'mg')}."""
334
- return {(float(m.group(1).replace(",", ".")), m.group(2).lower().replace("µg", "mcg").replace("ug", "mcg"))
335
- for m in STRENGTH.finditer(strength or "")}
336
-
337
-
338
- def compare_with_list(candidate, medications):
339
- """
340
- medications: the patient's list, each {"id", "name", "ingredients": {rxcui}, "strength": printed or None}.
341
- -> {"status", "medication_id", "message"}: a deterministic comparison of ingredients and strength, no model score.
342
- same_medicine same ingredients and strength (or the list gives no strength)
343
- different_strength same ingredients, another strength
344
- contains_listed a combination that includes a listed medicine's ingredient
345
- not_on_list no ingredient in common
346
- """
347
- have = {i["rxcui"] for i in candidate["ingredients"] if i.get("rxcui")}
348
- best = None
349
- for med in medications:
350
- common = have & med["ingredients"]
351
- if not common:
352
- continue
353
- if common == have == med["ingredients"]:
354
- a, b = _amounts(candidate.get("strength")), _amounts(med.get("strength"))
355
- if a and b and a != b:
356
- status = "different_strength"
357
- message = (f"This says {candidate.get('strength')}; your list has {med['name']}. Check with your "
358
- "pharmacist.")
359
- else:
360
- status = "same_medicine"
361
- message = f"This matches your {med['name']}: the same medicine" + \
362
- (" and strength." if a and b else ". Check the strength printed on it.")
363
- return {"status": status, "medication_id": med["id"], "message": message}
364
- best = best or {"status": "contains_listed", "medication_id": med["id"],
365
- "message": f"This medicine shares an ingredient with your {med['name']} but is not the same. "
366
- "Check with your pharmacist."}
367
- return best or {"status": "not_on_list", "medication_id": None,
368
- "message": "This medicine isn't on your list. Check with your pharmacist."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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."}