Version 0.1 (draft for review) · Scope Mobile web + native in-app tool · Owner TBD
A member-facing tool that answers two questions in under a minute:
The member types a drug name, gets an AI-assembled set of lower-cost and higher-cost alternatives with generic/brand clearly marked, picks a strength and form, then sees real prices at pharmacies near them in both a list and a map.
| Metric | Target |
|---|---|
| Search → pharmacy list completion rate | ≥ 55% |
| Median time to first price | ≤ 3.5 s |
| Alternatives panel engagement (tap-through) | ≥ 30% |
| "Was this helpful?" positive rate on AI panel | ≥ 70% |
| Clinically inappropriate suggestion rate (audited sample) | < 0.5% |
Search Alternatives Configure Location
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ "lipi"│ ───▶ │ brand │ ───▶ │ 20 mg │ ───▶ │ detect│
│ ─── │ │ ───── │ │ tablet│ │ or │
│ typea-│ │generic│ │ 30ct │ │ type │
│ head │ │ alts │ │ │ │address│
└───────┘ └───────┘ └───────┘ └───────┘
│ │
│ higher-cost ▼
│ options collapsed ┌───────────────┐
▼ │ Pharmacy list │
"Ask your doctor" │ ⇄ Map │
disclaimer shown │ pin → price │
│ + distance │
└───────────────┘
Two entry points beyond search: deep link from a claims/EOB row ("price this refill"), and a saved-medication list on the member's home screen.
┌──────────────────────────────────────────────────────────────┐
│ Mobile client (React Native + shared RN-Web) │
│ search · alternatives · configurator · map · list │
└───────────────┬──────────────────────────────────────────────┘
│ HTTPS / OAuth2 member token
┌───────────────▼──────────────────────────────────────────────┐
│ BFF (GraphQL or REST-for-mobile) │
│ session, response shaping, field-level PHI redaction │
└──┬──────┬──────────┬──────────────┬─────────────┬────────────┘
│ │ │ │ │
┌──▼───┐ ┌▼────────┐ ┌▼───────────┐ ┌▼──────────┐ ┌▼──────────┐
│Drug │ │Alterna- │ │ Pricing │ │ Pharmacy │ │ Member │
│Search│ │tives │ │ Service │ │ Network │ │ Context │
│Svc │ │Svc (AI) │ │ │ │ Service │ │ Service │
└──┬───┘ └──┬──────┘ └─┬──────────┘ └──┬────────┘ └──┬────────┘
│ │ │ │ │
┌──▼────────▼──┐ ┌────▼──────────┐ ┌──▼─────────┐ ┌─▼─────────┐
│ Drug KB │ │ RTPB / PBM │ │ Network │ │ Eligibility│
│ RxNorm, NDC │ │ adjudication │ │ roster + │ │ accumulators│
│ FDA Orange │ │ Cash/discount │ │ Google │ │ plan design │
│ Book, class │ │ price feeds │ │ Places │ │ │
│ + vector idx │ └───────────────┘ └────────────┘ └─────────────┘
└──────────────┘
▲
│ retrieval only — candidate set is deterministic
┌───────┴────────────────────┐
│ LLM (BAA-covered endpoint) │ ranks + explains. Never invents drugs.
└────────────────────────────┘
The mobile client must never hold plan design logic, and must never receive fields it doesn't render. The BFF composes one /search-results and one /pharmacy-prices response so the phone makes two calls, not nine, and strips anything the screen doesn't need before it leaves the trust boundary.
Typeahead over a normalized drug index.
Latency budget: p95 ≤ 120 ms.
This is the part most likely to go wrong, so the design deliberately limits what the model is allowed to do.
Two-stage: deterministic retrieval, then model ranking.
Stage 1 — candidate generation (no LLM). Given the searched RXCUI and the member's plan:
| Candidate type | Rule |
|---|---|
| Generic equivalent | Same RxNorm ingredient + strength + form, Orange Book AB-rated |
| Therapeutic alternative | Same USP therapeutic class / ATC level 4, on formulary |
| Formulary-preferred | Plan's step-therapy or preferred agent for that class |
| Different form/strength | Same ingredient, cheaper package (e.g. two 20 mg vs one 40 mg — flag only, never advise splitting) |
| Higher-cost | Same class, higher member cost — shown for context, collapsed by default |
Everything in the candidate set is a real, dispensable, on-market product from our own KB. The model cannot add to this set.
Stage 2 — LLM ranking and explanation. The model receives the candidate list plus structured cost data and returns, per candidate: a rank, a one-sentence plain-language reason, and a requiresNewPrescription flag. The prompt is constrained to a JSON schema; responses that fail schema validation or that reference an RXCUI outside the candidate set are rejected and we fall back to a rules-only ordering (cost ascending, generic-equivalent first).
Guardrails, all enforced outside the model:
Evaluation: a golden set of ~500 (drug, plan) pairs reviewed by a pharmacist, scored on clinical appropriateness, cost-order correctness, and explanation accuracy. Run on every prompt or model change; block release below threshold. Plus a shadow-mode audit of 1% of live traffic reviewed weekly.
Caching: alternatives for a (RXCUI, formulary ID) pair are deterministic given the same KB snapshot. Cache in Redis for 24 h. Expected hit rate > 90%, which also keeps LLM spend near-flat as traffic grows.
Three price types, clearly labeled in the UI:
RTPB calls are fanned out per pharmacy, capped at the 15 nearest in-network pharmacies, with a 1.5 s budget and partial rendering — the list paints with distances first and prices fill in.
Accumulator state changes; prices are cached at most 15 minutes and always stamped with "as of."
Cost control: Autocomplete session tokens, a 300 ms debounce, radius search served from our own DB, Distance Matrix only for what's on screen, and a per-member daily quota. Map tiles are the largest line item — cache aggressively and avoid re-initializing the map on tab switches.
PHI boundary: member identifiers, drug names, and prices are never sent to Google. We send an address string or a lat/lng, nothing else. This is stated in the privacy copy.
Eligibility, plan design, formulary ID, deductible and OOP-max accumulators, saved addresses, saved medications. Every read is audit-logged.
All endpoints require a member-scoped OAuth2 token. Accept-Language honored.
GET /v1/drugs/suggest?q=lipi&limit=8{
"suggestions": [
{ "rxcui": "153165", "display": "Lipitor", "genericName": "atorvastatin calcium",
"type": "BRAND", "hasGeneric": true },
{ "rxcui": "83367", "display": "Atorvastatin calcium",
"type": "GENERIC", "brandNames": ["Lipitor"] }
]
}
GET /v1/drugs/{rxcui}/alternatives{
"requested": {
"rxcui": "153165", "name": "Lipitor", "type": "BRAND",
"formularyTier": 3, "estimatedMonthlyCost": { "amount": 84.00, "currency": "USD" }
},
"lowerCost": [
{ "rxcui": "83367", "name": "Atorvastatin calcium", "type": "GENERIC",
"relationship": "GENERIC_EQUIVALENT", "orangeBookCode": "AB",
"formularyTier": 1, "estimatedMonthlyCost": { "amount": 9.00 },
"estimatedMonthlySavings": { "amount": 75.00 },
"requiresNewPrescription": false,
"reason": "Same active ingredient as Lipitor and rated equivalent by the FDA.",
"reasonSource": "LLM_RANKED" }
],
"higherCost": [ /* same shape */ ],
"disclaimer": "Costs are estimates for your plan. Talk to your prescriber before changing any medication.",
"asOf": "2026-09-13T14:22:05Z"
}
reasonSource is LLM_RANKED or RULES_FALLBACK — logged and used to monitor fallback rate.
GET /v1/drugs/{rxcui}/configurationsReturns available strengths, dose forms, and package quantities for the concept, so the two dropdowns are populated from real dispensable NDCs rather than a static list. Selecting a strength filters the form options and vice versa — the client must handle empty intersections (e.g. 80 mg has no oral suspension).
POST /v1/prices/pharmacies{
"ndc": "00093505698",
"quantity": 30, "daysSupply": 30,
"location": { "lat": 33.1507, "lng": -96.8236 },
"radiusMiles": 10,
"sort": "PRICE_ASC"
}
Response: pharmacy array with ncpdpId, name, address, lat, lng, distanceMiles, driveTimeMinutes, networkStatus (PREFERRED | IN_NETWORK | OUT_OF_NETWORK), price ({ amount, type: PLAN_RTPB | PLAN_ESTIMATE | CASH, appliesToDeductible }), hours, phone, googlePlaceId.
Streams via chunked response or returns priceStatus: PENDING per row with a follow-up poll, so the list is never blocked on the slowest RTPB call.
DrugConcept rxcui, name, type(BRAND|GENERIC), ingredientId, classes[]
DispensableProduct ndc, rxcui, strength, doseForm, packageQty, labeler, obCode
FormularyEntry formularyId, rxcui, tier, requiresPA, stepTherapy, qtyLimit
Pharmacy ncpdpId, name, address, geom(Point), googlePlaceId, chainId
NetworkStatus contractId, ncpdpId, status, effectiveDates
PriceQuote ndc, ncpdpId, memberId(hashed), amount, type, asOf, ttl
MemberContext memberId, planId, formularyId, accumulators, savedAddresses[]
PriceQuote.memberId is stored hashed with a per-environment pepper; quotes expire on a TTL index.
| Concern | Target |
|---|---|
| Typeahead p95 | 120 ms |
| Alternatives p95 (cache hit) | 250 ms |
| Alternatives p95 (cache miss, LLM) | 2.5 s, with skeleton UI from 0 ms |
| Pharmacy list first paint | 1.2 s (distances), prices fill within 3 s |
| Availability | 99.9% |
| Map interaction | 60 fps pan/zoom, ≤ 40 pins rendered, cluster beyond that |
Degradation ladder. LLM down → rules-based ordering, no reason text. RTPB down → estimated prices, labeled. Google Maps down → list view only, map tab disabled with an explanation. Network roster stale → show prices with an "as of" date rather than an empty state.
| Situation | Behavior |
|---|---|
| No search results | "No match for X. Check the spelling, or search the generic name." Plus 3 nearest lexical matches. |
| Drug not on formulary | Show it, marked not covered, with cash price and covered alternatives in the same class. |
| Drug requires prior authorization | Tier badge plus "Needs approval" chip and a link to the PA explainer. |
| No alternatives found | Say so plainly and go straight to the configurator. Don't invent options. |
| No in-network pharmacy within radius | Widen to 25 mi automatically, state that we did, and surface mail order. |
| Location permission denied | Address field, focused, with saved addresses listed. |
| RTPB timeout | Estimated price with an "estimate" chip and a retry affordance. |
Instrument: search submitted, zero-result searches (drives the synonym dictionary), alternative card tapped, alternative type tapped (generic-equivalent vs. class switch), configurator changes, location method chosen, list/map toggle, pin tapped, pharmacy detail opened, "call pharmacy" tapped, AI panel helpfulness vote, fallback-to-rules rate, RTPB timeout rate.
Track estimated savings surfaced vs. a proxy for savings realized (subsequent claims for the suggested alternative) — that's the number that justifies the tool.
| Phase | Contents |
|---|---|
| 0 | Drug search + configurator + list-only pharmacy prices, estimated pricing. Internal employees. |
| 1 | Alternatives service in shadow mode — generate, log, pharmacist-audit, don't display. |
| 2 | Alternatives shown to 10%, holdout for measurement. RTPB live. |
| 3 | Map view, Distance Matrix, pin price labels. |
| 4 | Full rollout, deep links from claims, saved medications, refill reminders. |