Medication matching¶
The matching pipeline turns a handwritten medication line into a provisional, review-ready catalog match. The model reads; the deterministic matcher decides; a constrained model call may only narrow an existing shortlist. Nothing in this pipeline invents a medication or creates a clinical record.
Pipeline¶
extracted line (name, dose text, frequency text, confidence)
│
▼
brand-first deterministic matching
exact (brand + aliases, Bengali included) · fuzzy (edit distance)
· phonetic (consonant skeleton) → Reciprocal Rank Fusion ordering
│
▼
variant selection — the dose text ("500 mg") picks the strength,
a written form hint (Tab./Cap./Inj) breaks same-strength form ties
│
▼
status: candidate · ambiguous · abstained
│
├─ candidate at LOW extraction confidence → demoted, model must
│ re-verify against the image before resolution is restored
├─ ambiguous → constrained disambiguation: the model sees the image
│ and the ranked options, answers with one offered id or null
└─ abstained → never escalated; the model must not invent
Safety invariants, all enforced in the domain layer and covered by tests:
- Status is decided by calibrated similarity thresholds; the margin is measured against the strongest other reference, so a fusion artifact can never auto-resolve a name.
- Disambiguation is multiple-choice over already-ranked options; an id outside the offered set, a low or malformed confidence, or any transport failure leaves the deterministic result unchanged.
- An abstained line is never upgraded by a model.
- Extracted prescription text never enters logs; failures log exception types and stable identifiers only.
Medication index¶
The catalog lives in a dedicated Postgres database (Neon), separate from the MySQL operational store, and is read fully into memory at composition time (~21.6K brand-strength-form variants). Sources, in precedence order: MEDICATION_INDEX_DATABASE_URL, MEDICATION_INDEX_CSV, then a three-entry demo index. A misconfigured index fails startup loudly.
Index rows carry brand, generic, strength, dosage form, manufacturer, and source/version provenance. Variants group by brand for matching; promoted aliases (below) join the brand's alias set on every reload.
Feedback loop¶
Reviewers vote on resolved matches (/lab buttons or POST /api/v1/match/feedback) — one current vote per normalized token and actor. The nightly promotion job turns tokens accepted by enough distinct actors into active aliases of that reference (length and collision guards; colliding tokens are left for human review), and deactivates reject-dominated aliases. Counters are recomputed from the feedback table on every run. A promoted handwriting variant exact-matches on the next index reload with no model call.
Frequency normalization¶
Bounded frequency text is normalized deterministically — Bengali or ASCII dose patterns (1+0+1), Latin abbreviations (OD/BD/TDS/QDS/HS), as-needed markers (SOS/PRN), meal relations, and duration tails — into times-per-day, named slots with per-slot amounts, whole-day durations, and food relation. Anything unrecognized stays null; nothing is guessed. The structured form is returned by the extraction endpoint for a future scheduling layer.
Evaluation¶
scripts/run_eval.py measures certain-line extraction recall and match behaviour against eval/eval_set.jsonl, an annotated answer key of real prescriptions (images themselves are gitignored and never enter the repository). Uncertain annotations are scored separately and cannot dent the recall gate. Every resolved match is printed for human review — a wrong confident match is the pipeline's worst failure mode and the metric releases should gate on as the answer key grows.