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

deploy API

Browse files
serving/app/main.py CHANGED
@@ -34,21 +34,28 @@ from typing import Literal
34
 
35
  import numpy as np
36
  from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
37
- from fastapi.middleware.cors import CORSMiddleware
38
  from fastapi.concurrency import run_in_threadpool
 
 
39
  from fastapi.responses import FileResponse, JSONResponse
40
  from PIL import Image, ImageOps, UnidentifiedImageError
41
  from pydantic import BaseModel, Field, ValidationError
 
42
 
 
 
43
  from epills.core.imprint.match import GALLERY_MIN_WEIGHT, read_hits
44
  from epills.core.inference.matching import check_imprint, decide
 
45
  from epills.core.inference.preprocess import WINDOW, crop_window
46
  from epills.core.safety import openfda
47
  from epills.core.safety.db import SafetyDB
48
- from epills.core.safety.rules import check_dose, check_medications, resolve_medication
49
  from epills.core.safety.rxnorm import RxNormClient, RxNormError
 
50
 
51
- API_VERSION = "0.5.0"
 
52
  LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
53
  "embedder": Path("outputs/serving/stage2_model.pt"),
54
  "catalogue": Path("data/processed/catalogue")}
@@ -56,11 +63,18 @@ IDENTIFY_NOTE = ("Possible matches from a catalogue of US pills, ranked by how a
56
  "what a pill is. Check with your pharmacist.")
57
  LABEL_NOTE = ("Medicines read from the photo. Check the name and strength against the box or label before adding "
58
  "it to your list.")
59
- MATCH_MESSAGES = {
60
- "match": "This is your {name}.",
61
- "unknown": "This pill isn't one of your medicines. Set it aside. Check with your pharmacist.",
 
 
 
 
62
  "ambiguous": "This pill looks like more than one of your medicines. Turn it over and photograph it again.",
63
  }
 
 
 
64
 
65
 
66
  def _asset(env, repo_path, local):
@@ -163,7 +177,9 @@ app = FastAPI(
163
  description="Pill verification against a patient's enrolled medications, pill identification and pictures from "
164
  "a catalogue of US pills, and medication safety checks. A medication verification aid, not a medical "
165
  "device: every alert defers to a pharmacist. Interaction data: DDInter 2.0 (CC BY-NC-SA 4.0); "
166
- "ingredients: RxNorm (NLM); labels: openFDA; pill pictures: NLM C3PI (research and development use).",
 
 
167
  )
168
  app.state.rxnorm = RxNormClient()
169
  app.state.safety_db = app.state.embedder = app.state.catalogue = app.state.feedback = None
@@ -178,10 +194,30 @@ async def api_key(request: Request, call_next):
178
  key = os.environ.get("EPILLS_API_KEY")
179
  if key and request.url.path.startswith("/v1") and request.method != "OPTIONS" \
180
  and request.headers.get("X-API-Key") != key:
181
- return JSONResponse({"detail": "missing or wrong X-API-Key"}, status_code=401)
182
  return await call_next(request)
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  # ---- request models ---------------------------------------------------------------------------------------------
186
  class Medication(BaseModel):
187
  id: str = Field(description="The app's id for this medication")
@@ -223,7 +259,9 @@ class GalleryMedication(BaseModel):
223
  class MatchRequest(BaseModel):
224
  queries: list[list[float]] = Field(min_length=1, description="Embeddings of the pills to check (/v1/embed)")
225
  gallery: list[GalleryMedication] = Field(min_length=1, description="The patient's enrolled medications")
226
- model_version: str | None = Field(None, description="Version the gallery was embedded with; checked if given")
 
 
227
  query_reads: list[list[str]] | None = Field(None, description="Per query, its `imprint_reads` from /v1/embed "
228
  "(read_imprint=true): turns on the imprint check")
229
 
@@ -287,6 +325,29 @@ def token_weights():
287
  return None
288
 
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  def match_decisions(queries, gallery, embedder, reads=None):
291
  """Per query: the decision, and with reads (per query, its OCR strings) the imprint check on it."""
292
  dim = queries.shape[1]
@@ -335,16 +396,22 @@ def product_summary(cat, pill_id, **extra):
335
 
336
  def identify_pill(embeddings, imprint=None, shape=None, colour=None, top_k=5, reads=None):
337
  cat = catalogue_for_identify()
338
- candidates, notes = cat.identify(embeddings, top_k=top_k, imprint=imprint, shape=shape, colour=colour,
339
  reads=reads)
340
- return {"candidates": [product_summary(cat, c.pop("pill_id"), **c) for c in candidates], "notes": notes,
341
- "note": IDENTIFY_NOTE}
 
 
 
342
 
343
 
344
  # ---- endpoints: health ----------------------------------------------------------------------------------------------
345
- @app.get("/health")
346
  def health():
347
- """Liveness check, and which assets are loaded (the model and catalogue load on first use)."""
 
 
 
348
  try:
349
  db = asset("safety_db")
350
  safety = {"loaded": True, "built_at": db.meta.get("built_at"), "pairs": db.meta.get("pairs")}
@@ -355,9 +422,12 @@ def health():
355
  device = getattr(getattr(emb, "forward", None), "device", None) or getattr(emb, "device", None)
356
  return {
357
  "status": "ok",
 
358
  "api_version": API_VERSION,
 
359
  "model_repo": os.environ.get("EPILLS_MODEL_REPO", "not set"),
360
  "model": {"loaded": emb is not None, "version": getattr(emb, "version", None),
 
361
  "device": str(device) if device else None, "error": _errors.get("embedder")},
362
  "catalogue": {"loaded": cat is not None, "products": len(cat) if cat else None,
363
  "model_version": getattr(cat, "model_version", None), "error": _errors.get("catalogue")},
@@ -367,8 +437,37 @@ def health():
367
  }
368
 
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  # ---- endpoints: Stage 2 -------------------------------------------------------------------------------------------
371
- @app.post("/v1/embed")
372
  async def embed(image: UploadFile = File(description="A pill crop, or a photo with `box`"),
373
  box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo; the "
374
  "server then crops it with the crop contract"),
