// Pure mapper: turns raw reason-for-visit service suggestions from the server (which may // reference services that no longer exist, aren't bookable by this patient, or are simply // hallucinated by the matcher) into suggestions the booking UI can safely render and act on. // Never trust the API's `name` or `serviceId` for display/navigation - only ids present in the // caller-supplied bookable options are honored, and the DISPLAY always comes from that bookable // option, never from the API response. // The wire shape returned by POST /R4/booking/match-service. export interface ApiServiceSuggestion { serviceId: string; name: string; rationale: string; confidence: number; } // A suggestion the UI can render/select directly - guaranteed to correspond to a real, // currently-bookable service. export interface BookableSuggestion { id: string; display: string; rationale: string; confidence: number; } /** * HOW FAR IN FRONT THE TOP SUGGESTION HAS TO BE BEFORE THE PRODUCT WILL SAY IT PREFERS ONE. * * A rank is not a preference. The model returns a confidence for each candidate, so two services * at 0.71 and 0.70 arrive in an order, and drawing the first as RECOMMENDED would turn an * arithmetic accident into advice - on the one screen where a model's judgement reaches a patient * directly. A tie shown as a winner is worse than no badge at all. * * The margin is on the model's own 0..1 scale and the calls run at temperature zero, so the * numbers are stable enough to compare across a single answer. It is deliberately wide: the cost * of withholding a badge is that the patient reads two sentences, which is what they do today. */ export const RECOMMENDATION_MARGIN = 0.15; /** * Which suggestion the product will say it favours, or undefined when it will not say. * * Exported so the screens and their tests ask the same question rather than each deciding what a * recommendation is - two answers to that would be two different products on two screens. */ export const recommendedSuggestionId = ( suggestions: BookableSuggestion[], ): string | undefined => { if (suggestions.length === 0) return undefined; const [top, next] = suggestions; if (!next) return top.confidence > 0 ? top.id : undefined; return top.confidence - next.confidence >= RECOMMENDATION_MARGIN ? top.id : undefined; }; export function mapSuggestionsToBookableServices( apiSuggestions: ApiServiceSuggestion[], bookableOptions: { id: string; display: string }[], opts: { minConfidence?: number; maxSuggestions?: number } = {}, ): BookableSuggestion[] { const minConfidence = opts.minConfidence ?? 0; const maxSuggestions = opts.maxSuggestions ?? 5; const byId = new Map(bookableOptions.map((o) => [o.id, o] as const)); const out: BookableSuggestion[] = []; for (const s of apiSuggestions) { const bookable = byId.get(s.serviceId); if (!bookable) continue; // only services the patient can actually book (drops hallucinated/stale ids) if (s.confidence < minConfidence) continue; out.push({ id: bookable.id, display: bookable.display, rationale: s.rationale, confidence: s.confidence }); } // ORDERED HERE RATHER THAN TRUSTED FROM THE WIRE. The list used to be whatever order the server // returned, and the cap was applied while walking it - so a lower-confidence suggestion could // take the last slot from a higher one purely by arriving first. Sort, then cap. out.sort((a, b) => b.confidence - a.confidence); return out.slice(0, maxSuggestions); }