// Does the patient's reason for the visit NAME something they could have picked from a dropdown? // // "I need a routine checkup with Dr. Practitioner One" and "my knee hurts and I want to be seen at the // Ft Madison clinic" are whole sentences, and two thirds of each - who, and where - used to fall on the // floor. This answers that half of the question against lists the booking funnel already holds, so it // can never name a practitioner or a site the patient could not have chosen manually. // // Whole-word matching, never substring: "kitchen" contains "chen", and a burn is an ordinary reason for // a visit. const wordsOf = (text: string): string[] => (text.toLowerCase().match(/[a-z']+/g) ?? []).filter((word) => word.length >= 3); /** * The ids of the candidates the reason appears to name. * * `noise` is the words that appear in a name of this kind without distinguishing anybody: titles for * people, "clinic"/"medical"/"health" for sites. Without it, a reason containing "I need to see a * doctor" names every practitioner whose record carries the prefix, and "the medical center" names * every site. */ export function namedMatches( candidates: Array<{ id: string; name: string }>, reasonText: string, noise: ReadonlySet, ): Array<{ id: string; hits: number }> { const said = new Set(wordsOf(reasonText)); if (said.size === 0) return []; const out: Array<{ id: string; hits: number }> = []; for (const candidate of candidates) { // Distinct words: a name that repeats a word must not out-score one that does not. const distinctive = new Set(wordsOf(candidate.name).filter((part) => !noise.has(part))); let hits = 0; for (const part of distinctive) if (said.has(part)) hits += 1; if (hits > 0) out.push({ id: candidate.id, hits }); } return out; } /** The ids of every candidate the reason names, when how WELL each one matches does not matter. */ export function namedBy( candidates: Array<{ id: string; name: string }>, reasonText: string, noise: ReadonlySet, ): string[] { return namedMatches(candidates, reasonText, noise).map((m) => m.id); } /** Words that appear in a person's formatted name without identifying them. */ export const NAME_NOISE: ReadonlySet = new Set([ 'doctor', 'mrs', 'miss', 'prof', 'professor', 'phd', 'the', ]); /** Words that appear in a clinic's name without telling it apart from the practice's other clinics. */ export const SITE_NOISE: ReadonlySet = new Set([ 'clinic', 'clinics', 'center', 'centre', 'health', 'healthcare', 'medical', 'medicine', 'office', 'offices', 'practice', 'family', 'care', 'group', 'associates', 'partners', 'building', 'suite', 'campus', 'site', 'location', 'main', 'our', 'the', 'and', 'for', ]); /** * Which of the sites offering the chosen service did the reason name? * * Returns the id of exactly ONE, or ''. Two sites that share their only distinctive word cannot be told * apart by a name, and answering with either would send somebody to the wrong clinic - so a tie is not * an answer. "Madison South" beats "Madison North" for *madison south* because it matches more of the * name, and neither wins for *the madison clinic*. Pass only the sites the patient could actually book * at, so a pre-answer can never name a site the manual picker would refuse. */ export function reasonNamesALocation( locations: Array<{ id: string; display: string }>, reasonText: string, ): string { const matches = namedMatches( locations.map((l) => ({ id: l.id, name: l.display })), reasonText, SITE_NOISE, ); if (matches.length === 0) return ''; const best = Math.max(...matches.map((m) => m.hits)); const winners = matches.filter((m) => m.hits === best); return winners.length === 1 ? winners[0].id : ''; }