DavidPanSX commited on
Commit
2dbf919
·
verified ·
1 Parent(s): 020bb3e

deploy API

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