// Pure mapper: raw practitioner rankings from POST /R4/booking/match-practitioner recommendations the booking UI // can safely render/act on. DISPLAY, specialty, and location ALWAYS come from the caller-supplied bookable list // (data the component already holds); the API is trusted only for the ranking (which id, rationale, confidence). import { NAME_NOISE, namedBy } from './reasonNames'; export interface ApiPractitionerRanking { practitionerRoleId: string; rationale: string; confidence: number } export interface BookablePractitioner { practitionerRoleId: string; display: string; specialtyDisplay: string; locationId: string; locationDisplay: string; /** * WHICH specialty this is, as opposed to what it is CALLED. * * `specialtyDisplay` is a label for the reader and is empty whenever the record holds a coded value * with no `display` and no `text` - which is the more correct FHIR, and is exactly how one of the * fixtures at Great River stores it (SNOMED 394814009, general practice, bare). Counting labels * therefore counted PRESENTATION, and a clinician stored properly was invisible to the count. */ specialtyKey: string; } export interface PractitionerRecommendation extends BookablePractitioner { rationale: string; confidence: number; crossLocation: boolean; } /** * Does the reason appear to name one of the practitioners this patient can actually book? * * Deterministic and local on purpose. It decides only whether the question is worth ASKING - never who * the answer is. The server's model does the matching, over a candidate list the server assembles * itself, and every id it returns is grounded back against `bookable` by mapPractitionerRankings. So a * false positive here costs one extra call, and a false negative leaves today's behaviour unchanged. */ export function reasonNamesABookablePractitioner( bookable: BookablePractitioner[], reasonText: string, ): boolean { return namedBy( bookable.map((b) => ({ id: b.practitionerRoleId, name: b.display })), reasonText, NAME_NOISE, ).length > 0; } /** * Two distinct specialties means there is a clinical choice to help with. A reason that names a * bookable practitioner means there is a choice to help with too - and that second door is the only * way a clinician whose specialty was never recorded can be suggested at all. Both still require two * bookable practitioners, because with one there is nothing to disambiguate. */ export function qualifiesForRecommendation( bookable: BookablePractitioner[], reasonText = '', ): boolean { if (bookable.length < 2) return false; // BY IDENTITY, NOT BY LABEL. This counted `specialtyDisplay`, which is empty for any specialty // stored as a bare coding - so two clinicians of genuinely different specialties produced a set of // ONE whenever either was recorded without a display string, and the recommendation never ran. // Measured at Great River: a Cardiovascular Disease Physician and a general practitioner, the second // held as SNOMED 394814009 with no display and no text. const specialties = new Set(bookable.map((b) => b.specialtyKey).filter(Boolean)); if (specialties.size >= 2) return true; return reasonNamesABookablePractitioner(bookable, reasonText); } export function mapPractitionerRankings( apiRankings: ApiPractitionerRanking[], bookable: BookablePractitioner[], opts: { minConfidence?: number; maxSuggestions?: number; selectedLocationId?: string; /** * Roles the self-booking guard will refuse - the patient already holds an upcoming appointment * with them. Supplied by the caller because only the booking page knows the patient's own * appointments; this function stays pure. * * THE ONE DEAD END THE ASSIST COULD WALK SOMEBODY INTO. Every other reason a practitioner is * unbookable has already removed them from `bookable` before they reach here, so grounding * against that list was enough. This one does not: the guard is a fact about the PATIENT, not * about the schedule, so the picker still offers them and the refusal only arrives on selection. * A recommendation is worse than the picker there - the picker merely lists them, the callout * points at them and says this is who you want. */ unbookableRoleIds?: ReadonlySet; } = {}, ): PractitionerRecommendation[] { const minConfidence = opts.minConfidence ?? 0; const maxSuggestions = opts.maxSuggestions ?? 3; const byId = new Map(bookable.map((b) => [b.practitionerRoleId, b] as const)); const out: PractitionerRecommendation[] = []; for (const r of apiRankings) { const b = byId.get(r.practitionerRoleId); if (!b) continue; // only practitioners the patient can actually book (drops hallucinated/stale ids) // Dropped rather than annotated: this callout's whole sentence is "this is who you want", and a // suggestion carrying its own refusal is not a suggestion. Dropping every candidate is safe - // the callout renders nothing on an empty list, which is the same fail-open the assist already // takes when the model errors or times out. if (opts.unbookableRoleIds?.has(r.practitionerRoleId)) continue; if (r.confidence < minConfidence) continue; out.push({ ...b, rationale: r.rationale, confidence: r.confidence, // An org-wide ("All locations") role has an EMPTY locationId - it practices everywhere INCLUDING the // selected site, so it is never "cross-location". Require a concrete site before flagging the switch, // else the callout would render "at - switch" (blank site) for a practitioner already at this site. crossLocation: !!opts.selectedLocationId && !!b.locationId && b.locationId !== opts.selectedLocationId, }); if (out.length >= maxSuggestions) break; } return out; }