@@ -386,11 +485,13 @@ async def embed(image: UploadFile = File(description="A pill crop, or a photo wi
386
  vector = (await run_in_threadpool(emb, crops))[0]
387
  reads = await run_in_threadpool(imprint_reads, crops) if read_imprint else None
388
  return {"embedding": [round(float(v), 6) for v in vector], "dim": int(vector.shape[0]),
389
- "model_version": emb.version, "framed_by": "box" if b else "as sent",
390
- "imprint_reads": reads[0] if reads else None}
 
 
391
 
392
 
393
- @app.post("/v1/match")
394
  def match(request: MatchRequest):
395
  """
396
  **Which of my medicines is this?** Pill embeddings + the patient's gallery -> per pill: `match` (with the
@@ -398,18 +499,17 @@ def match(request: MatchRequest):
398
  The threshold and margin are the served model's calibration.
399
  """
400
  emb = asset("embedder")
401
- if request.model_version and request.model_version != emb.version:
402
- raise HTTPException(409, f"gallery embedded with model {request.model_version}, but {emb.version} is "
403
- f"served: re-embed the enrolment photos with /v1/embed")
404
  queries = np.asarray(request.queries, np.float32)
405
  if request.query_reads is not None and len(request.query_reads) != len(queries):
406
  raise HTTPException(422, "query_reads needs one list of reads per query")
407
  return {"results": match_decisions(queries, request.gallery, emb, request.query_reads),
408
- "model_version": emb.version,
409
- "threshold": emb.calibration["threshold"], "margin": emb.calibration["margin"]}
 
410
 
411
 
412
- @app.post("/v1/identify")
413
  async def identify(images: list[UploadFile] = File(description="One pill: 1 photo, or 2 (front and back)"),
414
  boxes: str | None = Form(None, description="Optional [[x0, y0, x1, y1], ...], one per image"),
415
  imprint: str | None = Form(None, description="Letters or numbers on the pill, e.g. 'M 547'"),
@@ -434,10 +534,24 @@ async def identify(images: list[UploadFile] = File(description="One pill: 1 phot
434
  per_crop = await run_in_threadpool(imprint_reads, crops)
435
  reads = [r for crop_reads in per_crop for r in crop_reads] if per_crop else None
436
  return {**identify_pill(vectors, imprint, shape, colour, top_k, reads), "imprint_reads": reads,
437
- "model_version": emb.version}
438
 
439
 
440
- @app.post("/v1/scan")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  async def scan(image: UploadFile = File(description="A full photo of the pills"),
442
  boxes: str | None = Form(None, description="Pill boxes from a detector, [[x0, y0, x1, y1], ...]; "
443
  "without them a stand-in finder looks for pills on a "
@@ -445,28 +559,34 @@ async def scan(image: UploadFile = File(description="A full photo of the pills")
445
  gallery: str | None = Form(None, description="JSON list of {medication_id, name, embeddings}: the "
446
  "patient's gallery. Without it, each pill is "
447
  "identified against the catalogue"),
448
- schedule: str | None = Form(None, description="JSON list of {medication_id, count} for this dose: "
449
- "adds the dose check"),
450
- model_version: str | None = Form(None, description="Version the gallery was embedded with"),
 
 
 
 
451
  top_k: int = Form(3, ge=1, le=10),
452
  read_imprint: bool = Form(True, description="Read each pill's imprint: checks the gallery decisions "
453
  "(medications with an `imprint`), or re-ranks catalogue "
454
  "candidates")):
455
  """
456
- **One call for a dose check:** full photo -> every pill's box and decision (with the patient's gallery), or its
457
- catalogue candidates (without one); with a schedule, also the dose-check alerts of `/v1/safety/dose`.
 
458
  """
459
  emb = asset("embedder")
460
  photo = await read_image(image)
461
  box_list = parse_boxes(boxes)
462
- found_by = "boxes sent"
463
  if box_list is None:
464
- from epills.core.inference.pill_finder import find_pills
465
- box_list, found_by = find_pills(photo), "stand-in finder (plain backgrounds only)"
466
- result = {"found_by": found_by, "model_version": emb.version, "pills": []}
 
 
 
467
  if not box_list:
468
- result["message"] = ("No pill found. Spread the pills apart on a plain background and photograph them "
469
- "again.")
470
  return result
471
  crops = emb.frame(photo, box_list)
472
  vectors = await run_in_threadpool(emb, crops) # in a thread: a GPU queue wait mustn't block the API
@@ -476,33 +596,49 @@ async def scan(image: UploadFile = File(description="A full photo of the pills")
476
  meds = [GalleryMedication(**g) for g in parse_json(gallery, "gallery")]
477
  except (TypeError, ValidationError) as error:
478
  raise HTTPException(422, f"gallery: {error}")
479
- if model_version and model_version != emb.version:
480
- raise HTTPException(409, f"gallery embedded with model {model_version}, but {emb.version} is served")
481
  reads = None
482
  if read_imprint and (meds is None or any(m.imprint for m in meds)):
483
  reads = await run_in_threadpool(imprint_reads, crops)
484
 
485
  if meds is not None:
486
  decisions = match_decisions(vectors, meds, emb, reads)
487
- result["pills"] = [{"box": list(b), **d, "imprint_reads": reads[i] if reads else None}
488
- for i, (b, d) in enumerate(zip(box_list, decisions))]
 
489
  if schedule:
490
  try:
491
  sched = [ScheduledMedication(**s).model_dump() for s in parse_json(schedule, "schedule")]
492
  except (TypeError, ValidationError) as error:
493
  raise HTTPException(422, f"schedule: {error}")
494
- alerts, summary = check_dose(sched, [{"medication_id": d["medication_id"], "decision": d["decision"]}
495
- for d in decisions],
496
- {m.medication_id: m.name or m.medication_id for m in meds})
497
- result["dose"] = {"alerts": [a.to_dict() for a in alerts], "summary": summary}
498
  else:
499
- result["pills"] = [{"box": list(b), **identify_pill(v[None], top_k=top_k, reads=reads[i] if reads else None),
 
500
  "imprint_reads": reads[i] if reads else None}
501
- for i, (b, v) in enumerate(zip(box_list, vectors))]
502
  return result
503
 
504
 
505
- @app.post("/v1/read-label")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  async def read_label(image: UploadFile = File(description="A photo of a medicine box or a pharmacy label, close "
507
  "up and straight on")):
508
  """
@@ -525,7 +661,7 @@ async def read_label(image: UploadFile = File(description="A photo of a medicine
525
  return result
526
 
527
 
528
- @app.get("/v1/pills/search")
529
  def pills_search(name: str = Query(min_length=2, description="Brand or generic name, e.g. 'metformin', 'Glucophage'"),
530
  strength: str | None = Query(None, description="e.g. '500 MG'"),
531
  limit: int = Query(50, ge=1, le=200)):
@@ -541,7 +677,7 @@ def pills_search(name: str = Query(min_length=2, description="Brand or generic n
541
  "source": cat.meta.get("source")}
542
 
543
 
544
- @app.get("/v1/pills/{pill_id}")
545
  def pill(pill_id: str):
546
  """One catalogue product: names, ingredients, appearance, imprint and its pictures."""
547
  cat = asset("catalogue")
@@ -560,7 +696,7 @@ def pill_image(pill_id: str, n: int):
560
 
561
 
562
  # ---- endpoints: feedback for retraining (opt-in) -----------------------------------------------------------------
563
- @app.post("/v1/feedback")
564
  async def feedback(image: UploadFile = File(description="The pill: a crop, or a photo with `box`"),
565
  box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo"),
566
  consent: bool = Form(description="The user agreed to share this photo to improve the model"),
@@ -595,7 +731,7 @@ async def feedback(image: UploadFile = File(description="The pill: a crop, or a
595
  return {"stored": True, "item": item, "note": "Thank you. The photo is used only to improve pill recognition."}
596
 
597
 
598
- @app.delete("/v1/feedback/{consent_id}")
599
  def feedback_delete(consent_id: str):
600
  """Withdrawal of consent: deletes every photo stored under this consent id."""
601
  try:
@@ -605,7 +741,7 @@ def feedback_delete(consent_id: str):
605
 
606
 
607
  # ---- endpoints: Stage 3 -------------------------------------------------------------------------------------------
608
- @app.post("/v1/safety/medications")
609
  def safety_medications(request: MedicationsRequest):
610
  """
611
  Run when a medication is added or changed: interactions between every pair of the patient's medications
@@ -624,18 +760,19 @@ def safety_medications(request: MedicationsRequest):
624
  }
625
 
626
 
627
- @app.post("/v1/safety/dose")
628
  def safety_dose(request: DoseRequest):
629
  """
630
- Run at every dose check, after /v1/match: double doses, pills not part of this dose, missed medications, and
631
- unknown or unsure pills. (/v1/scan with a schedule does this in the same call.)
 
632
  """
633
  alerts, summary = check_dose([s.model_dump() for s in request.schedule],
634
  [p.model_dump() for p in request.pills], request.names)
635
  return {"alerts": [a.to_dict() for a in alerts], "summary": summary}
636
 
637
 
638
- @app.get("/v1/safety/label")
639
  def safety_label(rxcui: str | None = Query(None), ingredient: str | None = Query(None)):
640
  """The warning sections of the official US label (openFDA), quoted as written; only when the user asks."""
641
  if not rxcui and not ingredient:
 
34
 
35
  import numpy as np
36
  from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
 
37
  from fastapi.concurrency import run_in_threadpool
38
+ from fastapi.exceptions import RequestValidationError
39
+ from fastapi.middleware.cors import CORSMiddleware
40
  from fastapi.responses import FileResponse, JSONResponse
41
  from PIL import Image, ImageOps, UnidentifiedImageError
42
  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
53
+ from epills.core.safety.rules import SOURCE_SCHEDULE, Alert, check_dose, check_medications, resolve_medication
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"),
61
  "catalogue": Path("data/processed/catalogue")}
 
63
  "what a pill is. Check with your pharmacist.")
64
  LABEL_NOTE = ("Medicines read from the photo. Check the name and strength against the box or label before adding "
65
  "it to your list.")
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}.",
72
+ "unknown": "This pill doesn't look like any of your medicines. Set it aside. Check with your pharmacist.",
73
  "ambiguous": "This pill looks like more than one of your medicines. Turn it over and photograph it again.",
74
  }
75
+ ERROR_CODES = {401: "unauthorised", 404: "not_found", 409: "version_mismatch", 422: "invalid_input",
76
+ 502: "upstream_failed", 503: "not_ready"}
77
+ RETRY_AFTER = 20 # seconds, with 503 not_ready (a part still loading, or the Space waking up)
78
 
79
 
80
  def _asset(env, repo_path, local):
 
177
  description="Pill verification against a patient's enrolled medications, pill identification and pictures from "
178
  "a catalogue of US pills, and medication safety checks. A medication verification aid, not a medical "
179
  "device: every alert defers to a pharmacist. Interaction data: DDInter 2.0 (CC BY-NC-SA 4.0); "
180
+ "ingredients: RxNorm (NLM); labels: openFDA; pill pictures: NLM C3PI (research and development use). "
181
+ "Stage 2 answers are visual evidence only: they never confirm a medicine or that it is safe to take.",
182
+ responses={code: {"model": S.Error, "description": name} for code, name in ERROR_CODES.items()},
183
  )
184
  app.state.rxnorm = RxNormClient()
185
  app.state.safety_db = app.state.embedder = app.state.catalogue = app.state.feedback = None
 
194
  key = os.environ.get("EPILLS_API_KEY")
195
  if key and request.url.path.startswith("/v1") and request.method != "OPTIONS" \
196
  and request.headers.get("X-API-Key") != key:
197
+ return JSONResponse({"code": "unauthorised", "detail": "missing or wrong X-API-Key"}, status_code=401)
198
  return await call_next(request)
199
 
200
 
201
+ @app.exception_handler(StarletteHTTPException)
202
+ async def http_error(_request, error):
203
+ """One error shape everywhere (blueprint B5): code, detail, and retry_after with Retry-After on 503."""
204
+ body, headers = {"code": ERROR_CODES.get(error.status_code, "error"), "detail": error.detail}, \
205
+ dict(getattr(error, "headers", None) or {})
206
+ if error.status_code == 503:
207
+ body["retry_after"], headers["Retry-After"] = RETRY_AFTER, str(RETRY_AFTER)
208
+ return JSONResponse(body, status_code=error.status_code, headers=headers)
209
+
210
+
211
+ @app.exception_handler(RequestValidationError)
212
+ async def invalid_input(_request, error):
213
+ return JSONResponse({"code": "invalid_input", "detail": jsonable(error.errors())}, status_code=422)
214
+
215
+
216
+ def jsonable(errors):
217
+ return [{k: (v if isinstance(v, (str, int, float, list, type(None))) else str(v)) for k, v in e.items()}
218
+ for e in errors]
219
+
220
+
221
  # ---- request models ---------------------------------------------------------------------------------------------
222
  class Medication(BaseModel):
223
  id: str = Field(description="The app's id for this medication")
 
259
  class MatchRequest(BaseModel):
260
  queries: list[list[float]] = Field(min_length=1, description="Embeddings of the pills to check (/v1/embed)")
261
  gallery: list[GalleryMedication] = Field(min_length=1, description="The patient's enrolled medications")
262
+ model_version: str | None = Field(None, description="The model_version stored with the gallery's embeddings. "
263
+ "This or embedding_space_id is required")
264
+ embedding_space_id: str | None = Field(None, description="The embedding_space_id stored with the gallery")
265
  query_reads: list[list[str]] | None = Field(None, description="Per query, its `imprint_reads` from /v1/embed "
266
  "(read_imprint=true): turns on the imprint check")
267
 
 
325
  return None
326
 
327
 
328
+ def space(emb):
329
+ return getattr(emb, "embedding_space_id", emb.version)
330
+
331
+
332
+ def check_gallery_version(emb, model_version, embedding_space_id):
333
+ """Embeddings compare only within one space (blueprint B3): the gallery must say which space it was made in."""
334
+ if not model_version and not embedding_space_id:
335
+ raise HTTPException(422, "send the model_version (or embedding_space_id) stored with the gallery's embeddings")
336
+ if (model_version and model_version != emb.version) or (embedding_space_id and embedding_space_id != space(emb)):
337
+ raise HTTPException(409, f"gallery embedded with {embedding_space_id or model_version}, but {space(emb)} is "
338
+ f"served: re-embed the enrolment photos with /v1/embed")
339
+
340
+
341
+ def detection_out(det=None, boxes=None):
342
+ """The detection block: from the stand-in finder (det), or the boxes the app sent (trusted as given)."""
343
+ if det is None:
344
+ return {"boxes": [list(b) for b in boxes], "box_flags": [[] for _ in boxes], "pill_count": len(boxes),
345
+ "count_reliable": bool(boxes), "flags": [] if boxes else ["no_pills"], "found_by": "boxes sent"}
346
+ return {"boxes": [list(b) for b in det.boxes], "box_flags": det.box_flags, "pill_count": det.pill_count,
347
+ "count_reliable": bool(det.boxes) and "count_uncertain" not in det.flags, "flags": det.flags,
348
+ "found_by": STAND_IN}
349
+
350
+
351
  def match_decisions(queries, gallery, embedder, reads=None):
352
  """Per query: the decision, and with reads (per query, its OCR strings) the imprint check on it."""
353
  dim = queries.shape[1]
 
396
 
397
  def identify_pill(embeddings, imprint=None, shape=None, colour=None, top_k=5, reads=None):
398
  cat = catalogue_for_identify()
399
+ candidates, notes = cat.identify(embeddings, top_k=max(top_k, 2), imprint=imprint, shape=shape, colour=colour,
400
  reads=reads)
401
+ # calibrated on unfiltered searches only: with a typed imprint, shape or colour the confidence is left out
402
+ assessment = None if (imprint or shape or colour) else assess(candidates, cat.model_version)
403
+ return {"candidates": [product_summary(cat, c.pop("pill_id"), **c) for c in candidates[:top_k]], "notes": notes,
404
+ "note": IDENTIFY_NOTE, **(assessment or {"confidence": None, "catalogue_fit": None, "verdict": None,
405
+ "verdict_message": None})}
406
 
407
 
408
  # ---- endpoints: health ----------------------------------------------------------------------------------------------
409
+ @app.get("/health", response_model=S.Health)
410
  def health():
411
+ """
412
+ Liveness check, and which parts are loaded. `ready` is true once the model, catalogue and safety database are
413
+ all loaded. A sleeping Space answers with an HTML page (not JSON) while it wakes up: retry until this is JSON.
414
+ """
415
  try:
416
  db = asset("safety_db")
417
  safety = {"loaded": True, "built_at": db.meta.get("built_at"), "pairs": db.meta.get("pairs")}
 
422
  device = getattr(getattr(emb, "forward", None), "device", None) or getattr(emb, "device", None)
423
  return {
424
  "status": "ok",
425
+ "ready": emb is not None and cat is not None and safety["loaded"],
426
  "api_version": API_VERSION,
427
+ "started_at": STARTED_AT,
428
  "model_repo": os.environ.get("EPILLS_MODEL_REPO", "not set"),
429
  "model": {"loaded": emb is not None, "version": getattr(emb, "version", None),
430
+ "embedding_space_id": space(emb) if emb is not None else None,
431
  "device": str(device) if device else None, "error": _errors.get("embedder")},
432
  "catalogue": {"loaded": cat is not None, "products": len(cat) if cat else None,
433
  "model_version": getattr(cat, "model_version", None), "error": _errors.get("catalogue")},
 
437
  }
438
 
439
 
440
+ @app.get("/v1/model-info", response_model=S.ModelInfo)
441
+ def model_info():
442
+ """
443
+ The served model (blueprint B3): service version vs model version, the embedding space and preprocessing that
444
+ stored embeddings must match, the calibrated decision rule, and what the model was trained on and where it is weak.
445
+ """
446
+ emb = asset("embedder")
447
+ cal = emb.calibration
448
+ return {
449
+ "api_version": API_VERSION, "model_version": emb.version, "embedding_space_id": space(emb),
450
+ "preprocess_version": getattr(emb, "preprocess_version", None) or "unknown", "embedding_dim": 256,
451
+ "normalized": True, "crop_window": float(getattr(emb, "window", WINDOW)),
452
+ "threshold": cal["threshold"], "margin": cal["margin"],
453
+ "calibration": {"rule": cal.get("rule"), "calibrated_on": cal.get("calibrated_on")},
454
+ "imprint": {"gallery_min_weight": GALLERY_MIN_WEIGHT, "identify_alpha": IMPRINT_ALPHA,
455
+ "identify_top_k": IMPRINT_TOP_K},
456
+ "training_coverage": "Loose-pill photos of US prescription pills (NLM C3PI): 2,362 medicines; catalogue of "
457
+ "4,936 US pill products. Not trained on Singapore products, packaging or blisters.",
458
+ "known_limits": [
459
+ "Other phones and lighting: 48% top-1 over 32 Hong Kong products (MEDISEG), against 83% on held-out C3PI.",
460
+ "Similarities are not probabilities: pills that aren't in the catalogue still score about 0.93 median.",
461
+ "Imprints are readable on about 38% of photos.",
462
+ "The stand-in pill finder works on plain backgrounds only; check count_reliable before counting pills.",
463
+ "Visual evidence only: never confirms the medicine, its strength, or that it is safe to take.",
464
+ ],
465
+ "evidence": "visual",
466
+ }
467
+
468
+
469
  # ---- endpoints: Stage 2 -------------------------------------------------------------------------------------------
470
+ @app.post("/v1/embed", response_model=S.Embedding)
471
  async def embed(image: UploadFile = File(description="A pill crop, or a photo with `box`"),
472
  box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo; the "
473
  "server then crops it with the crop contract"),
 
485
  vector = (await run_in_threadpool(emb, crops))[0]
486
  reads = await run_in_threadpool(imprint_reads, crops) if read_imprint else None
487
  return {"embedding": [round(float(v), 6) for v in vector], "dim": int(vector.shape[0]),
488
+ "model_version": emb.version, "embedding_space_id": space(emb),
489
+ "preprocess_version": getattr(emb, "preprocess_version", None) or "unknown", "normalized": True,
490
+ "framed_by": "box" if b else "as sent", "imprint_reads": reads[0] if reads else None,
491
+ "evidence": "visual"}
492
 
493
 
494
+ @app.post("/v1/match", response_model=S.MatchResponse)
495
  def match(request: MatchRequest):
496
  """
497
  **Which of my medicines is this?** Pill embeddings + the patient's gallery -> per pill: `match` (with the
 
499
  The threshold and margin are the served model's calibration.
500
  """
501
  emb = asset("embedder")
502
+ check_gallery_version(emb, request.model_version, request.embedding_space_id)
 
 
503
  queries = np.asarray(request.queries, np.float32)
504
  if request.query_reads is not None and len(request.query_reads) != len(queries):
505
  raise HTTPException(422, "query_reads needs one list of reads per query")
506
  return {"results": match_decisions(queries, request.gallery, emb, request.query_reads),
507
+ "model_version": emb.version, "embedding_space_id": space(emb),
508
+ "threshold": emb.calibration["threshold"], "margin": emb.calibration["margin"],
509
+ "evidence": "visual", "limits": VISUAL_LIMITS}
510
 
511
 
512
+ @app.post("/v1/identify", response_model=S.IdentifyResponse)
513
  async def identify(images: list[UploadFile] = File(description="One pill: 1 photo, or 2 (front and back)"),
514
  boxes: str | None = Form(None, description="Optional [[x0, y0, x1, y1], ...], one per image"),
515
  imprint: str | None = Form(None, description="Letters or numbers on the pill, e.g. 'M 547'"),
 
534
  per_crop = await run_in_threadpool(imprint_reads, crops)
535
  reads = [r for crop_reads in per_crop for r in crop_reads] if per_crop else None
536
  return {**identify_pill(vectors, imprint, shape, colour, top_k, reads), "imprint_reads": reads,
537
+ "model_version": emb.version, "evidence": "visual", "limits": VISUAL_LIMITS}
538
 
539
 
540
+ @app.post("/v1/detect", response_model=S.DetectResponse)
541
+ async def detect(image: UploadFile = File(description="A full photo of the pills")):
542
+ """
543
+ **Where are the pills?** A photo -> a box per pill with quality flags (blueprint B6). Uses the stand-in finder for
544
+ plain backgrounds until the Stage 1 detector replaces it (same box format). Count pills only when
545
+ `count_reliable` is true; otherwise ask for a new photo (pills apart, plain surface).
546
+ """
547
+ photo = await read_image(image)
548
+ out = detection_out(await run_in_threadpool(detect_pills, photo))
549
+ if not out["boxes"]:
550
+ out["message"] = NO_PILL
551
+ return out
552
+
553
+
554
+ @app.post("/v1/scan", response_model=S.ScanResponse)
555
  async def scan(image: UploadFile = File(description="A full photo of the pills"),
556
  boxes: str | None = Form(None, description="Pill boxes from a detector, [[x0, y0, x1, y1], ...]; "
557
  "without them a stand-in finder looks for pills on a "
 
559
  gallery: str | None = Form(None, description="JSON list of {medication_id, name, embeddings}: the "
560
  "patient's gallery. Without it, each pill is "
561
  "identified against the catalogue"),
562
+ schedule: str | None = Form(None, description="Deprecated (the main backend owns dose rules): JSON "
563
+ "list of {medication_id, count}; adds an advisory dose "
564
+ "check"),
565
+ model_version: str | None = Form(None, description="The model_version stored with the gallery "
566
+ "(this or embedding_space_id is required with one)"),
567
+ embedding_space_id: str | None = Form(None, description="The embedding_space_id stored with the "
568
+ "gallery"),
569
  top_k: int = Form(3, ge=1, le=10),
570
  read_imprint: bool = Form(True, description="Read each pill's imprint: checks the gallery decisions "
571
  "(medications with an `imprint`), or re-ranks catalogue "
572
  "candidates")):
573
  """
574
+ **Candidates for every pill in a photo** (diagnostics; blueprint D3): full photo -> each pill's box with quality
575
+ flags and its visual decision (with the patient's gallery) or catalogue candidates (without one). Not a dose
576
+ verification: the main backend decides with the regimen. Boxes flagged by the finder are not counted.
577
  """
578
  emb = asset("embedder")
579
  photo = await read_image(image)
580
  box_list = parse_boxes(boxes)
 
581
  if box_list is None:
582
+ detection = detection_out(await run_in_threadpool(detect_pills, photo))
583
+ else:
584
+ detection = detection_out(boxes=box_list)
585
+ box_list = [tuple(b) for b in detection["boxes"]]
586
+ result = {"found_by": detection["found_by"], "model_version": emb.version, "detection": detection, "pills": [],
587
+ "evidence": "visual", "limits": VISUAL_LIMITS}
588
  if not box_list:
589
+ result["message"] = NO_PILL
 
590
  return result
591
  crops = emb.frame(photo, box_list)
592
  vectors = await run_in_threadpool(emb, crops) # in a thread: a GPU queue wait mustn't block the API
 
596
  meds = [GalleryMedication(**g) for g in parse_json(gallery, "gallery")]
597
  except (TypeError, ValidationError) as error:
598
  raise HTTPException(422, f"gallery: {error}")
599
+ check_gallery_version(emb, model_version, embedding_space_id)
 
600
  reads = None
601
  if read_imprint and (meds is None or any(m.imprint for m in meds)):
602
  reads = await run_in_threadpool(imprint_reads, crops)
603
 
604
  if meds is not None:
605
  decisions = match_decisions(vectors, meds, emb, reads)
606
+ result["pills"] = [{"box": list(b), "quality_flags": fl, "counted": not fl, **d,
607
+ "imprint_reads": reads[i] if reads else None}
608
+ for i, (b, fl, d) in enumerate(zip(box_list, detection["box_flags"], decisions))]
609
  if schedule:
610
  try:
611
  sched = [ScheduledMedication(**s).model_dump() for s in parse_json(schedule, "schedule")]
612
  except (TypeError, ValidationError) as error:
613
  raise HTTPException(422, f"schedule: {error}")
614
+ result["dose"] = advisory_dose(sched, result["pills"], detection, meds)
 
 
 
615
  else:
616
+ result["pills"] = [{"box": list(b), "quality_flags": fl, "counted": not fl,
617
+ **identify_pill(v[None], top_k=top_k, reads=reads[i] if reads else None),
618
  "imprint_reads": reads[i] if reads else None}
619
+ for i, (b, fl, v) in enumerate(zip(box_list, detection["box_flags"], vectors))]
620
  return result
621
 
622
 
623
+ def advisory_dose(sched, pills, detection, meds):
624
+ """/v1/scan's deprecated dose check: only when the pill count is reliable, and only counted pills."""
625
+ note = ("Advisory and deprecated: the main backend checks doses against the regimen and dose history "
626
+ "(docs/dose_rules.md).")
627
+ if not detection["count_reliable"]:
628
+ alert = Alert("count_uncertain", "moderate", "I can't count the pills in this photo reliably. Spread them "
629
+ "apart on a plain surface and photograph them again.", [], SOURCE_SCHEDULE,
630
+ details={"flags": detection["flags"]})
631
+ summary = {"pills_seen": len(pills), "matched": 0, "unknown": 0, "unsure": 0, "all_ok": False,
632
+ "all_visually_matched": False, "count_reliable": False,
633
+ "text": "I can't count the pills in this photo reliably."}
634
+ return {"alerts": [alert.to_dict()], "summary": summary, "deprecated": True, "note": note}
635
+ alerts, summary = check_dose(sched, [{"medication_id": p["medication_id"], "decision": p["decision"]}
636
+ for p in pills if p["counted"]],
637
+ {m.medication_id: m.name or m.medication_id for m in meds})
638
+ return {"alerts": [a.to_dict() for a in alerts], "summary": summary, "deprecated": True, "note": note}
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
  """
 
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'"),
667
  limit: int = Query(50, ge=1, le=200)):
 
677
  "source": cat.meta.get("source")}
678
 
679
 
680
+ @app.get("/v1/pills/{pill_id}", response_model=S.Product)
681
  def pill(pill_id: str):
682
  """One catalogue product: names, ingredients, appearance, imprint and its pictures."""
683
  cat = asset("catalogue")
 
696
 
697
 
698
  # ---- endpoints: feedback for retraining (opt-in) -----------------------------------------------------------------
699
+ @app.post("/v1/feedback", response_model=S.FeedbackStored)
700
  async def feedback(image: UploadFile = File(description="The pill: a crop, or a photo with `box`"),
701
  box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo"),
702
  consent: bool = Form(description="The user agreed to share this photo to improve the model"),
 
731
  return {"stored": True, "item": item, "note": "Thank you. The photo is used only to improve pill recognition."}
732
 
733
 
734
+ @app.delete("/v1/feedback/{consent_id}", response_model=S.FeedbackDeleted)
735
  def feedback_delete(consent_id: str):
736
  """Withdrawal of consent: deletes every photo stored under this consent id."""
737
  try:
 
741
 
742
 
743
  # ---- endpoints: Stage 3 -------------------------------------------------------------------------------------------
744
+ @app.post("/v1/safety/medications", response_model=S.MedicationsResponse)
745
  def safety_medications(request: MedicationsRequest):
746
  """
747
  Run when a medication is added or changed: interactions between every pair of the patient's medications
 
760
  }
761
 
762
 
763
+ @app.post("/v1/safety/dose", response_model=S.DoseResponse)
764
  def safety_dose(request: DoseRequest):
765
  """
766
+ The dose rules' **reference implementation** (blueprint D3: the main backend owns the decision, with the regimen
767
+ and dose history; spec and test cases in docs/dose_rules.md). After /v1/match: extra pills, pills not part of
768
+ this dose, scheduled medicines not seen, and unknown or unsure pills. Visual evidence only.
769
  """
770
  alerts, summary = check_dose([s.model_dump() for s in request.schedule],
771
  [p.model_dump() for p in request.pills], request.names)
772
  return {"alerts": [a.to_dict() for a in alerts], "summary": summary}
773
 
774
 
775
+ @app.get("/v1/safety/label", response_model=S.LabelResponse)
776
  def safety_label(rxcui: str | None = Query(None), ingredient: str | None = Query(None)):
777
  """The warning sections of the official US label (openFDA), quoted as written; only when the user asks."""
778
  if not rxcui and not ingredient:
serving/app/schemas.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The shape of every PharmaScan API answer (blueprint job B5), so /openapi.json documents responses, not only requests.
3
+ Models allow extra fields: new fields can be added without breaking clients that validate against these.
4
+ """
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ Evidence = Literal["visual"]
10
+
11
+
12
+ class Out(BaseModel):
13
+ model_config = ConfigDict(extra="allow")
14
+
15
+
16
+ class Error(Out):
17
+ code: str = Field(description="unauthorised, not_found, version_mismatch, invalid_input, upstream_failed, "
18
+ "not_ready or error")
19
+ detail: str | list | dict = Field(description="What went wrong (for invalid_input: the fields at fault)")
20
+ retry_after: int | None = Field(None, description="Seconds to wait before retrying (with not_ready)")
21
+
22
+
23
+ # ---- health and model info -------------------------------------------------------------------------------------------
24
+ class Part(Out):
25
+ loaded: bool
26
+ error: str | None = None
27
+
28
+
29
+ class ModelPart(Part):
30
+ version: str | None = None
31
+ embedding_space_id: str | None = None
32
+ device: str | None = Field(None, description="zerogpu, or cpu (slower, when no GPU is granted)")
33
+
34
+
35
+ class CataloguePart(Part):
36
+ products: int | None = None
37
+ model_version: str | None = None
38
+
39
+
40
+ class SafetyPart(Part):
41
+ built_at: str | None = None
42
+ pairs: str | None = None
43
+
44
+
45
+ class TextPart(Part):
46
+ model: str | None = None
47
+
48
+
49
+ class Health(Out):
50
+ status: Literal["ok"]
51
+ ready: bool = Field(description="The model, catalogue and safety database are all loaded")
52
+ api_version: str
53
+ started_at: str
54
+ model_repo: str
55
+ model: ModelPart
56
+ catalogue: CataloguePart
57
+ safety_db: SafetyPart
58
+ text_reading: TextPart
59
+
60
+
61
+ class ModelInfo(Out):
62
+ api_version: str
63
+ model_version: str
64
+ embedding_space_id: str = Field(description="Compare embeddings only within one space")
65
+ preprocess_version: str
66
+ embedding_dim: int
67
+ normalized: bool
68
+ crop_window: float = Field(description="Crop contract: square window = this x the pill box's long side")
69
+ threshold: float
70
+ margin: float
71
+ calibration: dict
72
+ imprint: dict
73
+ training_coverage: str
74
+ known_limits: list[str]
75
+ evidence: Evidence
76
+
77
+
78
+ # ---- Stage 2 ---------------------------------------------------------------------------------------------------------
79
+ class Embedding(Out):
80
+ embedding: list[float]
81
+ dim: int
82
+ model_version: str
83
+ embedding_space_id: str
84
+ preprocess_version: str
85
+ normalized: bool
86
+ framed_by: Literal["box", "as sent"]
87
+ imprint_reads: list[str] | None = None
88
+ evidence: Evidence
89
+
90
+
91
+ class Ref(Out):
92
+ medication_id: str
93
+ name: str
94
+ similarity: float
95
+
96
+
97
+ class MatchResult(Out):
98
+ decision: Literal["match", "unknown", "ambiguous"]
99
+ medication_id: str | None
100
+ name: str | None
101
+ similarity: float
102
+ closest: Ref
103
+ runner_up: Ref | None
104
+ message: str
105
+ imprint_check: str | None = None
106
+ imprint_read_for: list[str] = []
107
+
108
+
109
+ class MatchResponse(Out):
110
+ results: list[MatchResult]
111
+ model_version: str
112
+ embedding_space_id: str
113
+ threshold: float
114
+ margin: float
115
+ evidence: Evidence
116
+ limits: str
117
+
118
+
119
+ class Picture(Out):
120
+ url: str
121
+ kind: str
122
+
123
+
124
+ class Product(Out):
125
+ pill_id: str
126
+ name: str | None
127
+ rxnorm_name: str | None
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):
139
+ similarity: float = Field(description="Cosine similarity of appearance: not a probability")
140
+ imprint_match: bool = Field(description="The typed imprint fits this product")
141
+ imprint_read: float = Field(description="How well the imprint read on the photo fits this product, 0-1")
142
+
143
+
144
+ class Identification(Out):
145
+ candidates: list[Candidate]
146
+ notes: list[str]
147
+ note: str
148
+ confidence: float | None = Field(None, description="Chance the first candidate is the right product, if the pill "
149
+ "is a US catalogue product (calibrated; null with filters)")
150
+ catalogue_fit: Literal["good", "weak", "poor"] | None = None
151
+ verdict: Literal["possible_match", "uncertain", "no_confident_match"] | None = None
152
+ verdict_message: str | None = None
153
+
154
+
155
+ class IdentifyResponse(Identification):
156
+ imprint_reads: list[str] | None = None
157
+ model_version: str
158
+ evidence: Evidence
159
+ limits: str
160
+
161
+
162
+ class Detection(Out):
163
+ boxes: list[list[float]] = Field(description="[x0, y0, x1, y1] per pill, in the photo's pixels")
164
+ box_flags: list[list[str]] = Field(description="Per box: touches_edge, merged, irregular, small")
165
+ pill_count: int
166
+ count_reliable: bool = Field(description="False when any box is flagged or blobs were left out: don't count "
167
+ "pills from this photo")
168
+ flags: list[str] = Field(description="Per photo: no_pills, count_uncertain, too_many")
169
+ found_by: str
170
+
171
+
172
+ class DetectResponse(Detection):
173
+ message: str | None = None
174
+
175
+
176
+ class Alert(Out):
177
+ type: str
178
+ severity: str
179
+ message: str
180
+ medication_ids: list[str]
181
+ source: str
182
+ severity_graded: bool
183
+ colour: str
184
+ speak: bool
185
+ notify_caregiver: bool
186
+ details: dict
187
+
188
+
189
+ class DoseSummary(Out):
190
+ pills_seen: int
191
+ matched: int
192
+ unknown: int
193
+ unsure: int
194
+ all_ok: bool = Field(description="Deprecated name of all_visually_matched")
195
+ all_visually_matched: bool
196
+ count_reliable: bool = True
197
+ text: str
198
+
199
+
200
+ class DoseResponse(Out):
201
+ alerts: list[Alert]
202
+ summary: DoseSummary
203
+
204
+
205
+ class ScanDose(DoseResponse):
206
+ deprecated: bool = True
207
+ note: str
208
+
209
+
210
+ class ScanPill(Out):
211
+ box: list[float]
212
+ quality_flags: list[str] = []
213
+ counted: bool = Field(True, description="False for a flagged box: not counted in the dose check")
214
+ imprint_reads: list[str] | None = None
215
+
216
+
217
+ class ScanResponse(Out):
218
+ found_by: str
219
+ model_version: str
220
+ detection: Detection
221
+ pills: list[ScanPill] = Field(description="With a gallery: /v1/match's fields per pill; without: /v1/identify's")
222
+ dose: ScanDose | None = None
223
+ message: str | None = None
224
+ evidence: Evidence
225
+ limits: str
226
+
227
+
228
+ class Ingredient(Out):
229
+ rxcui: str | None
230
+ name: str
231
+
232
+
233
+ class LabelCandidate(Out):
234
+ name: str
235
+ brand: str | None
236
+ ingredients: list[Ingredient]
237
+ strength: str | None
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
+
250
+ class SearchProduct(Product):
251
+ match: Literal["same", "contains", "name"]
252
+
253
+
254
+ class SearchResponse(Out):
255
+ resolution: dict
256
+ count: int
257
+ products: list[SearchProduct]
258
+ source: str | None
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):
279
+ stored: bool
280
+ item: str
281
+ note: str
282
+
283
+
284
+ class FeedbackDeleted(Out):
285
+ deleted: bool
286
+ local_photos_removed: int
287
+ removed_from_repo: bool
src/epills/core/catalogue/confidence.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Honest confidence for catalogue identification (blueprint job B4). Raw similarities are not probabilities: pills that
3
+ aren't in the catalogue at all still score about 0.93. Fitted by scripts/calibrate_identify.py for one model; for any
4
+ other model version, assess() returns None (refit after every new model).
5
+
6
+ confidence chance the first candidate is the right product *if the pill is a US catalogue product*: logistic
7
+ fit of (top-1 similarity, gap to the runner-up, imprint read on the top-1) on C3PI validation photos
8
+ catalogue_fit poor / weak / good: poor below what 95% of rightly identified catalogue pills reach, good above what
9
+ 90% of non-catalogue pills (MEDISEG) reach
10
+ verdict no_confident_match (poor fit or confidence < LOW), possible_match (good fit and confidence >= HIGH),
11
+ uncertain (the rest)
12
+ Test (run 4, held-out medicines; outputs/runs/stage2_run4/eval/identify_confidence.json): stated 0.93 -> 95% right,
13
+ 0.71 -> 66%, 0.53 -> 48%, 0.31 -> 33%, 0.03 -> 3%. Catalogue pills: possible_match 73% (83% right), uncertain 11%,
14
+ no_confident_match 16%. Non-catalogue pills: no_confident_match 71%, uncertain 21%, possible_match 9%.
15
+ """
16
+ import math
17
+
18
+ MODEL_VERSION = "stage2_run4@2026-10-09"
19
+ WEIGHTS = (-36.3393, 37.514, 31.6165, 1.0213) # intercept, top-1 similarity, gap to the runner-up, imprint read
20
+ T_POOR, T_GOOD = 0.9451, 0.9654
21
+ LOW, HIGH = 0.2, 0.5
22
+ MESSAGES = {
23
+ "possible_match": "The first candidate looks most like this pill. A photo can't confirm it. Check with your "
24
+ "pharmacist.",
25
+ "uncertain": "Several catalogue pills look alike and none stands out. Type the imprint, or check with your "
26
+ "pharmacist.",
27
+ "no_confident_match": "No catalogue pill matches this one well: it may not be a US catalogue product, or the photo "
28
+ "differs from the catalogue photos. Check with your pharmacist.",
29
+ }
30
+
31
+
32
+ def assess(candidates, model_version):
33
+ """candidates: best first, each with similarity and imprint_read (at least two) -> dict, or None."""
34
+ if model_version != MODEL_VERSION or len(candidates) < 2:
35
+ return None
36
+ s1, s2 = candidates[0]["similarity"], candidates[1]["similarity"]
37
+ z = WEIGHTS[0] + WEIGHTS[1] * s1 + WEIGHTS[2] * (s1 - s2) + WEIGHTS[3] * candidates[0].get("imprint_read", 0.0)
38
+ confidence = 1 / (1 + math.exp(-z))
39
+ fit = "poor" if s1 < T_POOR else "good" if s1 >= T_GOOD else "weak"
40
+ verdict = ("no_confident_match" if fit == "poor" or confidence < LOW else
41
+ "possible_match" if fit == "good" and confidence >= HIGH else "uncertain")
42
+ return {"confidence": round(confidence, 3), "catalogue_fit": fit, "verdict": verdict,
43
+ "verdict_message": MESSAGES[verdict]}
src/epills/core/inference/embedder.py CHANGED
@@ -9,7 +9,7 @@ import os
9
  import numpy as np
10
  import torch
11
 
12
- from epills.core.inference.preprocess import WINDOW, crop_window, eval_transform
13
  from epills.core.models.hybrid import HybridPillNet
14
 
15
 
@@ -22,6 +22,9 @@ class Embedder:
22
  self.version = ckpt.get("version") or ckpt.get("config", {}).get("name", "unversioned")
23
  self.calibration = ckpt.get("calibration") or {}
24
  self.window = self.calibration.get("window") or WINDOW # the crop contract this model was calibrated with
 
 
 
25
  self.transform = eval_transform()
26
  self.batch_size = batch_size
27
 
 
9
  import numpy as np
10
  import torch
11
 
12
+ from epills.core.inference.preprocess import CROP_SIZE, IMAGE_SIZE, WINDOW, crop_window, eval_transform
13
  from epills.core.models.hybrid import HybridPillNet
14
 
15
 
 
22
  self.version = ckpt.get("version") or ckpt.get("config", {}).get("name", "unversioned")
23
  self.calibration = ckpt.get("calibration") or {}
24
  self.window = self.calibration.get("window") or WINDOW # the crop contract this model was calibrated with
25
+ # embeddings are comparable only within one space: same model, same crop contract, loose-pill photos
26
+ self.embedding_space_id = f"pill-crop:{self.version}:w{self.window:g}"
27
+ self.preprocess_version = f"crop-contract-1:window-{self.window:g}:crop-{CROP_SIZE}:input-{IMAGE_SIZE}"
28
  self.transform = eval_transform()
29
  self.batch_size = batch_size
30
 
src/epills/core/inference/pill_finder.py CHANGED
@@ -1,46 +1,190 @@
1
  """
2
  A stand-in for the Stage 1 detector: finds pills on a plain background (a mat, a table) in a full photo.
3
 
4
- Works when the background is one roughly uniform colour and pills don't touch. Anything else needs the real
5
- detector, whose boxes the API accepts instead. The background is the median colour of a frame along the photo's
6
- edges; a pill is a blob that differs from it in Lab colour, after a morphological clean-up and hole filling.
 
 
 
 
 
 
 
 
7
  """
 
 
8
  import cv2
9
  import numpy as np
10
 
11
  WORK = 768 # long side of the working image
12
- MIN_DISTANCE = 14.0 # Lab distance below which a pixel is background
13
- MIN_AREA = 0.0015 # smallest blob, as a share of the photo
14
- MAX_AREA = 0.35 # largest blob (bigger: probably the mat's edge or a shadow, not a pill)
 
 
 
 
 
 
 
15
 
16
 
17
- def find_pills(image, max_pills=20):
18
- """PIL photo -> boxes (x0, y0, x1, y1) in the photo's pixels, largest first."""
19
- rgb = np.asarray(image.convert("RGB"))
20
- h, w = rgb.shape[:2]
21
- f = WORK / max(h, w)
22
- small = cv2.resize(rgb, (max(1, round(w * f)), max(1, round(h * f))), interpolation=cv2.INTER_AREA)
23
- lab = cv2.cvtColor(small, cv2.COLOR_RGB2LAB).astype(np.float32)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  sh, sw = lab.shape[:2]
25
- b = max(2, int(0.04 * min(sh, sw)))
26
- frame = np.concatenate([lab[:b].reshape(-1, 3), lab[-b:].reshape(-1, 3),
27
- lab[:, :b].reshape(-1, 3), lab[:, -b:].reshape(-1, 3)])
28
- d = np.linalg.norm(lab - np.median(frame, axis=0), axis=2)
29
  otsu, _ = cv2.threshold(np.clip(d * 2, 0, 255).astype(np.uint8), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
30
- mask = (d > max(otsu / 2, MIN_DISTANCE)).astype(np.uint8)
31
  k = max(3, int(0.01 * min(sh, sw)) | 1)
32
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
33
- mask = cv2.morphologyEx(cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel), cv2.MORPH_CLOSE, kernel)
 
 
 
 
 
 
 
34
  flood = np.pad(mask, 1)
35
  cv2.floodFill(flood, np.zeros((sh + 4, sw + 4), np.uint8), (0, 0), 1)
36
- mask = mask | (1 - flood[1:-1, 1:-1])
 
 
 
 
 
 
 
 
 
 
37
 
38
- n, _, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
39
- boxes = []
40
- for x, y, bw, bh, area in stats[1:]:
 
41
  share = area / (sh * sw)
42
- touches = (x <= 1) + (y <= 1) + (x + bw >= sw - 1) + (y + bh >= sh - 1)
43
- if MIN_AREA <= share <= MAX_AREA and touches < 2:
44
- boxes.append((area, (x / f, y / f, (x + bw) / f, (y + bh) / f)))
45
- boxes.sort(key=lambda t: -t[0])
46
- return [tuple(round(v, 1) for v in box) for _, box in boxes[:max_pills]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  A stand-in for the Stage 1 detector: finds pills on a plain background (a mat, a table) in a full photo.
3
 
4
+ Works when the background is plain (one colour, possibly unevenly lit) and pills lie apart. Anything else needs the
5
+ real detector, whose boxes the API accepts instead.
6
+ 1. Background: each pixel is compared with the mat's colour *at that place*: a smooth surface (degree-3 polynomial
7
+ per Lab channel) fitted robustly, so vignetting and lighting gradients don't turn corners into "pills". Pills are
8
+ outliers to that fit and don't move it.
9
+ 2. A pill is a blob that differs from it (Otsu threshold, at least MIN_DISTANCE), after a morphological clean-up and
10
+ hole filling, with a pill-like shape: compact (solidity), not too elongated, not a sliver along the photo's edge.
11
+ 3. Pieces of one pill (a light pill on a light background splits along its score line or imprint) are merged when
12
+ they nearly touch; small crumbs next to a pill are dropped.
13
+ Every box carries quality flags; any flag, or a dropped crumb, sets the photo flag `count_uncertain`, so a pill count
14
+ from this finder is never trusted blindly (blueprint job B1).
15
  """
16
+ from dataclasses import dataclass, field
17
+
18
  import cv2
19
  import numpy as np
20
 
21
  WORK = 768 # long side of the working image
22
+ MIN_DISTANCE = 12.0 # Lab distance from the local background below which a pixel is background
23
+ MIN_AREA = 0.0008 # smallest blob, as a share of the photo
24
+ MAX_AREA = 0.25 # largest blob
25
+ MIN_SOLIDITY = 0.80 # blob area / its convex hull: pills are convex, lighting patches and shadows aren't
26
+ MAX_ASPECT = 4.5 # long / short side of the blob's rotated rectangle
27
+ MERGE_GAP = 0.12 # pieces closer than this x the larger piece's long side are one pill
28
+ CRUMB_RATIO = 0.2 # a blob smaller than this x a neighbouring pill's area ...
29
+ CRUMB_GAP = 0.6 # ... within this x its long side is a crumb (shadow edge, chip), dropped
30
+ TEXTURE_RATIO = 0.08 # anywhere, a blob below this x the largest pill's area is a speck of texture, dropped
31
+ LOW_FACTOR = 0.5 # hysteresis: the faint threshold, as a share of the clear one
32
 
33
 
34
+ @dataclass
35
+ class Detection:
36
+ boxes: list = field(default_factory=list) # (x0, y0, x1, y1) in the photo's pixels, largest first
37
+ box_flags: list = field(default_factory=list) # per box: "touches_edge", "merged", "irregular", "small"
38
+ flags: list = field(default_factory=list) # per photo: "no_pills", "count_uncertain", "too_many"
39
+ dropped: int = 0 # crumbs and non-pill blobs left out
40
+
41
+ @property
42
+ def pill_count(self):
43
+ return len(self.boxes)
44
+
45
+
46
+ def _gap(a, b):
47
+ """Distance between two boxes (0 when they overlap)."""
48
+ dx = max(a[0] - b[2], b[0] - a[2], 0.0)
49
+ dy = max(a[1] - b[3], b[1] - a[3], 0.0)
50
+ return float(np.hypot(dx, dy))
51
+
52
+
53
+ def _long(b):
54
+ return max(b[2] - b[0], b[3] - b[1])
55
+
56
+
57
+ def _area(b):
58
+ return (b[2] - b[0]) * (b[3] - b[1])
59
+
60
+
61
+ def _terms(x, y):
62
+ """Polynomial terms up to degree 3 in x and y (in -1..1): enough for vignetting and lighting gradients."""
63
+ return np.stack([np.ones_like(x), x, y, x * x, x * y, y * y, x ** 3, x * x * y, x * y * y, y ** 3], axis=-1)
64
+
65
+
66
+ def _background(lab):
67
+ """The mat's colour at every pixel: a smooth surface per Lab channel, fitted robustly (pills are outliers)."""
68
+ sh, sw = lab.shape[:2]
69
+ tiny = cv2.resize(lab, (64, max(8, round(64 * sh / sw))), interpolation=cv2.INTER_AREA).astype(np.float32)
70
+ th, tw = tiny.shape[:2]
71
+ gy, gx = np.mgrid[0:th, 0:tw]
72
+ A = _terms(gx / (tw - 1) * 2 - 1, gy / (th - 1) * 2 - 1).reshape(-1, 10)
73
+ Y = tiny.reshape(-1, 3)
74
+ keep = np.ones(len(Y), bool)
75
+ for _ in range(4): # refit without the pixels that don't look like mat
76
+ coef, *_ = np.linalg.lstsq(A[keep], Y[keep], rcond=None)
77
+ resid = np.linalg.norm(Y - A @ coef, axis=1)
78
+ keep = resid <= max(2.5 * np.median(resid[keep]), 3.0)
79
+ gy, gx = np.mgrid[0:sh, 0:sw]
80
+ return (_terms(gx / (sw - 1) * 2 - 1, gy / (sh - 1) * 2 - 1) @ coef).astype(np.float32)
81
+
82
+
83
+ def _mask(lab):
84
+ """Pixels that differ from the mat's colour at that place."""
85
  sh, sw = lab.shape[:2]
86
+ d = np.linalg.norm(lab.astype(np.float32) - _background(lab), axis=2)
 
 
 
87
  otsu, _ = cv2.threshold(np.clip(d * 2, 0, 255).astype(np.uint8), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
88
+ high = max(otsu / 2, MIN_DISTANCE)
89
  k = max(3, int(0.01 * min(sh, sw)) | 1)
90
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
91
+ strong = cv2.morphologyEx((d > high).astype(np.uint8), cv2.MORPH_OPEN, kernel)
92
+ # hysteresis: a pill's faint parts (the white half of a capsule) join the clearly different part they touch
93
+ weak = cv2.morphologyEx((d > max(high * LOW_FACTOR, MIN_DISTANCE * 0.75)).astype(np.uint8), cv2.MORPH_OPEN, kernel)
94
+ n, labels = cv2.connectedComponents(weak, connectivity=8)
95
+ keep = np.zeros(n, bool)
96
+ keep[np.unique(labels[strong > 0])] = True
97
+ keep[0] = False
98
+ mask = cv2.morphologyEx(keep[labels].astype(np.uint8), cv2.MORPH_CLOSE, kernel)
99
  flood = np.pad(mask, 1)
100
  cv2.floodFill(flood, np.zeros((sh + 4, sw + 4), np.uint8), (0, 0), 1)
101
+ return mask | (1 - flood[1:-1, 1:-1])
102
+
103
+
104
+ def detect_pills(image, max_pills=20):
105
+ """PIL photo -> Detection: pill boxes in the photo's pixels with quality flags."""
106
+ rgb = np.asarray(image.convert("RGB"))
107
+ h, w = rgb.shape[:2]
108
+ f = WORK / max(h, w)
109
+ small = cv2.resize(rgb, (max(1, round(w * f)), max(1, round(h * f))), interpolation=cv2.INTER_AREA)
110
+ mask = _mask(cv2.cvtColor(small, cv2.COLOR_RGB2LAB))
111
+ sh, sw = mask.shape
112
 
113
+ blobs, dropped = [], 0
114
+ n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
115
+ for i in range(1, n):
116
+ x, y, bw, bh, area = stats[i]
117
  share = area / (sh * sw)
118
+ if not MIN_AREA <= share <= MAX_AREA:
119
+ dropped += share > MAX_AREA
120
+ continue
121
+ contours, _ = cv2.findContours((labels[y:y + bh, x:x + bw] == i).astype(np.uint8), cv2.RETR_EXTERNAL,
122
+ cv2.CHAIN_APPROX_SIMPLE)
123
+ contour = max(contours, key=cv2.contourArea)
124
+ hull = cv2.contourArea(cv2.convexHull(contour))
125
+ solidity = area / hull if hull > 0 else 0.0
126
+ (_, _), (rw, rh), _ = cv2.minAreaRect(contour)
127
+ aspect = max(rw, rh) / max(min(rw, rh), 1.0)
128
+ edges = (x <= 1) + (y <= 1) + (x + bw >= sw - 1) + (y + bh >= sh - 1)
129
+ if edges >= 2 or aspect > MAX_ASPECT or (edges and solidity < 0.9) or solidity < MIN_SOLIDITY - 0.15:
130
+ dropped += 1 # lighting patches, mat edges, slivers: not pills
131
+ continue
132
+ flags = set()
133
+ if edges:
134
+ flags.add("touches_edge")
135
+ if solidity < MIN_SOLIDITY:
136
+ flags.add("irregular")
137
+ blobs.append({"box": [float(x), float(y), float(x + bw), float(y + bh)], "area": float(area), "flags": flags})
138
+
139
+ merged = True
140
+ while merged: # pieces of one pill
141
+ merged = False
142
+ for a in range(len(blobs)):
143
+ for b in range(a + 1, len(blobs)):
144
+ A, B = blobs[a], blobs[b]
145
+ if _gap(A["box"], B["box"]) <= MERGE_GAP * max(_long(A["box"]), _long(B["box"])):
146
+ A["box"] = [min(A["box"][0], B["box"][0]), min(A["box"][1], B["box"][1]),
147
+ max(A["box"][2], B["box"][2]), max(A["box"][3], B["box"][3])]
148
+ A["area"] += B["area"]
149
+ A["flags"] |= B["flags"] | {"merged"}
150
+ del blobs[b]
151
+ merged = True
152
+ break
153
+ if merged:
154
+ break
155
+
156
+ if any("touches_edge" not in b["flags"] for b in blobs): # whole pills found: blobs cut by the edge are mat
157
+ dropped += sum("touches_edge" in b["flags"] for b in blobs)
158
+ blobs = [b for b in blobs if "touches_edge" not in b["flags"]]
159
+ blobs.sort(key=lambda t: -t["area"])
160
+ kept = []
161
+ for blob in blobs: # crumbs next to a bigger pill, specks of texture anywhere
162
+ if kept and (blob["area"] < TEXTURE_RATIO * kept[0]["area"] or any(
163
+ blob["area"] < CRUMB_RATIO * k["area"] and _gap(blob["box"], k["box"]) < CRUMB_GAP * _long(k["box"])
164
+ for k in kept)):
165
+ dropped += 1
166
+ continue
167
+ kept.append(blob)
168
+ if len(kept) >= 2:
169
+ median = float(np.median([k["area"] for k in kept]))
170
+ for k in kept:
171
+ if k["area"] < 0.15 * median:
172
+ k["flags"].add("small")
173
+
174
+ det = Detection(dropped=dropped)
175
+ for k in kept[:max_pills]:
176
+ det.boxes.append(tuple(round(v / f, 1) for v in k["box"]))
177
+ det.box_flags.append(sorted(k["flags"]))
178
+ if not det.boxes:
179
+ det.flags.append("no_pills")
180
+ if len(kept) > max_pills:
181
+ det.flags.append("too_many")
182
+ if any(det.box_flags) or len(kept) > max_pills or any(
183
+ "merged" in fl for fl in det.box_flags):
184
+ det.flags.append("count_uncertain")
185
+ return det
186
+
187
+
188
+ def find_pills(image, max_pills=20):
189
+ """PIL photo -> boxes (x0, y0, x1, y1) in the photo's pixels, largest first (see detect_pills for flags)."""
190
+ return detect_pills(image, max_pills).boxes
src/epills/core/safety/rules.py CHANGED
@@ -1,251 +1,254 @@
1
- """
2
- Stage 3 rules: fixed logic on curated data, no trained model (ROADMAP §4).
3
-
4
- check_medications when a medication is added or changed: drug-drug interactions (DDInter 2.0) and therapeutic
5
- duplication (two medications sharing an active ingredient, via RxNorm)
6
- check_dose at every dose check: double doses, pills not part of this dose, missed medications, unknown and
7
- unsure pills (from Stage 2's matching)
8
-
9
- Alert rules from the project plan:
10
- - Major: red, spoken, caregiver notified. Moderate: amber, spoken. Minor: shown on screen only.
11
- - Every alert ends with "Check with your pharmacist." The app never tells anyone to take or stop a medication.
12
- - DDInter's "Unknown" level (listed, but not graded) gets its own severity, "ungraded": on screen only, not spoken
13
- (decision D6, 2026-10-08), so common medication lists don't drown the graded alerts in noise.
14
- - A medication that can't be identified is reported, never silently skipped: no alert must not mean "no
15
- interaction" when the check couldn't run.
16
- """
17
- from dataclasses import asdict, dataclass, field
18
- from itertools import combinations
19
-
20
- from epills.core.safety.db import normalise_name
21
-
22
- PHARMACIST = "Check with your pharmacist."
23
- UNKNOWN_LEVEL_AS = "ungraded" # decision D6
24
- SOURCE_DDINTER = "DDInter 2.0 (CC BY-NC-SA 4.0), interaction severity"
25
- SOURCE_RXNORM = "RxNorm (NLM), active ingredients"
26
- SOURCE_SCHEDULE = "The patient's schedule and the pill check"
27
-
28
- STYLE = { # severity -> how the app shows it (the plan's severity mapping)
29
- "major": {"colour": "red", "speak": True, "notify_caregiver": True},
30
- "moderate": {"colour": "amber", "speak": True, "notify_caregiver": False},
31
- "ungraded": {"colour": "grey", "speak": False, "notify_caregiver": False},
32
- "minor": {"colour": "grey", "speak": False, "notify_caregiver": False},
33
- }
34
- RANK = {"major": 4, "moderate": 3, "ungraded": 2, "minor": 1}
35
-
36
-
37
- @dataclass
38
- class Alert:
39
- type: str
40
- severity: str
41
- message: str
42
- medication_ids: list
43
- source: str
44
- severity_graded: bool = True
45
- colour: str = ""
46
- speak: bool = False
47
- notify_caregiver: bool = False
48
- details: dict = field(default_factory=dict)
49
-
50
- def __post_init__(self):
51
- style = STYLE[self.severity]
52
- self.colour, self.speak, self.notify_caregiver = style["colour"], style["speak"], style["notify_caregiver"]
53
- if not self.message.endswith(PHARMACIST):
54
- self.message = f"{self.message} {PHARMACIST}"
55
-
56
- def to_dict(self):
57
- return asdict(self)
58
-
59
-
60
- @dataclass
61
- class Ingredient:
62
- rxcui: str | None
63
- name: str
64
-
65
-
66
- @dataclass
67
- class ResolvedMedication:
68
- id: str
69
- display_name: str
70
- ingredients: list
71
- resolved_by: str # ingredients / ndc / rxcui / name / approximate / unresolved
72
- matched_name: str | None = None
73
- note: str | None = None
74
-
75
-
76
- def resolve_medication(med, rxnorm):
77
- """
78
- The active ingredients of one medication. med: dict with id and any of name, rxcui, ndc, ingredients (names
79
- printed on the label, for medicines without US codes). Order of trust: explicit ingredients, NDC, RxCUI, exact
80
- name, then approximate name (reported, so the app can ask "did you mean ...?").
81
- """
82
- mid, name = str(med["id"]), med.get("name") or ""
83
- display = name or ", ".join(med.get("ingredients") or []) or mid
84
- try:
85
- if med.get("ingredients"):
86
- found = []
87
- for ing in med["ingredients"]:
88
- rxcui = rxnorm.rxcui_by_name(ing) if rxnorm else None
89
- ins = rxnorm.ingredients(rxcui) if rxcui else []
90
- found += [Ingredient(r, n) for r, n in ins] or [Ingredient(None, normalise_name(ing))]
91
- return ResolvedMedication(mid, display, found, "ingredients")
92
- if rxnorm is None:
93
- return ResolvedMedication(mid, display, [], "unresolved", note="RxNorm lookups are switched off")
94
- for how, rxcui in (("ndc", med.get("ndc") and rxnorm.rxcui_by_ndc(med["ndc"])),
95
- ("rxcui", med.get("rxcui")),
96
- ("name", name and rxnorm.rxcui_by_name(name))):
97
- if rxcui:
98
- ins = rxnorm.ingredients(rxcui)
99
- if ins:
100
- if not name: # entered by code only: show RxNorm's name, not the app's id
101
- display = (rxnorm.properties(rxcui) or {}).get("name") or display
102
- return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], how)
103
- if name:
104
- for rxcui, matched in rxnorm.approximate(name): # best first; skip retired concepts
105
- ins = rxnorm.ingredients(rxcui)
106
- if ins:
107
- return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], "approximate",
108
- matched_name=matched)
109
- except Exception as error: # RxNorm unreachable: report it, don't guess
110
- return ResolvedMedication(mid, display, [], "unresolved", note=f"lookup failed: {error}")
111
- return ResolvedMedication(mid, display, [], "unresolved", note="no match in RxNorm")
112
-
113
-
114
- def _ingredient_list(names):
115
- return names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1]
116
-
117
-
118
- def check_medications(resolved, db):
119
- """
120
- Interactions and duplications across a patient's medication list (list of ResolvedMedication).
121
- Returns (alerts, per_medication), where per_medication says how each one was identified and checked.
122
- """
123
- alerts, per_med = [], []
124
- ddinter = {} # medication id -> [(ingredient, [ddinter rows])]
125
- for m in resolved:
126
- rows = [(ing, db.drugs_for_ingredient(ing.rxcui, ing.name)) for ing in m.ingredients]
127
- ddinter[m.id] = rows
128
- not_listed = [ing.name for ing, r in rows if not r]
129
- per_med.append({
130
- "id": m.id, "name": m.display_name, "resolved_by": m.resolved_by, "matched_name": m.matched_name,
131
- "ingredients": [{"rxcui": i.rxcui, "name": i.name} for i in m.ingredients],
132
- "not_in_interaction_database": not_listed, "note": m.note,
133
- })
134
- if m.resolved_by == "unresolved":
135
- alerts.append(Alert(
136
- "unresolved", "moderate",
137
- f"I couldn't identify {m.display_name}, so it was not checked for interactions. "
138
- "Please add its active ingredient from the label.",
139
- [m.id], SOURCE_RXNORM, details={"reason": m.note}))
140
-
141
- # therapeutic duplication: the same active ingredient in two medications (brand + generic, or a combination
142
- # pill + a single one). Amber, not red: two strengths of one drug can be prescribed together on purpose.
143
- for a, b in combinations(resolved, 2):
144
- shared = sorted({i.name for i in a.ingredients} & {i.name for i in b.ingredients}
145
- | {i.name for i in a.ingredients for j in b.ingredients if i.rxcui and i.rxcui == j.rxcui})
146
- if shared:
147
- alerts.append(Alert(
148
- "duplication", "moderate",
149
- f"{a.display_name} and {b.display_name} both contain {_ingredient_list(shared)}, "
150
- "so together they may be a double dose.",
151
- [a.id, b.id], SOURCE_RXNORM, details={"shared_ingredients": shared}))
152
-
153
- # interactions: every ingredient of one medication against every ingredient of another
154
- for a, b in combinations(resolved, 2):
155
- found = []
156
- for ing_a, rows_a in ddinter[a.id]:
157
- for ing_b, rows_b in ddinter[b.id]:
158
- if ing_a.rxcui and ing_a.rxcui == ing_b.rxcui:
159
- continue # same ingredient: a duplication, handled above
160
- for ra in rows_a:
161
- for rb in rows_b:
162
- level = db.level(ra[0], rb[0])
163
- if level:
164
- found.append({"ingredient_a": ing_a.name, "ingredient_b": ing_b.name,
165
- "ddinter_a": ra[1], "ddinter_b": rb[1], "level": level})
166
- if not found:
167
- continue
168
- severities = [UNKNOWN_LEVEL_AS if f["level"] == "Unknown" else f["level"].lower() for f in found]
169
- severity = max(severities, key=RANK.get)
170
- graded = severity != "ungraded"
171
- words = {"major": "a serious", "moderate": "a moderate", "minor": "a minor", "ungraded": ""}[severity]
172
- text = (f"{a.display_name} and {b.display_name} have {words} interaction." if graded else
173
- f"{a.display_name} and {b.display_name} are listed as interacting, but the severity isn't graded.")
174
- alerts.append(Alert("interaction", severity, text, [a.id, b.id], SOURCE_DDINTER,
175
- severity_graded=graded, details={"pairs": found}))
176
-
177
- alerts.sort(key=lambda al: -RANK[al.severity])
178
- return alerts, per_med
179
-
180
-
181
- def _number_word(n):
182
- words = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]
183
- return words[n] if 0 <= n < len(words) else str(n)
184
-
185
-
186
- def _count_words(n, singular, plural=None):
187
- return f"{_number_word(n)} {singular if n == 1 else (plural or singular + 's')}"
188
-
189
-
190
- def check_dose(schedule, pills, names=None):
191
- """
192
- Dose check after Stage 2's matching.
193
- schedule: [{"medication_id", "count"}] for this dose (count = pills expected)
194
- pills: [{"medication_id", "decision"}], decision = match / unknown / ambiguous, from /v1/match
195
- names: optional {medication_id: display name} for messages (enrolled medications)
196
- Returns (alerts, summary dict).
197
- """
198
- names = {str(k): v for k, v in (names or {}).items()}
199
- expected = {str(s["medication_id"]): int(s.get("count", 1)) for s in schedule}
200
- matched, unknown, ambiguous = {}, 0, 0
201
- for p in pills:
202
- decision = p.get("decision", "unknown")
203
- if decision == "match" and p.get("medication_id") is not None:
204
- mid = str(p["medication_id"])
205
- matched[mid] = matched.get(mid, 0) + 1
206
- elif decision == "ambiguous":
207
- ambiguous += 1
208
- else:
209
- unknown += 1
210
- name = lambda mid: names.get(mid, mid)
211
- alerts = []
212
- if unknown:
213
- what = "One pill is" if unknown == 1 else f"{_count_words(unknown, 'pill').capitalize()} are"
214
- alerts.append(Alert("unknown_pill", "major",
215
- f"{what} not one of your medicines. Please set {'it' if unknown == 1 else 'them'} aside.",
216
- [], SOURCE_SCHEDULE, details={"count": unknown}))
217
- for mid, n in sorted(matched.items()):
218
- e = expected.get(mid, 0)
219
- if e == 0:
220
- alerts.append(Alert("not_scheduled", "major",
221
- f"{'One pill looks' if n == 1 else _count_words(n, 'pill').capitalize() + ' look'} like "
222
- f"your {name(mid)}, which is not part of this dose. Please set "
223
- f"{'it' if n == 1 else 'them'} aside.",
224
- [mid], SOURCE_SCHEDULE, details={"found": n, "expected": 0}))
225
- elif n > e:
226
- alerts.append(Alert("double_dose", "major",
227
- f"This dose has {_count_words(n, name(mid) + ' pill')} instead of "
228
- f"{_number_word(e)}. Please set the extra "
229
- f"{'one' if n - e == 1 else 'ones'} aside.",
230
- [mid], SOURCE_SCHEDULE, details={"found": n, "expected": e}))
231
- for mid, e in sorted(expected.items()):
232
- n = matched.get(mid, 0)
233
- if n < e:
234
- missing = ("is missing from this dose" if n == 0 else
235
- f"is short: {_count_words(n, 'pill')} instead of {_number_word(e)}")
236
- alerts.append(Alert("missed", "moderate", f"Your {name(mid)} {missing}.", [mid], SOURCE_SCHEDULE,
237
- details={"found": n, "expected": e}))
238
- if ambiguous:
239
- alerts.append(Alert("unsure_pill", "moderate",
240
- f"I'm not sure about {'one pill' if ambiguous == 1 else _count_words(ambiguous, 'pill')}. "
241
- "Please turn it over and photograph the dose again.", [], SOURCE_SCHEDULE,
242
- details={"count": ambiguous}))
243
- alerts.sort(key=lambda al: -RANK[al.severity])
244
- total = len(pills)
245
- summary = {
246
- "pills_seen": total, "matched": sum(matched.values()), "unknown": unknown, "unsure": ambiguous,
247
- "all_ok": not alerts,
248
- "text": (f"I see {_count_words(total, 'pill')}." +
249
- (" They all match this dose." if not alerts and total else "")),
250
- }
251
- return alerts, summary
 
 
 
 
1
+ """
2
+ Stage 3 rules: fixed logic on curated data, no trained model (ROADMAP §4).
3
+
4
+ check_medications when a medication is added or changed: drug-drug interactions (DDInter 2.0) and therapeutic
5
+ duplication (two medications sharing an active ingredient, via RxNorm)
6
+ check_dose at every dose check: extra pills, pills not part of this dose, scheduled medicines not seen, unknown and
7
+ unsure pills (from Stage 2's matching)
8
+
9
+ Alert rules from the project plan:
10
+ - Major: red, spoken, caregiver notified. Moderate: amber, spoken. Minor: shown on screen only.
11
+ - Every alert ends with "Check with your pharmacist." The app never tells anyone to take or stop a medication.
12
+ - DDInter's "Unknown" level (listed, but not graded) gets its own severity, "ungraded": on screen only, not spoken
13
+ (decision D6, 2026-10-08), so common medication lists don't drown the graded alerts in noise.
14
+ - A medication that can't be identified is reported, never silently skipped: no alert must not mean "no
15
+ interaction" when the check couldn't run.
16
+ """
17
+ from dataclasses import asdict, dataclass, field
18
+ from itertools import combinations
19
+
20
+ from epills.core.safety.db import normalise_name
21
+
22
+ PHARMACIST = "Check with your pharmacist."
23
+ UNKNOWN_LEVEL_AS = "ungraded" # decision D6
24
+ SOURCE_DDINTER = "DDInter 2.0 (CC BY-NC-SA 4.0), interaction severity"
25
+ SOURCE_RXNORM = "RxNorm (NLM), active ingredients"
26
+ SOURCE_SCHEDULE = "The patient's schedule and the pill check"
27
+
28
+ STYLE = { # severity -> how the app shows it (the plan's severity mapping)
29
+ "major": {"colour": "red", "speak": True, "notify_caregiver": True},
30
+ "moderate": {"colour": "amber", "speak": True, "notify_caregiver": False},
31
+ "ungraded": {"colour": "grey", "speak": False, "notify_caregiver": False},
32
+ "minor": {"colour": "grey", "speak": False, "notify_caregiver": False},
33
+ }
34
+ RANK = {"major": 4, "moderate": 3, "ungraded": 2, "minor": 1}
35
+
36
+
37
+ @dataclass
38
+ class Alert:
39
+ type: str
40
+ severity: str
41
+ message: str
42
+ medication_ids: list
43
+ source: str
44
+ severity_graded: bool = True
45
+ colour: str = ""
46
+ speak: bool = False
47
+ notify_caregiver: bool = False
48
+ details: dict = field(default_factory=dict)
49
+
50
+ def __post_init__(self):
51
+ style = STYLE[self.severity]
52
+ self.colour, self.speak, self.notify_caregiver = style["colour"], style["speak"], style["notify_caregiver"]
53
+ if not self.message.endswith(PHARMACIST):
54
+ self.message = f"{self.message} {PHARMACIST}"
55
+
56
+ def to_dict(self):
57
+ return asdict(self)
58
+
59
+
60
+ @dataclass
61
+ class Ingredient:
62
+ rxcui: str | None
63
+ name: str
64
+
65
+
66
+ @dataclass
67
+ class ResolvedMedication:
68
+ id: str
69
+ display_name: str
70
+ ingredients: list
71
+ resolved_by: str # ingredients / ndc / rxcui / name / approximate / unresolved
72
+ matched_name: str | None = None
73
+ note: str | None = None
74
+
75
+
76
+ def resolve_medication(med, rxnorm):
77
+ """
78
+ The active ingredients of one medication. med: dict with id and any of name, rxcui, ndc, ingredients (names
79
+ printed on the label, for medicines without US codes). Order of trust: explicit ingredients, NDC, RxCUI, exact
80
+ name, then approximate name (reported, so the app can ask "did you mean ...?").
81
+ """
82
+ mid, name = str(med["id"]), med.get("name") or ""
83
+ display = name or ", ".join(med.get("ingredients") or []) or mid
84
+ try:
85
+ if med.get("ingredients"):
86
+ found = []
87
+ for ing in med["ingredients"]:
88
+ rxcui = rxnorm.rxcui_by_name(ing) if rxnorm else None
89
+ ins = rxnorm.ingredients(rxcui) if rxcui else []
90
+ found += [Ingredient(r, n) for r, n in ins] or [Ingredient(None, normalise_name(ing))]
91
+ return ResolvedMedication(mid, display, found, "ingredients")
92
+ if rxnorm is None:
93
+ return ResolvedMedication(mid, display, [], "unresolved", note="RxNorm lookups are switched off")
94
+ for how, rxcui in (("ndc", med.get("ndc") and rxnorm.rxcui_by_ndc(med["ndc"])),
95
+ ("rxcui", med.get("rxcui")),
96
+ ("name", name and rxnorm.rxcui_by_name(name))):
97
+ if rxcui:
98
+ ins = rxnorm.ingredients(rxcui)
99
+ if ins:
100
+ if not name: # entered by code only: show RxNorm's name, not the app's id
101
+ display = (rxnorm.properties(rxcui) or {}).get("name") or display
102
+ return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], how)
103
+ if name:
104
+ for rxcui, matched in rxnorm.approximate(name): # best first; skip retired concepts
105
+ ins = rxnorm.ingredients(rxcui)
106
+ if ins:
107
+ return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], "approximate",
108
+ matched_name=matched)
109
+ except Exception as error: # RxNorm unreachable: report it, don't guess
110
+ return ResolvedMedication(mid, display, [], "unresolved", note=f"lookup failed: {error}")
111
+ return ResolvedMedication(mid, display, [], "unresolved", note="no match in RxNorm")
112
+
113
+
114
+ def _ingredient_list(names):
115
+ return names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1]
116
+
117
+
118
+ def check_medications(resolved, db):
119
+ """
120
+ Interactions and duplications across a patient's medication list (list of ResolvedMedication).
121
+ Returns (alerts, per_medication), where per_medication says how each one was identified and checked.
122
+ """
123
+ alerts, per_med = [], []
124
+ ddinter = {} # medication id -> [(ingredient, [ddinter rows])]
125
+ for m in resolved:
126
+ rows = [(ing, db.drugs_for_ingredient(ing.rxcui, ing.name)) for ing in m.ingredients]
127
+ ddinter[m.id] = rows
128
+ not_listed = [ing.name for ing, r in rows if not r]
129
+ per_med.append({
130
+ "id": m.id, "name": m.display_name, "resolved_by": m.resolved_by, "matched_name": m.matched_name,
131
+ "ingredients": [{"rxcui": i.rxcui, "name": i.name} for i in m.ingredients],
132
+ "not_in_interaction_database": not_listed, "note": m.note,
133
+ })
134
+ if m.resolved_by == "unresolved":
135
+ alerts.append(Alert(
136
+ "unresolved", "moderate",
137
+ f"I couldn't identify {m.display_name}, so it was not checked for interactions. "
138
+ "Please add its active ingredient from the label.",
139
+ [m.id], SOURCE_RXNORM, details={"reason": m.note}))
140
+
141
+ # therapeutic duplication: the same active ingredient in two medications (brand + generic, or a combination
142
+ # pill + a single one). Amber, not red: two strengths of one drug can be prescribed together on purpose.
143
+ for a, b in combinations(resolved, 2):
144
+ shared = sorted({i.name for i in a.ingredients} & {i.name for i in b.ingredients}
145
+ | {i.name for i in a.ingredients for j in b.ingredients if i.rxcui and i.rxcui == j.rxcui})
146
+ if shared:
147
+ alerts.append(Alert(
148
+ "duplication", "moderate",
149
+ f"{a.display_name} and {b.display_name} both contain {_ingredient_list(shared)}, "
150
+ "so together they may be a double dose.",
151
+ [a.id, b.id], SOURCE_RXNORM, details={"shared_ingredients": shared}))
152
+
153
+ # interactions: every ingredient of one medication against every ingredient of another
154
+ for a, b in combinations(resolved, 2):
155
+ found = []
156
+ for ing_a, rows_a in ddinter[a.id]:
157
+ for ing_b, rows_b in ddinter[b.id]:
158
+ if ing_a.rxcui and ing_a.rxcui == ing_b.rxcui:
159
+ continue # same ingredient: a duplication, handled above
160
+ for ra in rows_a:
161
+ for rb in rows_b:
162
+ level = db.level(ra[0], rb[0])
163
+ if level:
164
+ found.append({"ingredient_a": ing_a.name, "ingredient_b": ing_b.name,
165
+ "ddinter_a": ra[1], "ddinter_b": rb[1], "level": level})
166
+ if not found:
167
+ continue
168
+ severities = [UNKNOWN_LEVEL_AS if f["level"] == "Unknown" else f["level"].lower() for f in found]
169
+ severity = max(severities, key=RANK.get)
170
+ graded = severity != "ungraded"
171
+ words = {"major": "a serious", "moderate": "a moderate", "minor": "a minor", "ungraded": ""}[severity]
172
+ text = (f"{a.display_name} and {b.display_name} have {words} interaction." if graded else
173
+ f"{a.display_name} and {b.display_name} are listed as interacting, but the severity isn't graded.")
174
+ alerts.append(Alert("interaction", severity, text, [a.id, b.id], SOURCE_DDINTER,
175
+ severity_graded=graded, details={"pairs": found}))
176
+
177
+ alerts.sort(key=lambda al: -RANK[al.severity])
178
+ return alerts, per_med
179
+
180
+
181
+ def _number_word(n):
182
+ words = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]
183
+ return words[n] if 0 <= n < len(words) else str(n)
184
+
185
+
186
+ def _count_words(n, singular, plural=None):
187
+ return f"{_number_word(n)} {singular if n == 1 else (plural or singular + 's')}"
188
+
189
+
190
+ def check_dose(schedule, pills, names=None):
191
+ """
192
+ Dose check after Stage 2's matching.
193
+ schedule: [{"medication_id", "count"}] for this dose (count = pills expected)
194
+ pills: [{"medication_id", "decision"}], decision = match / unknown / ambiguous, from /v1/match
195
+ names: optional {medication_id: display name} for messages (enrolled medications)
196
+ Returns (alerts, summary dict).
197
+ """
198
+ names = {str(k): v for k, v in (names or {}).items()}
199
+ expected = {str(s["medication_id"]): int(s.get("count", 1)) for s in schedule}
200
+ matched, unknown, ambiguous = {}, 0, 0
201
+ for p in pills:
202
+ decision = p.get("decision", "unknown")
203
+ if decision == "match" and p.get("medication_id") is not None:
204
+ mid = str(p["medication_id"])
205
+ matched[mid] = matched.get(mid, 0) + 1
206
+ elif decision == "ambiguous":
207
+ ambiguous += 1
208
+ else:
209
+ unknown += 1
210
+ name = lambda mid: names.get(mid, mid)
211
+ alerts = []
212
+ if unknown:
213
+ what = "One pill doesn't" if unknown == 1 else f"{_count_words(unknown, 'pill').capitalize()} don't"
214
+ alerts.append(Alert("unknown_pill", "major",
215
+ f"{what} look like any of your medicines. Please set {'it' if unknown == 1 else 'them'} "
216
+ "aside.",
217
+ [], SOURCE_SCHEDULE, details={"count": unknown}))
218
+ for mid, n in sorted(matched.items()):
219
+ e = expected.get(mid, 0)
220
+ if e == 0:
221
+ alerts.append(Alert("not_scheduled", "major",
222
+ f"{'One pill looks' if n == 1 else _count_words(n, 'pill').capitalize() + ' look'} like "
223
+ f"your {name(mid)}, which is not part of this dose. Please set "
224
+ f"{'it' if n == 1 else 'them'} aside.",
225
+ [mid], SOURCE_SCHEDULE, details={"found": n, "expected": 0}))
226
+ elif n > e:
227
+ alerts.append(Alert("double_dose", "major",
228
+ f"This photo shows {_count_words(n, 'pill')} that look like your {name(mid)} instead "
229
+ f"of {_number_word(e)}. Please set the extra {'one' if n - e == 1 else 'ones'} aside.",
230
+ [mid], SOURCE_SCHEDULE, details={"found": n, "expected": e}))
231
+ for mid, e in sorted(expected.items()):
232
+ n = matched.get(mid, 0)
233
+ if n < e: # not seen in this photo: never a missed dose (the backend knows what was taken)
234
+ text = (f"I can't see your {name(mid)} in this photo." if n == 0 else
235
+ f"I can see only {_count_words(n, 'pill')} that look like your {name(mid)}, instead of "
236
+ f"{_number_word(e)}.")
237
+ alerts.append(Alert("not_seen", "moderate", text, [mid], SOURCE_SCHEDULE,
238
+ details={"found": n, "expected": e}))
239
+ if ambiguous:
240
+ alerts.append(Alert("unsure_pill", "moderate",
241
+ f"I'm not sure about {'one pill' if ambiguous == 1 else _count_words(ambiguous, 'pill')}. "
242
+ "Please turn it over and photograph the dose again.", [], SOURCE_SCHEDULE,
243
+ details={"count": ambiguous}))
244
+ alerts.sort(key=lambda al: -RANK[al.severity])
245
+ total = len(pills)
246
+ summary = {
247
+ "pills_seen": total, "matched": sum(matched.values()), "unknown": unknown, "unsure": ambiguous,
248
+ "all_ok": not alerts, # deprecated name of all_visually_matched
249
+ "all_visually_matched": not alerts,
250
+ "count_reliable": True,
251
+ "text": (f"I see {_count_words(total, 'pill')}." +
252
+ (" They all look like the medicines of this dose." if not alerts and total else "")),
253
+ }
254
+ return alerts, summary