/** * Matching and ranking for the record search, shared by the MCP `search` tool * and the in-app search (`POST /api/search`). Pure: no database, no crypto. * * Folding. A field and a query are compared after the body-sites service's * folding (`normalizeSearchText`: Unicode compatibility form, marks stripped, * lower case, whitespace collapsed) plus the German spellings a keyboard * without umlauts produces: "ß" reads as "ss", and "ae", "oe", "ue" read as * "a", "o", "u". Both sides fold the same way, so "Schulter" finds * "schulter", "Hüfte" finds "huefte" and "Fuß" finds "fuss". * * Ranking. Every query word scores the best field it hits (a title, label, * site, reason or practitioner counts more than a kind word or a side, which * counts more than a note or an indexed-text hit). Results order by how many * query words they matched, then by score, then by a fixed kind order, then * newest first, then by id: the same query over the same record always * returns the same list, which is what the MCP offset cursor relies on. */ import { tokenise } from "@/lib/documents/content-index"; import { expandQueryTokens } from "@/lib/documents/search-synonyms"; import { normalizeSearchText } from "@/lib/encounters/procedures"; /** Query words that take part in matching; the rest are ignored. */ export const MAX_QUERY_WORDS = 8; /** Record results one MCP search can return in total, across its kinds. */ export const MAX_RECORD_RESULTS = 50; /** Field weights: what a query word hitting that field is worth. */ export const WEIGHT = { primary: 3, secondary: 2, tertiary: 1 } as const; export interface SearchField { text: string; weight: number; /** * Free text a person wrote (a note, a reason, an outcome). The in-app * search cuts its snippet from the first such field a query word hits. */ body?: boolean; } /** * What the in-app search shows for a candidate. The MCP wire ignores it; it * builds its own English title from the same row. */ export interface CandidateHit { /** The group the hit is listed under. */ kind: string; /** The record's own id (without the `kind:` prefix the MCP ids carry). */ recordId: string; /** The record's own name, or null when the kind's label names it. */ title: string | null; /** Short context: a practitioner, a body site, a dose. Plain values. */ context: string[]; /** The record's calendar day, `YYYY-MM-DD`, or null. */ day: string | null; /** In-app path that opens the record. */ path: string; /** For a note: which kind of entry it was written on. */ source?: string; } export interface SearchCandidate { id: string; title: string; url: string; /** Tie-break order between kinds; lower sorts first. */ kindOrder: number; /** Epoch ms of the record's own date, newest first on a tie. */ date: number | null; fields: SearchField[]; /** Query words the blind content index matched (documents only). */ contentWords?: ReadonlySet; /** True for the record kinds; bounds `MAX_RECORD_RESULTS`. */ record?: boolean; /** Display data for the in-app search. */ hit?: CandidateHit; } // ── Folding ────────────────────────────────────────────────────────── /** * The German keyboard spellings, folded onto the plain vowel. Applied to text * that `normalizeSearchText` already folded, so "ä" is "a" by now and only * the typed digraph is left to fold. */ function foldGerman(folded: string): string { return folded .replace(/ß/g, "ss") .replace(/ae/g, "a") .replace(/oe/g, "o") .replace(/ue/g, "u"); } /** The form a field and a query are compared in. */ export function foldForMatch(text: string): string { return foldGerman(normalizeSearchText(text)); } /** * Words the index tokeniser keeps but a search box does not need. The * tokeniser's own list already drops the common function words. */ const QUERY_STOPWORDS: ReadonlySet = new Set([ "mein", "meine", "meinen", "meinem", "meiner", "mine", "our", "all", "any", "some", "last", "latest", "recent", "letzte", "letzten", "letzter", "letztes", "show", "find", "list", "get", ]); /** * Body parts, German and English, for matching a body site or a label. A site * is typed in the owner's language and the question often arrives in another, * so "knee" has to find "Knie". Search-time only; the document vault's synonym * list is left as it is. Same normal form as the tokeniser: lower case, * accents stripped. */ const BODY_PART_GROUPS: ReadonlyArray> = [ ["knee", "knees", "knie", "kniegelenk"], ["shoulder", "shoulders", "schulter", "schultergelenk"], ["hip", "hips", "hufte", "huftgelenk"], ["ankle", "sprunggelenk", "knochel"], ["foot", "feet", "fuss"], ["hand", "hands", "hande"], ["wrist", "handgelenk"], ["elbow", "ellbogen", "ellenbogen"], ["back", "rucken", "wirbelsaule", "spine"], ["neck", "nacken", "hals"], ["head", "kopf"], ["eye", "eyes", "auge", "augen"], ["ear", "ears", "ohr", "ohren"], ["tooth", "teeth", "zahn", "zahne"], ["heart", "herz"], ["lung", "lungs", "lunge"], ["stomach", "magen"], ["bowel", "colon", "darm"], ["skin", "haut"], ["arm", "arms", "arme"], ["leg", "legs", "bein", "beine"], ["finger", "fingers"], ["toe", "toes", "zeh", "zehe", "zehen"], ["breast", "brust"], ["thyroid", "schilddruse"], ]; const BODY_PART_BY_WORD: ReadonlyMap> = (() => { const map = new Map(); for (const group of BODY_PART_GROUPS) { for (const word of group) map.set(word, [...group]); } return map; })(); /** A query word's alternatives: the vault's synonyms plus body parts. */ function synonymsOf(word: string): string[] { const out = new Set( [ ...expandQueryTokens([word]), ...(BODY_PART_BY_WORD.get(word) ?? BODY_PART_BY_WORD.get(foldGerman(word)) ?? []), ].map(foldGerman), ); out.delete(foldGerman(word)); return [...out]; } /** * The words of a query that take part in matching: folded, split, short and * filler words dropped, deduplicated, capped. These are also the words the * blind document index is asked for, so they keep the tokeniser's own form. */ export function queryWords(query: string): string[] { return tokenise(normalizeSearchText(query)) .filter((word) => !QUERY_STOPWORDS.has(word)) .slice(0, MAX_QUERY_WORDS); } interface PreparedField { folded: string; tokens: ReadonlySet; weight: number; } function prepareField(field: SearchField): PreparedField { const folded = foldForMatch(field.text); return { folded, tokens: new Set(folded.split(/[^\p{L}\p{N}]+/u).filter(Boolean)), weight: field.weight, }; } export interface CandidateScore { /** Query words that hit anything. */ matched: number; /** Sum of each matched word's best field weight, plus the phrase bonus. */ score: number; /** The whole query appears as typed inside one field. */ phrase: boolean; } /** Bonus for the whole query appearing inside one field. */ const PHRASE_BONUS = 5; /** Query words shorter than this match whole words or word starts only. */ export const MIN_SUBSTRING_WORD = 4; /** * Whether a query word hits a field by its own spelling. A word of four * letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has * to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm". */ function hitsField(field: PreparedField, word: string): boolean { if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word); for (const token of field.tokens) if (token.startsWith(word)) return true; return false; } /** * Score one candidate against the query. * * A word hits a field when the field contains it (so "knee" finds "Knee * replacement") or when one of its curated synonyms is a whole word of the * field (so "Zucker" finds "glucose"). Synonyms match whole words only: they * are wider than the word the person typed, and a substring match on them * would find "rate" inside "moderate". */ export function scoreCandidate( candidate: Pick, words: readonly string[], foldedQuery: string, ): CandidateScore { const fields = candidate.fields .filter((f) => f.text.trim().length > 0) .map(prepareField); const query = foldGerman(foldedQuery); let matched = 0; let score = 0; for (const word of words) { const folded = foldGerman(word); const synonyms = synonymsOf(word); let best = 0; for (const field of fields) { if (field.weight <= best) continue; if ( hitsField(field, folded) || synonyms.some((s) => field.tokens.has(s)) ) { best = field.weight; } } if (best < WEIGHT.tertiary && candidate.contentWords?.has(word)) { best = WEIGHT.tertiary; } if (best > 0) { matched += 1; score += best; } } const phrase = query.length > 0 && fields.some((field) => query.includes(" ") ? field.folded.includes(query) : hitsField(field, query), ); if (phrase) score += PHRASE_BONUS; return { matched, score, phrase }; } /** `scoreCandidate` for a raw query. */ export function scoreForQuery( candidate: Pick, query: string, ): CandidateScore { return scoreCandidate( candidate, queryWords(query), normalizeSearchText(query), ); } /** Whether a scored candidate stays in the result at all. */ export function isHit(score: CandidateScore): boolean { return score.matched > 0 || score.phrase; } /** * Filter and order candidates for one query. Pure and deterministic. * * An empty query keeps every candidate. Otherwise a candidate stays when a * query word hit it or the whole query appears in one of its fields (the * substring match the older search kinds were built on, kept so a short * query like "hr" still finds what it found before). */ export function rankCandidates( candidates: readonly SearchCandidate[], query: string, maxRecords: number = MAX_RECORD_RESULTS, ): SearchCandidate[] { const foldedQuery = normalizeSearchText(query); const words = queryWords(query); const scored = candidates.flatMap((candidate) => { if (!foldedQuery) { return [{ candidate, matched: 0, score: 0 }]; } const s = scoreCandidate(candidate, words, foldedQuery); if (!isHit(s)) return []; return [{ candidate, matched: s.matched, score: s.score }]; }); scored.sort( (a, b) => b.matched - a.matched || b.score - a.score || a.candidate.kindOrder - b.candidate.kindOrder || (b.candidate.date ?? 0) - (a.candidate.date ?? 0) || (a.candidate.id < b.candidate.id ? -1 : a.candidate.id > b.candidate.id ? 1 : 0), ); let records = 0; return scored.flatMap(({ candidate }) => { if (!candidate.record) return [candidate]; records += 1; return records <= maxRecords ? [candidate] : []; }); } // ── Highlighting and snippets ──────────────────────────────────────── /** A half-open `[start, end)` character range of the original text. */ export type MarkRange = [number, number]; export interface MarkedText { text: string; /** Ranges of `text` a query word matched, sorted and disjoint. */ marks: MarkRange[]; } /** * Fold `text` the way `foldForMatch` does, keeping for every folded character * the range of the original it came from. The two have different lengths * ("ß" becomes two characters, "ue" one), so a match found in the folded form * is mapped back through this table. */ function foldWithMap(text: string): { folded: string; from: number[]; to: number[]; } { // One entry per UTF-16 unit of the folded string, so an index `indexOf` // returns can be looked up directly, astral characters included. const units: string[] = []; const from: number[] = []; const to: number[] = []; // Whether the vowel at that position has already taken in its "e". const absorbed: boolean[] = []; let index = 0; for (const ch of text) { const end = index + ch.length; let piece = ch.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase(); if (/\s/u.test(ch)) piece = " "; piece = piece.replace(/ß/g, "ss"); for (const out of piece) { const prev = units.length - 1; // "ae", "oe", "ue" fold onto the vowel: the "e" widens the vowel's // source range instead of adding a character. if ( out === "e" && prev >= 0 && /[aou]/.test(units[prev]) && !absorbed[prev] ) { to[prev] = end; absorbed[prev] = true; continue; } for (let k = 0; k < out.length; k++) { units.push(out[k]); from.push(index); to.push(end); absorbed.push(false); } } index = end; } return { folded: units.join(""), from, to }; } /** * The ranges of `text` the query's words hit by their own spelling, under the * same rules as the matcher: a word of four letters or more anywhere, a * shorter one only at the start of a word. Synonym hits are not marked (the * text holds a different word), so a result can match with nothing marked. */ export function markMatches(text: string, query: string): MarkRange[] { // The matcher's words; a query too short for the tokeniser ("Hb") is // found by the phrase rule, so it is marked by its folded self. const tokens = queryWords(query); const words = [ ...new Set( (tokens.length > 0 ? tokens : foldForMatch(query).split(" ")).map((w) => foldGerman(w), ), ), ].filter((w) => w.length >= 2); if (words.length === 0 || text.length === 0) return []; const { folded, from, to } = foldWithMap(text); const ranges: MarkRange[] = []; for (const word of words) { let at = folded.indexOf(word); while (at !== -1) { const startsWord = at === 0 || !/[\p{L}\p{N}]/u.test(folded[at - 1]); if (word.length >= MIN_SUBSTRING_WORD || startsWord) { ranges.push([from[at], to[at + word.length - 1]]); } at = folded.indexOf(word, at + 1); } } ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]); const merged: MarkRange[] = []; for (const range of ranges) { const last = merged[merged.length - 1]; if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]); else merged.push([range[0], range[1]]); } return merged; } /** `text` with its marks for `query`. */ export function marked(text: string, query: string): MarkedText { return { text, marks: markMatches(text, query) }; } /** Characters a snippet holds at most, before the ellipses. */ export const SNIPPET_CHARS = 140; /** Characters a snippet keeps ahead of its first mark. */ const SNIPPET_LEAD = 40; /** * A short window of free text around its first match, whitespace collapsed, * cut at word boundaries and marked with "…" where it was cut. Null when the * query hits nothing in the text by its own spelling. */ export function snippetOf( raw: string, query: string, max: number = SNIPPET_CHARS, ): MarkedText | null { const text = raw.replace(/\s+/g, " ").trim(); const marks = markMatches(text, query); if (marks.length === 0) return null; if (text.length <= max) return { text, marks }; let start = Math.max(0, marks[0][0] - SNIPPET_LEAD); if (start > 0) { const space = text.indexOf(" ", start); if (space !== -1 && space < marks[0][0]) start = space + 1; } let end = Math.min(text.length, start + max); if (end < text.length) { const space = text.lastIndexOf(" ", end); if (space > marks[0][1]) end = space; } const lead = start > 0 ? "…" : ""; const tail = end < text.length ? "…" : ""; const shift = lead.length - start; return { text: `${lead}${text.slice(start, end)}${tail}`, marks: marks .filter(([s, e]) => s >= start && e <= end) .map(([s, e]) => [s + shift, e + shift] as MarkRange), }; }