mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
/**
|
|
* 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<string>;
|
|
/** 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<string> = 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<ReadonlyArray<string>> = [
|
|
["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<string, ReadonlyArray<string>> = (() => {
|
|
const map = new Map<string, string[]>();
|
|
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<string>;
|
|
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<SearchCandidate, "fields" | "contentWords">,
|
|
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<SearchCandidate, "fields" | "contentWords">,
|
|
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),
|
|
};
|
|
}
|