From 42ba4717654db1c2c6b92e44fbba2e599c063ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:20:15 +0200 Subject: [PATCH 1/6] refactor(search): share the record matcher and give record candidates display data --- src/lib/mcp/__tests__/record-search.test.ts | 4 +- src/lib/mcp/record-search.ts | 412 ++++++----------- src/lib/mcp/tools.ts | 4 +- src/lib/search/match.ts | 469 ++++++++++++++++++++ 4 files changed, 617 insertions(+), 272 deletions(-) create mode 100644 src/lib/search/match.ts diff --git a/src/lib/mcp/__tests__/record-search.test.ts b/src/lib/mcp/__tests__/record-search.test.ts index a7b804078..4d05457da 100644 --- a/src/lib/mcp/__tests__/record-search.test.ts +++ b/src/lib/mcp/__tests__/record-search.test.ts @@ -151,13 +151,13 @@ import { USER_TEXT_FENCE_END, USER_TEXT_FENCE_START, } from "@/lib/ai/coach/data-fence"; +import { MAX_DOCUMENT_EXCERPT_CHARS } from "../record-search"; import { - MAX_DOCUMENT_EXCERPT_CHARS, MAX_RECORD_RESULTS, queryWords, rankCandidates, type SearchCandidate, -} from "../record-search"; +} from "@/lib/search/match"; import type { McpAuthContext } from "../auth"; const ctx = (userId: string): McpAuthContext => ({ diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 38eaf0870..d41b3efe2 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -19,12 +19,9 @@ * on whole words of its text through the blind token index the vault search * uses. The document body itself is never read for a search. * - * 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 offset cursor relies on. + * Ranking and folding live in `@/lib/search/match`, shared with the in-app + * search (`GET /api/search`), which reads its record kinds through + * `loadRecordCandidates` here as well: one matcher, one set of record reads. * * Module switches. A record kind whose module the account has off (or the * operator switched off) is not read at all: no row, no link, no count. @@ -55,9 +52,7 @@ import { decryptIndexText, decryptVerbatimText, hashQueryTokens, - tokenise, } from "@/lib/documents/content-index"; -import { expandQueryTokens } from "@/lib/documents/search-synonyms"; import { normalizeSearchText } from "@/lib/encounters/procedures"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -71,15 +66,18 @@ import { customLookupOf, resolveVaccineEntry, } from "@/lib/vaccinations/resolve-vaccine-entry"; +import { + MAX_QUERY_WORDS, + MIN_SUBSTRING_WORD, + WEIGHT, + queryWords, + type SearchCandidate, +} from "@/lib/search/match"; // ── Bounds ─────────────────────────────────────────────────────────── /** Rows one search decrypts and matches, per record kind. */ export const MAX_ROWS_PER_KIND = 500; -/** Query words that take part in matching; the rest are ignored. */ -export const MAX_QUERY_WORDS = 8; -/** Record results one search can return in total, across the four kinds. */ -export const MAX_RECORD_RESULTS = 50; /** Newest records per kind listed when the query is empty. */ export const EMPTY_QUERY_PER_KIND = 10; /** Characters of indexed document text one `fetch` returns. */ @@ -125,251 +123,8 @@ async function readableKinds(userId: string): Promise> { return out; } -// ── Matching and ranking (pure) ────────────────────────────────────── - -/** 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; -} - -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 four record kinds; bounds `MAX_RECORD_RESULTS`. */ - record?: boolean; -} - -/** - * 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 the - * assistant's, so "knee" has to find "Knie". Search-time only and local to - * this module; 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) ?? []), - ]); - out.delete(word); - return [...out]; -} - -/** - * The words of a query that take part in matching: folded, split, short and - * filler words dropped, deduplicated, capped. - */ -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 = normalizeSearchText(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; - -/** - * 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". - */ -/** 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; -} - -export function scoreCandidate( - candidate: Pick, - words: readonly string[], - foldedQuery: string, -): CandidateScore { - const fields = candidate.fields - .filter((f) => f.text.trim().length > 0) - .map(prepareField); - let matched = 0; - let score = 0; - for (const word of words) { - const synonyms = synonymsOf(word); - let best = 0; - for (const field of fields) { - if (field.weight <= best) continue; - if (hitsField(field, word) || 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 = - foldedQuery.length > 0 && - fields.some((field) => - foldedQuery.includes(" ") - ? field.folded.includes(foldedQuery) - : hitsField(field, foldedQuery), - ); - if (phrase) score += PHRASE_BONUS; - return { matched, 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, -): 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 (s.matched === 0 && !s.phrase) 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 <= MAX_RECORD_RESULTS ? [candidate] : []; - }); -} +// ── Matching and ranking ───────────────────────────────────────────── +// Shared with the in-app search; see `@/lib/search/match`. // ── Labels ─────────────────────────────────────────────────────────── @@ -500,19 +255,51 @@ const KIND_ORDER: Record = { interface Owner { locale: Locale; timezone: string; + /** The language of the in-app display data (`SearchCandidate.hit`). */ + display: Locale; } -async function loadOwner(userId: string): Promise { +async function loadOwner(userId: string, display?: Locale): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, select: { locale: true, timezone: true }, }); + const locale = resolveLocale(row?.locale); return { - locale: resolveLocale(row?.locale), + locale, timezone: row?.timezone ?? DEFAULT_TIMEZONE, + display: display ?? locale, }; } +/** The in-app path of one record: `recordUrl` without an origin. */ +export function recordPath(kind: RecordKind, id: string): string { + return recordUrl("", kind, id); +} + +/** "left Knee" in the display language, or the site alone. */ +function sitePhrase( + site: string | null, + laterality: Laterality | null, + display: Locale, +): string | null { + if (!site) return null; + if (!laterality) return site; + const key = + laterality === "LEFT" + ? "encounters.laterality.left" + : laterality === "RIGHT" + ? "encounters.laterality.right" + : "encounters.laterality.both"; + return `${site} (${getServerTranslator(display).t(key)})`; +} + +function compact(values: ReadonlyArray): string[] { + return values + .map((v) => v?.replace(/\s+/g, " ").trim() ?? "") + .filter((v) => v.length > 0); +} + function visitTitle(args: { kind: EncounterKind; day: string; @@ -583,6 +370,8 @@ async function visitCandidates( const outcome = decryptText(row.outcomeEncrypted); const bodySite = decryptText(row.bodySiteEncrypted); const day = userDayKey(row.occurredAt, owner.timezone); + const kindLabel = encounterKindLabel(row.kind, owner.display); + const title = reason?.replace(/\s+/g, " ").trim() || null; return { id: `visit:${row.id}`, title: visitTitle({ @@ -597,9 +386,21 @@ async function visitCandidates( kindOrder: KIND_ORDER.visit, date: row.occurredAt.getTime(), record: true, + hit: { + kind: "visit", + recordId: row.id, + title: title ?? kindLabel, + context: compact([ + title ? kindLabel : null, + practitioner?.name, + sitePhrase(bodySite, row.laterality, owner.display), + ]), + day, + path: recordPath("visit", row.id), + }, fields: [ { text: bodySite ?? "", weight: WEIGHT.primary }, - { text: reason ?? "", weight: WEIGHT.primary }, + { text: reason ?? "", weight: WEIGHT.primary, body: true }, { text: practitioner?.name ?? "", weight: WEIGHT.primary }, { text: practitioner?.specialty ?? "", weight: WEIGHT.primary }, { @@ -616,7 +417,7 @@ async function visitCandidates( text: sideTerms(row.laterality, owner.locale) ?? "", weight: WEIGHT.secondary, }, - { text: outcome ?? "", weight: WEIGHT.secondary }, + { text: outcome ?? "", weight: WEIGHT.secondary, body: true }, ], }; }); @@ -696,6 +497,17 @@ async function conditionCandidates( kindOrder: KIND_ORDER.condition, date: row.onsetAt.getTime(), record: true, + hit: { + kind: "condition", + recordId: row.id, + title: row.label, + context: compact([ + illnessTypeLabel(row.type, owner.display), + sitePhrase(bodySite, row.laterality, owner.display), + ]), + day, + path: recordPath("condition", row.id), + }, fields: [ { text: row.label, weight: WEIGHT.primary }, { text: bodySite ?? "", weight: WEIGHT.primary }, @@ -736,6 +548,7 @@ const DOCUMENT_SELECT = { async function contentMatches( userId: string, words: readonly string[], + includeAiDeferred: boolean, ): Promise>> { const byDocument = new Map>(); await Promise.all( @@ -747,8 +560,12 @@ async function contentMatches( userId, searchTokens: { hasSome: hashes }, // A document held back from AI reading is not searched by its - // text over this wire either; its title and kind still are. - document: { deletedAt: null, aiReadDeferred: false }, + // text over MCP either; its title and kind still are. The person's + // own in-app search is not an AI surface and reads it. + document: { + deletedAt: null, + ...(includeAiDeferred ? {} : { aiReadDeferred: false }), + }, }, select: { documentId: true }, take: MAX_ROWS_PER_KIND, @@ -790,6 +607,7 @@ async function documentCandidates( take: number, query: string, words: readonly string[], + includeAiDeferred: boolean, ): Promise { const nameTerms = documentNameTerms(query, words); const [rows, content, named] = await Promise.all([ @@ -800,7 +618,7 @@ async function documentCandidates( take, }), words.length > 0 - ? contentMatches(userId, words) + ? contentMatches(userId, words, includeAiDeferred) : Promise.resolve(new Map>()), // Title and file name are plaintext columns, so an older document is // found by them in SQL, the way the vault list finds it. @@ -849,6 +667,22 @@ async function documentCandidates( kindOrder: KIND_ORDER.document, date: (dated ?? row.createdAt).getTime(), record: true, + hit: { + kind: "document", + recordId: row.id, + title: + row.title ?? + row.filename ?? + documentKindLabel(row.kind, owner.display), + context: compact([ + documentKindLabel(row.kind, owner.display), + row.title ? row.filename : null, + ]), + day: dated + ? dateOnly(dated) + : userDayKey(row.createdAt, owner.timezone), + path: recordPath("document", row.id), + }, contentWords: content.get(row.id), fields: [ { text: row.title ?? "", weight: WEIGHT.primary }, @@ -899,6 +733,19 @@ async function vaccinationCandidates( kindOrder: KIND_ORDER.vaccination, date: row.occurredAt.getTime(), record: true, + hit: { + kind: "vaccination", + recordId: row.id, + title: + row.vaccineName ?? + vaccineCatalogName(row.antigenSlug, owner.display) ?? + own?.name ?? + row.antigenSlug ?? + null, + context: compact([practitioner?.name]), + day, + path: recordPath("vaccination", row.id), + }, fields: [ { text: row.vaccineName ?? "", weight: WEIGHT.primary }, { @@ -927,29 +774,56 @@ async function vaccinationCandidates( }); } +export interface RecordSearchOptions { + /** + * The kinds to read. Defaults to the account's module switches; the in-app + * search passes its own set, narrowed by the module map and by the + * sections a delegate's grant covers. + */ + kinds?: ReadonlySet; + /** The language of `SearchCandidate.hit`; defaults to the owner's. */ + displayLocale?: Locale; + /** + * Match a document's indexed text even when it was held back from AI + * reading. Only the person's own in-app search sets it; MCP never does. + */ + includeAiDeferredText?: boolean; +} + /** * Every record candidate this account can see for one query. Unranked; the - * caller ranks them together with the older search kinds. + * caller ranks them together with its other kinds. */ export async function loadRecordCandidates( userId: string, query: string, origin: string, + options: RecordSearchOptions = {}, ): Promise { const words = queryWords(query); const empty = normalizeSearchText(query).length === 0; const take = empty ? EMPTY_QUERY_PER_KIND : MAX_ROWS_PER_KIND; const [kinds, owner] = await Promise.all([ - readableKinds(userId), - loadOwner(userId), + options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId), + loadOwner(userId, options.displayLocale), ]); const groups = await Promise.all([ - visitCandidates(userId, owner, origin, take, empty), + kinds.has("visit") + ? visitCandidates(userId, owner, origin, take, empty) + : Promise.resolve([]), kinds.has("condition") ? conditionCandidates(userId, owner, origin, take) : Promise.resolve([]), kinds.has("document") - ? documentCandidates(userId, owner, origin, take, query, words) + ? documentCandidates( + userId, + owner, + origin, + take, + query, + words, + options.includeAiDeferredText === true, + ) : Promise.resolve([]), kinds.has("vaccination") ? vaccinationCandidates(userId, owner, origin, take) diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index c81f80a45..771b10be5 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -90,10 +90,12 @@ import { fetchRecord, isRecordKind, loadRecordCandidates, +} from "@/lib/mcp/record-search"; +import { rankCandidates, WEIGHT as SEARCH_WEIGHT, type SearchCandidate, -} from "@/lib/mcp/record-search"; +} from "@/lib/search/match"; import type { McpAuthContext } from "./auth"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; diff --git a/src/lib/search/match.ts b/src/lib/search/match.ts new file mode 100644 index 000000000..7e04cd7ea --- /dev/null +++ b/src/lib/search/match.ts @@ -0,0 +1,469 @@ +/** + * Matching and ranking for the record search, shared by the MCP `search` tool + * and the in-app search (`GET /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 }; +} + +/** 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[]; +} { + const chars: 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 = chars.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(chars[prev]) && + !absorbed[prev] + ) { + to[prev] = end; + absorbed[prev] = true; + continue; + } + chars.push(out); + from.push(index); + to.push(end); + absorbed.push(false); + } + index = end; + } + return { folded: chars.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[] { + const words = [ + ...new Set(queryWords(query).map((w) => foldGerman(w))), + ].filter(Boolean); + 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), + }; +} From 1766ffa3d2b190bcaa860a4b9a30afc9512fa7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:27:28 +0200 Subject: [PATCH 2/6] feat(search): search the record from one endpoint --- docs/api/openapi.yaml | 194 +++- .../migration.sql | 16 + prisma/schema.prisma | 6 + src/__tests__/sharing-surface-guard.test.ts | 6 +- src/app/api/search/route.ts | 101 ++ .../__tests__/surface-module-registry.test.ts | 11 + src/lib/modules/surface.ts | 18 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/search.ts | 40 + src/lib/search/contract.ts | 82 ++ src/lib/search/search-records.ts | 949 ++++++++++++++++++ src/lib/search/sources.ts | 72 ++ src/lib/search/wire-schemas.ts | 76 ++ 14 files changed, 1564 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0397_measurements_noted_live_idx/migration.sql create mode 100644 src/app/api/search/route.ts create mode 100644 src/lib/openapi/routes/search.ts create mode 100644 src/lib/search/contract.ts create mode 100644 src/lib/search/search-records.ts create mode 100644 src/lib/search/sources.ts create mode 100644 src/lib/search/wire-schemas.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..505b99fae 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23677,6 +23677,51 @@ paths: "404": *a69 "422": *a4 "429": *a5 + /api/search: + get: + tags: + - Records + summary: Search the record + description: "One search over the record: visits and procedures, conditions, documents (title, file name and indexed + text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the + notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each + carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed + as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source + newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is + never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does + not open it, like the timeline." + parameters: + - in: query + name: q + schema: + type: string + minLength: 2 + maxLength: 100 + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + required: true + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + - in: query + name: limit + schema: + default: 5 + description: Most hits per group. + type: integer + minimum: 1 + maximum: 20 + description: Most hits per group. + responses: + "200": + description: The hits, grouped by kind. + content: + application/json: + schema: + $ref: "#/components/schemas/SearchEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -34384,15 +34429,15 @@ components: profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link - — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, - share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, - share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — - sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, - sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, - sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, - sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search — + search.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound, + share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, + share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; + sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, + sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, + sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; @@ -82204,6 +82249,137 @@ components: - data - error additionalProperties: false + SearchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SearchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SearchResponse: + type: object + properties: + groups: + type: array + items: + type: object + properties: + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + hits: + type: array + items: + $ref: "#/components/schemas/SearchHit" + more: + type: boolean + required: + - kind + - hits + - more + additionalProperties: false + truncated: + type: boolean + description: The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries + may match and are not listed. + required: + - groups + - truncated + additionalProperties: false + SearchHit: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + title: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + context: + type: array + items: + $ref: "#/components/schemas/SearchMarkedText" + snippet: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + day: + anyOf: + - type: string + - type: "null" + path: + type: string + description: In-app path that opens the record, never an API path. + source: + anyOf: + - type: string + enum: + - mood + - measurement + - illness + - type: "null" + required: + - id + - kind + - title + - context + - snippet + - day + - path + - source + additionalProperties: false + SearchMarkedText: + type: object + properties: + text: + type: string + marks: + type: array + items: + type: array + prefixItems: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Half-open [start, end) character ranges of `text` a query word matched. + required: + - text + - marks + additionalProperties: false __schema0: anyOf: - type: string diff --git a/prisma/migrations/0397_measurements_noted_live_idx/migration.sql b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql new file mode 100644 index 000000000..20711fc33 --- /dev/null +++ b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql @@ -0,0 +1,16 @@ +-- v1.42.2 — the record search reads the notes on readings newest first. +-- +-- A record can hold millions of readings and a handful of notes. Without an +-- index that holds only the noted rows, the search walks the account's whole +-- `(user_id, measured_at)` range to find them. This partial index holds the +-- live rows that carry a note (the encrypted column, or the legacy plaintext +-- one the boot backfill has not migrated yet), so the read is the handful. +-- +-- Additive only, no table rewrite, no data change. Non-concurrent for the +-- reason migration 0243 gives: Prisma Migrate runs a migration file in a +-- transaction, and the entrypoint applies migrations before the app serves +-- traffic. The predicate is tiny, so the build is short. +CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx" + ON "measurements" ("user_id", "measured_at" DESC, "id" DESC) + WHERE "deleted_at" IS NULL + AND ("notes_encrypted" IS NOT NULL OR "notes" IS NOT NULL); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..9741a7925 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2095,6 +2095,12 @@ model Measurement { // without this one the purge planned a sequential scan and could not finish // inside statement_timeout. Raw SQL only (partial predicate not expressible // in Prisma). + // `measurements_noted_live_idx` is a partial index over `(user_id, + // measured_at DESC, id DESC) WHERE deleted_at IS NULL AND (notes_encrypted + // IS NOT NULL OR notes IS NOT NULL)` — see migration 0397 (v1.42.2). The + // record search reads the notes on readings newest first; without it a + // record of millions of readings with a few notes walks every reading. + // Raw SQL only (partial predicate not expressible in Prisma). @@map("measurements") } diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b7b768b4b..50035b43b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1045,6 +1045,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.", }, + "app/api/search/route.ts": { + domain: "record", + why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -2094,7 +2098,7 @@ const ACTOR_ROUTES: Record = { * literal (a score's daily course, MANAGE like the derived score itself). * 252 -> 254. */ -const FROZEN_ENTRY_COUNT = 254; +const FROZEN_ENTRY_COUNT = 255; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 000000000..855c9007c --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,101 @@ +/** + * `GET /api/search?q=&limit=` — one search over the record (Discussion #1117): + * visits and procedures, conditions, documents (title and indexed text), + * vaccinations, lab results, medications, symptoms, workouts and the notes on + * mood entries, readings and illness days. Each hit carries the in-app path + * that opens it. The work is `searchRecords` in `src/lib/search/`. + * + * Admission declares the whole record, like the timeline: the search lays + * every section side by side, so a grant scoped to some of them does not open + * it. The sources are narrowed to the grant's sections all the same + * (`actingDomainVisibility`), and to the record's module switches, so a + * switched-off module is not read at all. + * + * The query is health data ("Schulter", a drug, a diagnosis). It never + * reaches a log: the annotation carries its length and the counts only, and a + * refused query is answered without echoing it. + * + * Rate limited per actor: the palette asks once per pause in typing, and a + * manager searching someone's record spends their own allowance. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { searchRecords } from "@/lib/search/search-records"; +import { searchQuerySchema } from "@/lib/search/wire-schemas"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +/** Searches per actor per minute: a few per typed word, with room to spare. */ +const SEARCH_RATE_LIMIT = 60; +const SEARCH_RATE_WINDOW_MS = 60_000; + +function displayLocale(value: string | null | undefined): Locale { + return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user, actor, grantId } = await requireRecordAuth("read", "record"); + + const rl = await checkRateLimit( + `search:${actor.id}`, + SEARCH_RATE_LIMIT, + SEARCH_RATE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many searches. Please retry shortly.", 429); + } + + const params = new URL(request.url).searchParams; + const parsed = searchQuerySchema.safeParse({ + q: params.get("q") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + annotate({ + action: { name: "search.query.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "search.invalid", + }); + } + + const [modules, domainVisible, timezone] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const result = await searchRecords({ + recordId: user.id, + query: parsed.data.q, + limit: parsed.data.limit, + modules, + domainVisible, + displayLocale: displayLocale(actor.locale), + timezone, + }); + + annotate({ + action: { name: "search.query.run" }, + meta: { + query_length: parsed.data.q.length, + limit: parsed.data.limit, + sources: result.stats.sources, + rows_scanned: result.stats.rows, + matches: result.stats.candidates, + groups: result.groups.length, + hits: result.groups.reduce((n, g) => n + g.hits.length, 0), + truncated: result.truncated, + elapsed_ms: result.stats.elapsedMs, + delegated: grantId !== null, + }, + }); + return apiSuccess({ groups: result.groups, truncated: result.truncated }); +}); diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index a73018ea0..a4eaa2f23 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -68,6 +68,7 @@ import { import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select"; import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared"; import { DAY_SECTION_KEYS, TIMELINE_LANE_KEYS } from "@/lib/day/contract"; +import { SEARCH_SOURCE_KEYS } from "@/lib/search/sources"; import { SURFACE_KINDS, SURFACE_MODULE, @@ -171,6 +172,13 @@ describe("every surface id names a surface that exists", () => { } }); + it("search: a source the record search reads", () => { + const keys = new Set(SEARCH_SOURCE_KEYS); + for (const id of localIds("search")) { + expect(keys.has(id), id).toBe(true); + } + }); + it("correlation: a discovery channel, a weather field or the lab pass", () => { const channels = new Set([ ...DISCOVERY_BEHAVIOURS, @@ -289,6 +297,9 @@ describe("every surface that shows a module's data is owned by it", () => { ["day-section:lifeEvents", "timeline"], ["day-section:symptoms", "illness"], ["timeline-lane:documents", "inboundDocuments"], + ["search:document", "inboundDocuments"], + ["search:moodNote", "mood"], + ["search:symptomNote", "illness"], ])("%s is owned by %s", (id, owner) => { expect(surfaceModule(id)).toBe(owner); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 81df1d95f..21eb5a660 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -52,6 +52,8 @@ import type { ModuleKey } from "@/lib/modules/registry"; * day loader blanks a section whose owner is off, and the views drop it. * - `timeline-lane`: a lane of the timeline, by its `TimelineLaneKey`; the * readiness inventory leaves it out the same way. + * - `search`: a source the in-app record search reads, by its + * `SearchSourceKey`. A source whose owner is off is not read at all. */ export const SURFACE_KINDS = [ "nav", @@ -68,6 +70,7 @@ export const SURFACE_KINDS = [ "score-ring", "day-section", "timeline-lane", + "search", ] as const; export type SurfaceKind = (typeof SURFACE_KINDS)[number]; @@ -247,6 +250,21 @@ const STATIC_SURFACE_MODULE = { "timeline-lane:labs": "labs", "timeline-lane:documents": "inboundDocuments", "timeline-lane:cycle": "cycle", + + // ── Record search sources (v1.42.2) ── + // Visits are core. Notes on readings follow the owner of the reading's + // type and are filtered item by item, like the timeline's `life` lane. + "search:condition": "illness", + "search:document": "inboundDocuments", + "search:vaccination": "vaccinations", + "search:labAnalyte": "labs", + "search:labNote": "labs", + "search:medication": "medications", + "search:symptom": "illness", + "search:symptomNote": "illness", + "search:workout": "workouts", + "search:moodNote": "mood", + "search:illnessNote": "illness", } as const satisfies Record; /** A surface id the map declares literally. */ diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index e0ae588ad..8948915f3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -342,6 +342,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "report-selection.leaves.unknown", ], route: ["route.retired"], + search: ["search.invalid"], "share-link": [ "share-link.document.decryptFailed", "share-link.document.notFound", diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 03be40829..6f870b2ff 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -49,6 +49,7 @@ import { dailyPaths } from "./daily"; import { cyclePaths } from "./cycle"; import { dashboardWidgetPaths } from "./dashboard"; import { dayPaths } from "./day"; +import { searchPaths } from "./search"; import { biomarkerPaths } from "./biomarkers"; import { inboundDocumentPaths } from "./documents"; import { documentSourcePaths } from "./document-sources"; @@ -185,6 +186,7 @@ export const openApiPaths: NonNullable = { // v1.42 — the day view, the timeline and life events (appended, spread // order is load-bearing). ...dayPaths, + ...searchPaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). // diff --git a/src/lib/openapi/routes/search.ts b/src/lib/openapi/routes/search.ts new file mode 100644 index 000000000..4de58c029 --- /dev/null +++ b/src/lib/openapi/routes/search.ts @@ -0,0 +1,40 @@ +/** + * OpenAPI route table for the in-app record search (`/api/search`, v1.42.2, + * Discussion #1117). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The query and + * response schemas are the runtime ones from `@/lib/search/wire-schemas`, so + * the published contract is the parser's own. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + searchQuerySchema, + searchResponseSchema, +} from "@/lib/search/wire-schemas"; + +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; + +export const searchPaths: NonNullable = { + "/api/search": { + get: { + tags: ["Records"], + summary: "Search the record", + description: + "One search over the record: visits and procedures, conditions, documents (title, file name and indexed text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does not open it, like the timeline.", + requestParams: { query: searchQuerySchema }, + responses: { + "200": { + description: "The hits, grouped by kind.", + content: { + "application/json": { + schema: dataEnvelope(searchResponseSchema, "SearchEnvelope"), + }, + }, + }, + ...stdResponses, + ...recordRefusal(), + }, + }, + }, +}; diff --git a/src/lib/search/contract.ts b/src/lib/search/contract.ts new file mode 100644 index 000000000..40be764de --- /dev/null +++ b/src/lib/search/contract.ts @@ -0,0 +1,82 @@ +/** + * The in-app record search (`GET /api/search`): its vocabulary and wire + * types. Pure constants and types, no validator, so the command palette + * imports this file and nothing server-side. The Zod schemas the route parses + * with and the OpenAPI table publishes live in `./wire-schemas.ts`. + */ + +/** + * The groups a hit is listed under, in the order the palette falls back to + * when two groups' best hits tie. + */ +export const SEARCH_HIT_KINDS = [ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication", + "symptom", + "workout", + "note", +] as const; + +export type SearchHitKind = (typeof SEARCH_HIT_KINDS)[number]; + +/** The entry a free-text note was written on. */ +export const SEARCH_NOTE_SOURCES = ["mood", "measurement", "illness"] as const; + +export type SearchNoteSource = (typeof SEARCH_NOTE_SOURCES)[number]; + +/** Shortest query the server searches for. */ +export const SEARCH_QUERY_MIN = 2; +/** Longest query the server accepts. */ +export const SEARCH_QUERY_MAX = 100; +/** Hits per group when the caller names no limit. */ +export const SEARCH_LIMIT_DEFAULT = 5; +/** Most hits per group a caller can ask for. */ +export const SEARCH_LIMIT_MAX = 20; + +/** A half-open `[start, end)` character range of `text`. */ +export type SearchMarkRange = [number, number]; + +/** Text with the ranges a query word matched. Plain text, never markup. */ +export interface SearchMarkedText { + text: string; + marks: SearchMarkRange[]; +} + +export interface SearchHit { + /** Unique within one answer: `:`. */ + id: string; + kind: SearchHitKind; + /** The record's own name, or null when the kind's label names it. */ + title: SearchMarkedText | null; + /** Short context values: a practitioner, a body site, a dose. */ + context: SearchMarkedText[]; + /** The part of the record's free text the query hit, or null. */ + snippet: SearchMarkedText | null; + /** 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: the entry it was written on. Null for every other kind. */ + source: SearchNoteSource | null; +} + +export interface SearchGroup { + kind: SearchHitKind; + hits: SearchHit[]; + /** More hits of this kind matched than the limit lets through. */ + more: boolean; +} + +export interface SearchResponse { + /** Groups with at least one hit, the best group first. */ + groups: SearchGroup[]; + /** + * The search stopped before it had read every entry (the per-kind row cap + * or the time budget), so older entries may match and are not listed. + */ + truncated: boolean; +} diff --git a/src/lib/search/search-records.ts b/src/lib/search/search-records.ts new file mode 100644 index 000000000..6bfecc0dc --- /dev/null +++ b/src/lib/search/search-records.ts @@ -0,0 +1,949 @@ +/** + * The in-app record search behind `GET /api/search` (Discussion #1117): one + * query over visits, conditions, documents, vaccinations, lab results, + * medications, symptoms, workouts and the free-text notes on mood entries, + * readings and illness days, each hit with the in-app path that opens it. + * + * Most of the record is ciphertext at rest, so matching cannot run in SQL. + * The search reads the record's own rows newest first, decrypts them here and + * matches them with the shared matcher (`./match.ts`, the one the MCP + * `search` tool uses). The four clinical kinds come from the MCP module's own + * reads (`loadRecordCandidates`); the rest are read here in batches. + * + * Bounds, so a large record cannot take the heap or the pool down: + * + * - every batched source reads at most `MAX_SCAN_ROWS_PER_SOURCE` rows, in + * batches of `SCAN_BATCH`, and stops as soon as it holds more hits than + * the caller's limit (newest first, so those are the newest matches); + * - one search shares a time budget (`SEARCH_TIME_BUDGET_MS`); a source + * checks it before every batch and stops when it has run out; + * - at most `SOURCE_CONCURRENCY` sources read at once, so one search holds + * a few pool connections, not one per table; + * - only matching rows are kept: a batch's decrypted text is dropped once + * the batch is matched. + * + * When a cap or the budget cut a source short the answer says `truncated`. + * + * Plaintext columns that are indexable (a lab's analyte, a medication's name, + * a workout's sport) are read whole and matched here as well, so the folding + * is the same for every kind: "Schulter", "schulter" and "Schülter" are one + * word to the search whichever column holds it. + * + * Nothing here logs the query or any record text. The route annotates the + * query's length and the counts only. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { readNote } from "@/lib/crypto/note-cipher"; +import { prisma } from "@/lib/db"; +import { + decryptIndexText, + decryptVerbatimText, +} from "@/lib/documents/content-index"; +import { normalizeSearchText } from "@/lib/encounters/procedures"; +import type { Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { + loadRecordCandidates, + MAX_ROWS_PER_KIND, + type RecordKind, +} from "@/lib/mcp/record-search"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; +import type { ShareScope } from "@/lib/sharing/scope"; +import { userDayKey } from "@/lib/tz/format"; + +import { + SEARCH_HIT_KINDS, + type SearchGroup, + type SearchHit, + type SearchHitKind, + type SearchMarkedText, + type SearchNoteSource, + type SearchResponse, +} from "./contract"; +import { + isHit, + marked, + queryWords, + scoreCandidate, + snippetOf, + WEIGHT, + type SearchCandidate, +} from "./match"; +import { + SEARCH_SOURCE_DOMAIN, + SEARCH_SOURCE_KEYS, + SEARCH_SOURCE_KIND, + type SearchSourceKey, +} from "./sources"; + +// ── Bounds ─────────────────────────────────────────────────────────── + +/** Rows one batched source reads per round trip. */ +export const SCAN_BATCH = 250; +/** Rows one batched source reads at most, per search. */ +export const MAX_SCAN_ROWS_PER_SOURCE = 2000; +/** Wall-clock budget one search shares across its sources. */ +export const SEARCH_TIME_BUDGET_MS = 1500; +/** Sources that read at the same time. */ +export const SOURCE_CONCURRENCY = 4; +/** Plaintext name rows (analytes, medications, symptom names) read at most. */ +const MAX_NAME_ROWS = 1000; +/** Documents whose indexed text is opened for a snippet, per answer. */ +const MAX_DOCUMENT_SNIPPETS = 5; + +// ── The scan ───────────────────────────────────────────────────────── + +/** Shared by every source of one search. */ +export interface ScanBudget { + readonly deadline: number; + readonly now: () => number; + /** A cap or the deadline stopped a source before it read everything. */ + truncated: boolean; + /** Rows read, for the annotation. */ + rows: number; +} + +/** + * Read a source newest first in batches until it is exhausted, holds more + * than `wanted` hits, has read `maxRows` rows or the budget has run out. + * `read` pages by the last row's id (Prisma cursor over a stable order); + * `match` turns a row into a candidate that is a hit, or null. + */ +export async function scanNewest(args: { + budget: ScanBudget; + wanted: number; + maxRows?: number; + batch?: number; + read: (cursor: string | null, take: number) => Promise; + match: (row: Row) => SearchCandidate | null; +}): Promise { + const { budget, wanted, read, match } = args; + const maxRows = args.maxRows ?? MAX_SCAN_ROWS_PER_SOURCE; + const batch = args.batch ?? SCAN_BATCH; + const out: SearchCandidate[] = []; + let cursor: string | null = null; + let scanned = 0; + for (;;) { + if (budget.now() >= budget.deadline) { + budget.truncated = true; + break; + } + const take = Math.min(batch, maxRows - scanned); + const rows = await read(cursor, take); + scanned += rows.length; + budget.rows += rows.length; + for (const row of rows) { + const candidate = match(row); + if (candidate) out.push(candidate); + } + // A short batch is the end of the source. + if (rows.length < take) break; + // One more than the limit: the caller learns there is more. + if (out.length > wanted) break; + if (scanned >= maxRows) { + budget.truncated = true; + break; + } + cursor = rows[rows.length - 1].id; + } + return out; +} + +/** Prisma's keyset arguments for `scanNewest`. */ +function after(cursor: string | null): { + cursor?: { id: string }; + skip?: number; +} { + return cursor ? { cursor: { id: cursor }, skip: 1 } : {}; +} + +/** Run `tasks` with at most `limit` in flight. */ +async function inPool( + tasks: ReadonlyArray<() => Promise>, + limit: number, +): Promise { + let next = 0; + const worker = async () => { + while (next < tasks.length) { + const task = tasks[next++]; + await task(); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, tasks.length) }, worker), + ); +} + +// ── Sources ────────────────────────────────────────────────────────── + +interface SearchContext { + recordId: string; + query: string; + words: readonly string[]; + foldedQuery: string; + wanted: number; + timezone: string; + display: Locale; + modules: SurfaceModuleMap | null; + budget: ScanBudget; +} + +/** Decrypt a free-text `Bytes` column fail-soft: a key gap reads as absent. */ +function decryptText(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + try { + const text = decryptFromBytes(value); + return text.trim() ? text : null; + } catch { + return null; + } +} + +/** `readNote` fail-soft, for the two columns still carrying a plaintext twin. */ +function readNoteSoft( + ciphertext: Uint8Array | null, + plaintext: string | null, +): string | null { + try { + const text = readNote(ciphertext, plaintext); + return text?.trim() ? text : null; + } catch { + return null; + } +} + +/** The candidate when it is a hit for the context's query, else null. */ +function keepIfHit( + ctx: SearchContext, + candidate: SearchCandidate, +): SearchCandidate | null { + return isHit(scoreCandidate(candidate, ctx.words, ctx.foldedQuery)) + ? candidate + : null; +} + +function noteCandidate(args: { + id: string; + source: SearchSourceKey; + recordId: string; + title: string | null; + note: string; + date: Date | null; + day: string | null; + path: string; + noteSource?: SearchNoteSource; +}): SearchCandidate { + return { + id: args.id, + title: args.title ?? "", + url: args.path, + kindOrder: 0, + date: args.date?.getTime() ?? null, + fields: [{ text: args.note, weight: WEIGHT.tertiary, body: true }], + hit: { + kind: SEARCH_SOURCE_KIND[args.source], + recordId: args.recordId, + title: args.title, + context: [], + day: args.day, + path: args.path, + ...(args.noteSource ? { source: args.noteSource } : {}), + }, + }; +} + +async function labAnalyteCandidates( + ctx: SearchContext, +): Promise { + const rows = await prisma.labResult.groupBy({ + by: ["analyte"], + where: { userId: ctx.recordId, deletedAt: null }, + _max: { takenAt: true }, + orderBy: { _max: { takenAt: "desc" } }, + take: MAX_NAME_ROWS, + }); + ctx.budget.rows += rows.length; + return rows.flatMap((row) => { + const latest = row._max.takenAt; + const path = `/labs?analyte=${encodeURIComponent(row.analyte)}`; + const candidate = keepIfHit(ctx, { + id: `labAnalyte:${row.analyte}`, + title: row.analyte, + url: path, + kindOrder: 0, + date: latest?.getTime() ?? null, + fields: [{ text: row.analyte, weight: WEIGHT.primary }], + hit: { + kind: "lab", + recordId: row.analyte, + title: row.analyte, + context: [], + day: latest ? userDayKey(latest, ctx.timezone) : null, + path, + }, + }); + return candidate ? [candidate] : []; + }); +} + +function labNoteCandidates(ctx: SearchContext): Promise { + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.labResult.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + noteEncrypted: { not: null }, + }, + select: { id: true, analyte: true, takenAt: true, noteEncrypted: true }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = decryptText(row.noteEncrypted); + if (!note) return null; + return keepIfHit( + ctx, + noteCandidate({ + id: `labNote:${row.id}`, + source: "labNote", + recordId: row.id, + title: row.analyte, + note, + date: row.takenAt, + day: userDayKey(row.takenAt, ctx.timezone), + path: `/labs?analyte=${encodeURIComponent(row.analyte)}`, + }), + ); + }, + }); +} + +async function medicationCandidates( + ctx: SearchContext, +): Promise { + const rows = await prisma.medication.findMany({ + where: { userId: ctx.recordId }, + select: { id: true, name: true, dose: true, createdAt: true }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + take: MAX_NAME_ROWS, + }); + ctx.budget.rows += rows.length; + return rows.flatMap((row) => { + const path = `/medications/${encodeURIComponent(row.id)}`; + const candidate = keepIfHit(ctx, { + id: `medication:${row.id}`, + title: row.name, + url: path, + kindOrder: 0, + date: row.createdAt.getTime(), + fields: [ + { text: row.name, weight: WEIGHT.primary }, + { text: row.dose, weight: WEIGHT.secondary }, + ], + hit: { + kind: "medication", + recordId: row.id, + title: row.name, + context: row.dose.trim() ? [row.dose.trim()] : [], + day: null, + path, + }, + }); + return candidate ? [candidate] : []; + }); +} + +/** The record's symptom names, decrypted once per search. */ +function symptomLabels( + ctx: SearchContext, +): () => Promise> { + let pending: Promise> | null = null; + return () => { + pending ??= prisma.symptomDefinition + .findMany({ + where: { userId: ctx.recordId }, + select: { id: true, labelEncrypted: true }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + take: MAX_NAME_ROWS, + }) + .then((rows) => { + ctx.budget.rows += rows.length; + return new Map( + rows.map((row) => [row.id, decryptText(row.labelEncrypted)]), + ); + }); + return pending; + }; +} + +async function symptomCandidates( + ctx: SearchContext, + labels: () => Promise>, +): Promise { + const byId = await labels(); + return [...byId].flatMap(([id, label]) => { + if (!label) return []; + const path = `/illness?symptom=${encodeURIComponent(id)}`; + const candidate = keepIfHit(ctx, { + id: `symptom:${id}`, + title: label, + url: path, + kindOrder: 0, + date: null, + fields: [{ text: label, weight: WEIGHT.primary }], + hit: { + kind: "symptom", + recordId: id, + title: label, + context: [], + day: null, + path, + }, + }); + return candidate ? [candidate] : []; + }); +} + +async function symptomNoteCandidates( + ctx: SearchContext, + labels: () => Promise>, +): Promise { + const byId = await labels(); + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.symptomEvent.findMany({ + where: { userId: ctx.recordId, noteEncrypted: { not: null } }, + select: { + id: true, + definitionId: true, + occurredAt: true, + noteEncrypted: true, + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = decryptText(row.noteEncrypted); + if (!note) return null; + const day = userDayKey(row.occurredAt, ctx.timezone); + return keepIfHit( + ctx, + noteCandidate({ + id: `symptomNote:${row.id}`, + source: "symptomNote", + recordId: row.id, + title: byId.get(row.definitionId) ?? null, + note, + date: row.occurredAt, + day, + path: `/illness?day=${day}`, + }), + ); + }, + }); +} + +/** The sport's name in the display language, else its words. */ +function sportLabel(sportType: string, locale: Locale): string { + const key = `insights.workouts.sport.${sportType}`; + const label = getServerTranslator(locale).t(key); + return label === key ? sportType.toLowerCase().replace(/_/g, " ") : label; +} + +async function workoutCandidates( + ctx: SearchContext, +): Promise { + const types = await prisma.workout.groupBy({ + by: ["sportType"], + where: { userId: ctx.recordId }, + }); + ctx.budget.rows += types.length; + // A sport matches by its name in the display language, in English and as + // its enum words, so "laufen", "running" and "run" all find a run. + const matched = types + .map((t) => t.sportType) + .filter((sportType) => + keepIfHit(ctx, { + id: sportType, + title: sportType, + url: "", + kindOrder: 0, + date: null, + fields: [ + { + text: [ + sportLabel(sportType, ctx.display), + sportLabel(sportType, "en"), + sportType.toLowerCase().replace(/_/g, " "), + ].join(" "), + weight: WEIGHT.primary, + }, + ], + }), + ); + if (matched.length === 0) return []; + const rows = await prisma.workout.findMany({ + where: { userId: ctx.recordId, sportType: { in: matched } }, + select: { id: true, sportType: true, startedAt: true }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], + take: ctx.wanted + 1, + }); + ctx.budget.rows += rows.length; + return rows.map((row) => { + const path = `/insights/workouts/${encodeURIComponent(row.id)}`; + const label = sportLabel(row.sportType, ctx.display); + return { + id: `workout:${row.id}`, + title: label, + url: path, + kindOrder: 0, + date: row.startedAt.getTime(), + fields: [ + { + text: [ + label, + sportLabel(row.sportType, "en"), + row.sportType.toLowerCase().replace(/_/g, " "), + ].join(" "), + weight: WEIGHT.primary, + }, + ], + hit: { + kind: "workout", + recordId: row.id, + title: label, + context: [], + day: userDayKey(row.startedAt, ctx.timezone), + path, + }, + }; + }); +} + +function moodNoteCandidates(ctx: SearchContext): Promise { + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.moodEntry.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }], + }, + select: { + id: true, + date: true, + moodLoggedAt: true, + note: true, + noteEncrypted: true, + }, + orderBy: [{ moodLoggedAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = readNoteSoft(row.noteEncrypted, row.note); + if (!note) return null; + return keepIfHit( + ctx, + noteCandidate({ + id: `moodNote:${row.id}`, + source: "moodNote", + recordId: row.id, + title: null, + note, + date: row.moodLoggedAt, + day: row.date, + path: `/mood?day=${row.date}`, + noteSource: "mood", + }), + ); + }, + }); +} + +function illnessNoteCandidates(ctx: SearchContext): Promise { + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.illnessDayLog.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + noteEncrypted: { not: null }, + episode: { deletedAt: null }, + }, + select: { + id: true, + date: true, + noteEncrypted: true, + episode: { select: { id: true, label: true } }, + }, + orderBy: [{ date: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = decryptText(row.noteEncrypted); + if (!note) return null; + return keepIfHit( + ctx, + noteCandidate({ + id: `illnessNote:${row.id}`, + source: "illnessNote", + recordId: row.id, + title: row.episode.label, + note, + // The day log's own calendar date; noon keeps the sort stable. + date: new Date(`${row.date}T12:00:00Z`), + day: row.date, + path: `/illness/${encodeURIComponent(row.episode.id)}`, + noteSource: "illness", + }), + ); + }, + }); +} + +function measurementNoteCandidates( + ctx: SearchContext, +): Promise { + // A note on a reading follows the module of the reading's type. + const typeVisible = (type: MeasurementType): boolean => { + const owner = moduleForMeasurementType(type); + return owner === null || ctx.modules?.[owner] !== false; + }; + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + // Served by the partial index `measurements_noted_live_idx` (migration + // 0397): only live rows that carry a note, so a record of millions of + // readings with a handful of notes reads the handful. + read: (cursor, take) => + prisma.measurement.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + OR: [{ notesEncrypted: { not: null } }, { notes: { not: null } }], + }, + select: { + id: true, + type: true, + measuredAt: true, + notes: true, + notesEncrypted: true, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + if (!typeVisible(row.type)) return null; + const note = readNoteSoft(row.notesEncrypted, row.notes); + if (!note) return null; + const day = userDayKey(row.measuredAt, ctx.timezone); + return keepIfHit( + ctx, + noteCandidate({ + id: `measurementNote:${row.id}`, + source: "measurementNote", + recordId: row.id, + title: null, + note, + date: row.measuredAt, + day, + path: `/measurements?day=${day}`, + noteSource: "measurement", + }), + ); + }, + }); +} + +// ── Ranking and the answer ─────────────────────────────────────────── + +const RECORD_SOURCES: ReadonlyArray = [ + "visit", + "condition", + "document", + "vaccination", +]; + +interface Ranked { + candidate: SearchCandidate; + matched: number; + titleHit: boolean; + score: number; +} + +function compareRanked(a: Ranked, b: Ranked): number { + return ( + b.matched - a.matched || + Number(b.titleHit) - Number(a.titleHit) || + b.score - a.score || + (b.candidate.date ?? 0) - (a.candidate.date ?? 0) || + (a.candidate.id < b.candidate.id + ? -1 + : a.candidate.id > b.candidate.id + ? 1 + : 0) + ); +} + +function markedOrNull( + text: string | null, + query: string, +): SearchMarkedText | null { + return text ? marked(text, query) : null; +} + +/** The snippet: the first free-text field the query hit, unless it is the title. */ +function snippetFor( + candidate: SearchCandidate, + query: string, +): SearchMarkedText | null { + const title = candidate.hit?.title ?? null; + for (const field of candidate.fields) { + if (!field.body || !field.text.trim()) continue; + if (title !== null && field.text.trim() === title.trim()) continue; + const snippet = snippetOf(field.text, query); + if (snippet) return snippet; + } + return null; +} + +function toHit(candidate: SearchCandidate, query: string): SearchHit { + const hit = candidate.hit!; + return { + id: candidate.id, + kind: hit.kind as SearchHitKind, + title: markedOrNull(hit.title, query), + context: hit.context.map((value) => marked(value, query)), + snippet: snippetFor(candidate, query), + day: hit.day, + path: hit.path, + source: (hit.source as SearchNoteSource | undefined) ?? null, + }; +} + +/** + * A snippet of the indexed text for the listed documents that matched by + * their text alone. Bounded: the few listed documents, never the vault. + */ +async function addDocumentSnippets( + recordId: string, + hits: SearchHit[], + query: string, +): Promise { + const wanting = hits + .filter( + (hit) => + hit.kind === "document" && + hit.snippet === null && + (hit.title?.marks.length ?? 0) === 0, + ) + .slice(0, MAX_DOCUMENT_SNIPPETS); + if (wanting.length === 0) return; + const byDocument = new Map( + wanting.map((hit) => [hit.id.slice("document:".length), hit]), + ); + const rows = await prisma.documentContentIndex.findMany({ + where: { userId: recordId, documentId: { in: [...byDocument.keys()] } }, + select: { + documentId: true, + textEncrypted: true, + verbatimTextEncrypted: true, + }, + }); + for (const row of rows) { + const hit = byDocument.get(row.documentId); + if (!hit) continue; + try { + const text = + row.verbatimTextEncrypted && row.verbatimTextEncrypted.byteLength > 0 + ? decryptVerbatimText(row.verbatimTextEncrypted) + : decryptIndexText(row.textEncrypted); + hit.snippet = snippetOf(text, query); + } catch { + // A key gap leaves the hit without a snippet; it still opens. + } + } +} + +export interface SearchRecordsArgs { + /** The record the session is acting for. */ + recordId: string; + query: string; + /** Most hits per group. */ + limit: number; + /** The record's module map; an explicit `false` hides a source. */ + modules: SurfaceModuleMap | null; + /** Whether the caller's grant covers a section (always true for the owner). */ + domainVisible: (domain: ShareScope) => boolean; + /** The language of labels the server names (a visit kind, a sport). */ + displayLocale: Locale; + /** The record's zone, for the calendar day of an instant. */ + timezone: string; + /** Overrides for tests. */ + now?: () => number; + budgetMs?: number; +} + +export interface SearchStats { + sources: number; + rows: number; + candidates: number; + elapsedMs: number; +} + +/** The sources this caller may read on this record. */ +export function readableSources( + modules: SurfaceModuleMap | null, + domainVisible: (domain: ShareScope) => boolean, +): SearchSourceKey[] { + return SEARCH_SOURCE_KEYS.filter( + (key) => + domainVisible(SEARCH_SOURCE_DOMAIN[key]) && + isSurfaceVisible(`search:${key}`, modules), + ); +} + +export async function searchRecords( + args: SearchRecordsArgs, +): Promise { + const now = args.now ?? Date.now; + const started = now(); + const budget: ScanBudget = { + deadline: started + (args.budgetMs ?? SEARCH_TIME_BUDGET_MS), + now, + truncated: false, + rows: 0, + }; + const ctx: SearchContext = { + recordId: args.recordId, + query: args.query, + words: queryWords(args.query), + foldedQuery: normalizeSearchText(args.query), + wanted: args.limit, + timezone: args.timezone, + display: args.displayLocale, + modules: args.modules, + budget, + }; + const sources = new Set(readableSources(args.modules, args.domainVisible)); + const collected: SearchCandidate[] = []; + const labels = symptomLabels(ctx); + + const tasks: Array<() => Promise> = []; + const add = (key: SearchSourceKey, run: () => Promise) => { + if (!sources.has(key)) return; + tasks.push(async () => { + collected.push(...(await run())); + }); + }; + + const recordKinds = new Set( + RECORD_SOURCES.filter((key) => sources.has(key)), + ); + if (recordKinds.size > 0) { + tasks.push(async () => { + const candidates = await loadRecordCandidates( + args.recordId, + args.query, + "", + { + kinds: recordKinds, + displayLocale: args.displayLocale, + includeAiDeferredText: true, + }, + ); + budget.rows += candidates.length; + // Each kind reads its newest `MAX_ROWS_PER_KIND`; a kind that filled + // the read may have older rows the search did not see. + const perKind = new Map(); + for (const candidate of candidates) { + const kind = candidate.hit?.kind ?? ""; + perKind.set(kind, (perKind.get(kind) ?? 0) + 1); + if (keepIfHit(ctx, candidate)) collected.push(candidate); + } + if ([...perKind.values()].some((n) => n >= MAX_ROWS_PER_KIND)) { + budget.truncated = true; + } + }); + } + add("labAnalyte", () => labAnalyteCandidates(ctx)); + add("labNote", () => labNoteCandidates(ctx)); + add("medication", () => medicationCandidates(ctx)); + add("symptom", () => symptomCandidates(ctx, labels)); + add("symptomNote", () => symptomNoteCandidates(ctx, labels)); + add("workout", () => workoutCandidates(ctx)); + add("moodNote", () => moodNoteCandidates(ctx)); + add("illnessNote", () => illnessNoteCandidates(ctx)); + add("measurementNote", () => measurementNoteCandidates(ctx)); + + await inPool(tasks, SOURCE_CONCURRENCY); + + const ranked: Ranked[] = collected + .filter((candidate) => candidate.hit) + .map((candidate) => { + const s = scoreCandidate(candidate, ctx.words, ctx.foldedQuery); + const title = candidate.hit?.title ?? null; + return { + candidate, + matched: s.matched, + score: s.score, + titleHit: title !== null && marked(title, args.query).marks.length > 0, + }; + }) + .sort(compareRanked); + + const groups = new Map(); + for (const { candidate } of ranked) { + const kind = candidate.hit!.kind as SearchHitKind; + let group = groups.get(kind); + if (!group) { + group = { kind, hits: [], more: false }; + groups.set(kind, group); + } + if (group.hits.length < args.limit) + group.hits.push(toHit(candidate, args.query)); + else group.more = true; + } + // Groups in the order of their best hit; a tie keeps the fixed order. + const ordered = [...groups.values()].sort((a, b) => { + const ia = ranked.findIndex((r) => r.candidate.hit?.kind === a.kind); + const ib = ranked.findIndex((r) => r.candidate.hit?.kind === b.kind); + return ( + ia - ib || + SEARCH_HIT_KINDS.indexOf(a.kind) - SEARCH_HIT_KINDS.indexOf(b.kind) + ); + }); + + await addDocumentSnippets( + args.recordId, + ordered.flatMap((group) => group.hits), + args.query, + ); + + return { + groups: ordered, + truncated: budget.truncated, + stats: { + sources: sources.size, + rows: budget.rows, + candidates: ranked.length, + elapsedMs: now() - started, + }, + }; +} diff --git a/src/lib/search/sources.ts b/src/lib/search/sources.ts new file mode 100644 index 000000000..b654ae606 --- /dev/null +++ b/src/lib/search/sources.ts @@ -0,0 +1,72 @@ +/** + * What the in-app record search reads: one source per table and field it + * matches, the group each lists its hits under, and the record section each + * belongs to. Pure data, so the surface registry test reads it too. + * + * Two masks, as everywhere else. A source is read only when the record's + * module for it is on (the `search:` entries of + * `src/lib/modules/surface.ts`; a source with no entry is core) and when the + * caller's grant covers its section (`SEARCH_SOURCE_DOMAIN`). The route + * admits the whole record only, so for today's callers the second mask is + * always open; it is applied all the same, so narrowing that admission later + * cannot widen what the search reads. + */ +import type { ShareDomain } from "@/lib/sharing/scope"; + +import type { SearchHitKind } from "./contract"; + +export const SEARCH_SOURCE_KEYS = [ + "visit", + "condition", + "document", + "vaccination", + "labAnalyte", + "labNote", + "medication", + "symptom", + "symptomNote", + "workout", + "moodNote", + "illnessNote", + "measurementNote", +] as const; + +export type SearchSourceKey = (typeof SEARCH_SOURCE_KEYS)[number]; + +/** The group each source lists its hits under. */ +export const SEARCH_SOURCE_KIND: Record = { + visit: "visit", + condition: "condition", + document: "document", + vaccination: "vaccination", + labAnalyte: "lab", + labNote: "lab", + medication: "medication", + symptom: "symptom", + symptomNote: "symptom", + workout: "workout", + moodNote: "note", + illnessNote: "note", + measurementNote: "note", +}; + +/** + * The record section each source reads, as the routes that serve those rows + * declare it: visits and the immunization log sit in `profile`, workouts and + * reading notes in `measurements`, mood in `mind`. + */ +export const SEARCH_SOURCE_DOMAIN: Record = { + visit: "profile", + condition: "illness", + document: "documents", + vaccination: "profile", + labAnalyte: "labs", + labNote: "labs", + medication: "medications", + symptom: "illness", + symptomNote: "illness", + workout: "measurements", + moodNote: "mind", + illnessNote: "illness", + measurementNote: "measurements", +}; diff --git a/src/lib/search/wire-schemas.ts b/src/lib/search/wire-schemas.ts new file mode 100644 index 000000000..6523acf04 --- /dev/null +++ b/src/lib/search/wire-schemas.ts @@ -0,0 +1,76 @@ +/** + * Zod schemas for `GET /api/search`. The route parses its query with these + * and the OpenAPI table publishes them. Server and OpenAPI side only: the + * palette imports its types from `./contract.ts`. + */ +import { z } from "zod/v4"; + +import { + SEARCH_HIT_KINDS, + SEARCH_LIMIT_DEFAULT, + SEARCH_LIMIT_MAX, + SEARCH_NOTE_SOURCES, + SEARCH_QUERY_MAX, + SEARCH_QUERY_MIN, +} from "./contract"; + +export const searchQuerySchema = z.object({ + q: z + .string() + .trim() + .min(SEARCH_QUERY_MIN) + .max(SEARCH_QUERY_MAX) + .describe( + "What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue or ss.", + ), + limit: z.coerce + .number() + .int() + .min(1) + .max(SEARCH_LIMIT_MAX) + .default(SEARCH_LIMIT_DEFAULT) + .describe("Most hits per group."), +}); + +const markedTextSchema = z + .object({ + text: z.string(), + marks: z + .array(z.tuple([z.number().int(), z.number().int()])) + .describe( + "Half-open [start, end) character ranges of `text` a query word matched.", + ), + }) + .meta({ id: "SearchMarkedText" }); + +export const searchHitSchema = z + .object({ + id: z.string(), + kind: z.enum(SEARCH_HIT_KINDS), + title: markedTextSchema.nullable(), + context: z.array(markedTextSchema), + snippet: markedTextSchema.nullable(), + day: z.string().nullable(), + path: z + .string() + .describe("In-app path that opens the record, never an API path."), + source: z.enum(SEARCH_NOTE_SOURCES).nullable(), + }) + .meta({ id: "SearchHit" }); + +export const searchResponseSchema = z + .object({ + groups: z.array( + z.object({ + kind: z.enum(SEARCH_HIT_KINDS), + hits: z.array(searchHitSchema), + more: z.boolean(), + }), + ), + truncated: z + .boolean() + .describe( + "The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries may match and are not listed.", + ), + }) + .meta({ id: "SearchResponse" }); From 9b86ec2e10a068c6543faa3f532d7ee6ad4f28f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:29:41 +0200 Subject: [PATCH 3/6] test(search): cover the matcher, the scan bounds and the route --- src/app/api/search/__tests__/route.test.ts | 176 ++++++++++++++++++ src/lib/search/__tests__/match.test.ts | 141 ++++++++++++++ .../search/__tests__/search-records.test.ts | 121 ++++++++++++ src/lib/search/match.ts | 43 ++++- 4 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 src/app/api/search/__tests__/route.test.ts create mode 100644 src/lib/search/__tests__/match.test.ts create mode 100644 src/lib/search/__tests__/search-records.test.ts diff --git a/src/app/api/search/__tests__/route.test.ts b/src/app/api/search/__tests__/route.test.ts new file mode 100644 index 000000000..82e3ae25f --- /dev/null +++ b/src/app/api/search/__tests__/route.test.ts @@ -0,0 +1,176 @@ +/** + * `GET /api/search` — admission, validation, the rate limit, and what the + * route hands the search and the log. + * + * The search itself is `searchRecords`, covered by its own unit tests and by + * `tests/integration/record-search.test.ts` against Postgres; here it is a + * spy, so the test can assert exactly what the route passes it. + * + * Mutation checks (each run, each seen red): + * - admit on `("read", "profile")` instead of the whole record → "admits + * the whole record only" goes red; + * - drop `.min(SEARCH_QUERY_MIN)` → "refuses a one-letter query" goes red; + * - put the query into the annotation → "never logs the query" goes red; + * - pass `actor.id` as the record → "searches the record the session acts + * for" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + checkRateLimit: vi.fn(), + searchRecords: vi.fn(), + annotate: vi.fn(), + resolveModuleMap: vi.fn(), + domainVisible: vi.fn(() => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: mocks.requireRecordAuth, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: mocks.annotate, + getEvent: () => undefined, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: mocks.resolveModuleMap, +})); +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: vi.fn(async () => mocks.domainVisible), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/search/search-records", () => ({ + searchRecords: mocks.searchRecords, +})); + +import { GET } from "../route"; + +const handler = GET as unknown as (req: NextRequest) => Promise; + +function request(query: string): NextRequest { + return new NextRequest(`http://localhost/api/search?${query}`); +} + +const OWNER = { id: "owner-1", locale: "de" }; +const ACTOR = { id: "actor-1", locale: "en" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.requireRecordAuth.mockResolvedValue({ + user: OWNER, + actor: ACTOR, + grantId: "grant-1", + }); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.resolveModuleMap.mockResolvedValue({ illness: false }); + mocks.searchRecords.mockResolvedValue({ + groups: [ + { + kind: "visit", + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Schulter", marks: [[0, 8]] }, + context: [], + snippet: null, + day: "2026-01-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + more: false, + }, + ], + truncated: false, + stats: { sources: 12, rows: 40, candidates: 1, elapsedMs: 3 }, + }); +}); + +describe("GET /api/search", () => { + it("admits the whole record only, at read", async () => { + await handler(request("q=schulter")); + expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record"); + }); + + it("propagates a refused admission without searching", async () => { + mocks.requireRecordAuth.mockRejectedValueOnce(new Error("denied")); + await expect(handler(request("q=schulter"))).rejects.toThrow("denied"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("searches the record the session acts for, with its masks", async () => { + const res = await handler(request("q=Schulter&limit=7")); + expect(res.status).toBe(200); + expect(mocks.searchRecords).toHaveBeenCalledTimes(1); + const args = mocks.searchRecords.mock.calls[0][0]; + expect(args.recordId).toBe("owner-1"); + expect(args.query).toBe("Schulter"); + expect(args.limit).toBe(7); + expect(args.modules).toEqual({ illness: false }); + expect(args.domainVisible).toBe(mocks.domainVisible); + expect(args.timezone).toBe("Europe/Berlin"); + // Labels follow the reader, not the record. + expect(args.displayLocale).toBe("en"); + const body = await res.json(); + expect(body.data.groups[0].hits[0].path).toBe("/checkups?visit=v1"); + expect(body.data.truncated).toBe(false); + expect(body.data.stats).toBeUndefined(); + }); + + it("defaults the limit", async () => { + await handler(request("q=knie")); + expect(mocks.searchRecords.mock.calls[0][0].limit).toBe(5); + }); + + it.each([ + ["a one-letter query", "q=a"], + ["a blank query", "q=%20%20%20"], + ["a missing query", "limit=5"], + ["an over-long query", `q=${"x".repeat(101)}`], + ["a zero limit", "q=knie&limit=0"], + ["a limit above the cap", "q=knie&limit=21"], + ["a non-numeric limit", "q=knie&limit=lots"], + ])("refuses %s with 422 and never searches", async (_label, query) => { + const res = await handler(request(query)); + expect(res.status).toBe(422); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("does not echo a refused query", async () => { + const secret = "Psychotherapie".repeat(8); + const res = await handler(request(`q=${secret}`)); + expect(res.status).toBe(422); + expect(await res.text()).not.toContain("Psychotherapie"); + }); + + it("rate limits per actor before reading anything", async () => { + mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false }); + const res = await handler(request("q=schulter")); + expect(res.status).toBe(429); + expect(mocks.checkRateLimit.mock.calls[0][0]).toBe("search:actor-1"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("never logs the query, only its length and the counts", async () => { + await handler(request("q=Psychotherapie")); + const logged = JSON.stringify(mocks.annotate.mock.calls); + expect(logged).not.toMatch(/psychotherapie/i); + const run = mocks.annotate.mock.calls.find( + ([e]) => e.action.name === "search.query.run", + ); + expect(run?.[0].meta).toMatchObject({ + query_length: 14, + hits: 1, + groups: 1, + truncated: false, + delegated: true, + }); + }); +}); diff --git a/src/lib/search/__tests__/match.test.ts b/src/lib/search/__tests__/match.test.ts new file mode 100644 index 000000000..d70fcc870 --- /dev/null +++ b/src/lib/search/__tests__/match.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { + foldForMatch, + isHit, + markMatches, + scoreForQuery, + queryWords, + scoreCandidate, + snippetOf, + WEIGHT, + type SearchCandidate, +} from "../match"; + +function candidate( + fields: SearchCandidate["fields"], +): Pick { + return { fields }; +} + +const hit = (text: string, query: string, weight: number = WEIGHT.primary) => + isHit(scoreForQuery(candidate([{ text, weight }]), query)); + +/** The marked substrings, for readable assertions. */ +const marks = (text: string, query: string) => + markMatches(text, query).map(([s, e]) => text.slice(s, e)); + +describe("folding", () => { + it("ignores case, accents and the German keyboard spellings", () => { + expect(foldForMatch("Schulter")).toBe("schulter"); + expect(foldForMatch("Hüfte")).toBe(foldForMatch("Huefte")); + expect(foldForMatch("Fuß")).toBe(foldForMatch("Fuss")); + expect(foldForMatch("Müdigkeit")).toBe("mudigkeit"); + expect(foldForMatch(" FSME Impfung ")).toBe("fsme impfung"); + }); + + it("matches a term whatever way either side spells the umlaut", () => { + expect(hit("Schulter links", "schulter")).toBe(true); + expect(hit("schulter links", "SCHULTER")).toBe(true); + expect(hit("Hüftschmerzen", "hueft")).toBe(true); + expect(hit("Hueftschmerzen", "hüft")).toBe(true); + expect(hit("Fußgelenk", "fuss")).toBe(true); + expect(hit("Schläfrig", "schlaefrig")).toBe(true); + }); + + it("does not match what is not there", () => { + expect(hit("Knie rechts", "schulter")).toBe(false); + // A short word must start a word: "arm" is not inside "Darm". + expect(hit("Darmspiegelung", "arm")).toBe(false); + expect(hit("Armbruch", "arm")).toBe(true); + }); + + it("finds a body part across languages", () => { + expect(hit("Schulter", "shoulder")).toBe(true); + expect(hit("left shoulder", "Schulter")).toBe(true); + }); +}); + +describe("queryWords", () => { + it("drops filler and keeps the tokeniser's form", () => { + expect(queryWords("meine letzte Schulter OP")).toEqual(["schulter"]); + }); +}); + +describe("marks", () => { + it("marks the original characters, not the folded ones", () => { + expect(marks("Hüftgelenk und Hüfte", "hueft")).toEqual(["Hüft", "Hüft"]); + expect(marks("Fußgelenk", "fuss")).toEqual(["Fuß"]); + expect(marks("Huefte", "hüfte")).toEqual(["Huefte"]); + expect(marks("SCHULTER links", "schulter")).toEqual(["SCHULTER"]); + }); + + it("marks a short word only at a word start", () => { + expect(marks("Arm und Darm", "arm")).toEqual(["Arm"]); + }); + + it("marks a query too short for the tokeniser by itself", () => { + expect(marks("Hb und HbA1c", "hb")).toEqual(["Hb", "Hb"]); + }); + + it("merges overlapping marks", () => { + expect(markMatches("Schulterschmerz", "schulter schulterschmerz")).toEqual([ + [0, 15], + ]); + }); + + it("keeps ranges inside the text for multi-unit characters", () => { + const text = "😀 Schulter"; + const [[start, end]] = markMatches(text, "schulter"); + expect(text.slice(start, end)).toBe("Schulter"); + }); +}); + +describe("snippetOf", () => { + it("returns the whole short text with its marks", () => { + const s = snippetOf("Schmerzen in der Schulter", "schulter"); + expect(s?.text).toBe("Schmerzen in der Schulter"); + expect(s?.marks).toEqual([[17, 25]]); + }); + + it("cuts a long text around the first match at word boundaries", () => { + const filler = "Lorem ipsum dolor sit amet ".repeat(20); + const s = snippetOf(`${filler}die Schulter tut weh ${filler}`, "schulter"); + expect(s).not.toBeNull(); + expect(s!.text.startsWith("…")).toBe(true); + expect(s!.text.endsWith("…")).toBe(true); + expect(s!.text.length).toBeLessThanOrEqual(142); + const [[start, end]] = s!.marks; + expect(s!.text.slice(start, end)).toBe("Schulter"); + }); + + it("is null when the text does not contain the query", () => { + expect(snippetOf("Knie", "schulter")).toBeNull(); + }); +}); + +describe("ranking inputs", () => { + it("a title hit scores above a note hit", () => { + const title = scoreForQuery( + candidate([{ text: "Schulter", weight: WEIGHT.primary }]), + "schulter", + ); + const note = scoreForQuery( + candidate([{ text: "Schulter", weight: WEIGHT.tertiary, body: true }]), + "schulter", + ); + expect(title.score).toBeGreaterThan(note.score); + }); + + it("counts each matched word once", () => { + const s = scoreCandidate( + candidate([ + { text: "Schulter links", weight: WEIGHT.primary }, + { text: "links", weight: WEIGHT.secondary }, + ]), + ["schulter", "links"], + "schulter links", + ); + expect(s.matched).toBe(2); + }); +}); diff --git a/src/lib/search/__tests__/search-records.test.ts b/src/lib/search/__tests__/search-records.test.ts new file mode 100644 index 000000000..297838958 --- /dev/null +++ b/src/lib/search/__tests__/search-records.test.ts @@ -0,0 +1,121 @@ +/** + * The bounds of the record search and the masks that pick its sources. The + * reads themselves are exercised against Postgres in + * `tests/integration/record-search.test.ts`. + * + * Mutation checks (each run, each seen red): + * - drop the deadline check in `scanNewest` → "stops when the budget has + * run out" goes red; + * - drop the `scanned >= maxRows` stop → "never reads more than the cap" + * goes red; + * - drop the domain test from `readableSources` → "drops a source the grant + * does not cover" goes red. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import type { SearchCandidate } from "../match"; +import { + readableSources, + scanNewest, + type ScanBudget, +} from "../search-records"; + +function budget(deadlineIn = 10_000, clock = { t: 0 }): ScanBudget { + return { + deadline: clock.t + deadlineIn, + now: () => clock.t, + truncated: false, + rows: 0, + }; +} + +/** A source of `total` rows, newest first, every `hitEvery`-th one a hit. */ +function source(total: number, hitEvery = 1) { + const rows = Array.from({ length: total }, (_, i) => ({ id: `r${i}`, i })); + const reads: number[] = []; + return { + reads, + read: async (cursor: string | null, take: number) => { + const start = cursor ? rows.findIndex((r) => r.id === cursor) + 1 : 0; + reads.push(take); + return rows.slice(start, start + take); + }, + match: (row: { id: string; i: number }) => + row.i % hitEvery === 0 + ? ({ id: row.id } as unknown as SearchCandidate) + : null, + }; +} + +describe("scanNewest", () => { + it("reads a small source to its end and is not truncated", async () => { + const b = budget(); + const s = source(30, 10); + const out = await scanNewest({ budget: b, wanted: 5, batch: 10, ...s }); + expect(out.map((c) => c.id)).toEqual(["r0", "r10", "r20"]); + expect(b.truncated).toBe(false); + expect(b.rows).toBe(30); + }); + + it("stops once it holds more hits than wanted", async () => { + const b = budget(); + const s = source(10_000, 1); + const out = await scanNewest({ budget: b, wanted: 3, batch: 10, ...s }); + expect(out.length).toBe(10); + expect(s.reads).toEqual([10]); + expect(b.truncated).toBe(false); + }); + + it("never reads more than the cap, and says so", async () => { + const b = budget(); + const s = source(10_000, 1_000_000); + await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s }); + expect(b.rows).toBe(450); + expect(s.reads).toEqual([100, 100, 100, 100, 50]); + expect(b.truncated).toBe(true); + }); + + it("stops when the budget has run out, and says so", async () => { + const clock = { t: 0 }; + const b = budget(100, clock); + const s = source(10_000, 1_000_000); + const read = async (cursor: string | null, take: number) => { + clock.t += 60; + return s.read(cursor, take); + }; + await scanNewest({ budget: b, wanted: 3, batch: 10, read, match: s.match }); + expect(s.reads.length).toBe(2); + expect(b.truncated).toBe(true); + }); +}); + +describe("readableSources", () => { + const all = () => true; + + it("reads everything on an owner's record with every module on", () => { + expect(readableSources({}, all)).toHaveLength(13); + }); + + it("drops the sources of a switched-off module, never a core one", () => { + const sources = readableSources( + { illness: false, mood: false, inboundDocuments: false }, + all, + ); + expect(sources).not.toContain("condition"); + expect(sources).not.toContain("symptom"); + expect(sources).not.toContain("symptomNote"); + expect(sources).not.toContain("illnessNote"); + expect(sources).not.toContain("moodNote"); + expect(sources).not.toContain("document"); + expect(sources).toContain("visit"); + expect(sources).toContain("measurementNote"); + }); + + it("drops a source the grant does not cover", () => { + const sources = readableSources({}, (domain) => domain !== "mind"); + expect(sources).not.toContain("moodNote"); + expect(sources).toContain("visit"); + }); +}); diff --git a/src/lib/search/match.ts b/src/lib/search/match.ts index 7e04cd7ea..2bc858975 100644 --- a/src/lib/search/match.ts +++ b/src/lib/search/match.ts @@ -285,6 +285,18 @@ export function scoreCandidate( 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; @@ -355,7 +367,9 @@ function foldWithMap(text: string): { from: number[]; to: number[]; } { - const chars: string[] = []; + // 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". @@ -367,27 +381,29 @@ function foldWithMap(text: string): { if (/\s/u.test(ch)) piece = " "; piece = piece.replace(/ß/g, "ss"); for (const out of piece) { - const prev = chars.length - 1; + 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(chars[prev]) && + /[aou]/.test(units[prev]) && !absorbed[prev] ) { to[prev] = end; absorbed[prev] = true; continue; } - chars.push(out); - from.push(index); - to.push(end); - absorbed.push(false); + 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: chars.join(""), from, to }; + return { folded: units.join(""), from, to }; } /** @@ -397,9 +413,16 @@ function foldWithMap(text: string): { * 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(queryWords(query).map((w) => foldGerman(w))), - ].filter(Boolean); + ...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[] = []; From 1644acf4431853bc5cebf5097b7fe862b639d9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:39:54 +0200 Subject: [PATCH 4/6] feat(palette): list hits from the record under the local results --- messages/de.json | 29 +- messages/en.json | 29 +- messages/es.json | 29 +- messages/fr.json | 29 +- messages/it.json | 29 +- messages/ko.json | 29 +- messages/pl.json | 29 +- src/app/checkups/page-client.tsx | 8 + .../__tests__/command-palette.test.tsx | 118 ++++- .../command-palette/command-palette.tsx | 169 ++++++- src/components/command-palette/record-hit.tsx | 105 +++++ .../command-palette/use-record-search.ts | 77 ++++ src/components/encounters/visits-section.tsx | 11 + src/components/symptoms/symptoms-section.tsx | 12 + src/hooks/use-deep-linked-record.ts | 37 +- src/lib/query-keys/index.ts | 2 + src/lib/query-keys/search.ts | 15 + tests/integration/record-search.test.ts | 413 ++++++++++++++++++ 18 files changed, 1136 insertions(+), 34 deletions(-) create mode 100644 src/components/command-palette/record-hit.tsx create mode 100644 src/components/command-palette/use-record-search.ts create mode 100644 src/lib/query-keys/search.ts create mode 100644 tests/integration/record-search.test.ts diff --git a/messages/de.json b/messages/de.json index 8395f39a5..ac5771b9f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Suche", - "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", - "placeholder": "Seiten, Einstellungen, Module suchen…", + "description": "Finde eine Seite, eine Einstellung, ein Modul, eine Aktion oder einen Eintrag in deinen Daten.", + "placeholder": "Seiten, Einstellungen, deine Einträge suchen…", "results": "Treffer", "noResults": "Nichts gefunden für „{query}“.", "moduleOn": "An", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Einstellungen", "modules": "Module", - "actions": "Aktionen" + "actions": "Aktionen", + "records": "In deinen Einträgen" }, "actions": { "capture": "Neuer Eintrag", @@ -12583,6 +12584,28 @@ "backup": "Backup erstellen", "today": "Heute öffnen", "shortcuts": "Tastenkürzel zeigen" + }, + "records": { + "loading": "Deine Einträge werden durchsucht…", + "empty": "Nichts in deinen Einträgen für „{query}“.", + "error": "Deine Einträge konnten nicht durchsucht werden. Versuch es gleich noch einmal.", + "truncated": "Nur die neuesten Einträge wurden durchsucht.", + "kind": { + "visit": "Arztbesuch", + "condition": "Erkrankung", + "document": "Dokument", + "vaccination": "Impfung", + "lab": "Laborwert", + "medication": "Medikament", + "symptom": "Symptom", + "workout": "Training", + "note": "Notiz" + }, + "source": { + "mood": "Stimmungsnotiz", + "measurement": "Notiz zu einem Messwert", + "illness": "Krankheitstagebuch" + } } }, "shortcuts": { diff --git a/messages/en.json b/messages/en.json index 164ca09ff..da23da37b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Search", - "description": "Find a page, a setting, a module or an action.", - "placeholder": "Search pages, settings, modules…", + "description": "Find a page, a setting, a module, an action or an entry in your records.", + "placeholder": "Search pages, settings, your records…", "results": "Results", "noResults": "Nothing found for “{query}”.", "moduleOn": "On", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Settings", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "In your records" }, "actions": { "capture": "New entry", @@ -12583,6 +12584,28 @@ "backup": "Create a backup", "today": "Open today", "shortcuts": "Show keyboard shortcuts" + }, + "records": { + "loading": "Searching your records…", + "empty": "Nothing in your records for “{query}”.", + "error": "Your records could not be searched. Try again in a moment.", + "truncated": "Only the most recent entries were searched.", + "kind": { + "visit": "Visit", + "condition": "Condition", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Lab result", + "medication": "Medication", + "symptom": "Symptom", + "workout": "Workout", + "note": "Note" + }, + "source": { + "mood": "Mood note", + "measurement": "Note on a reading", + "illness": "Illness diary" + } } }, "shortcuts": { diff --git a/messages/es.json b/messages/es.json index 85feaee82..cd7e09945 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Buscar", - "description": "Encuentra una página, un ajuste, un módulo o una acción.", - "placeholder": "Buscar páginas, ajustes, módulos…", + "description": "Encuentra una página, un ajuste, un módulo, una acción o una entrada de tus registros.", + "placeholder": "Buscar páginas, ajustes, tus registros…", "results": "Resultados", "noResults": "No hay resultados para «{query}».", "moduleOn": "Activado", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Ajustes", "modules": "Módulos", - "actions": "Acciones" + "actions": "Acciones", + "records": "En tus registros" }, "actions": { "capture": "Nueva entrada", @@ -12583,6 +12584,28 @@ "backup": "Crear una copia de seguridad", "today": "Abrir hoy", "shortcuts": "Mostrar atajos de teclado" + }, + "records": { + "loading": "Buscando en tus registros…", + "empty": "Nada en tus registros para «{query}».", + "error": "No se pudo buscar en tus registros. Inténtalo de nuevo en un momento.", + "truncated": "Solo se buscaron las entradas más recientes.", + "kind": { + "visit": "Consulta", + "condition": "Enfermedad", + "document": "Documento", + "vaccination": "Vacuna", + "lab": "Resultado de laboratorio", + "medication": "Medicamento", + "symptom": "Síntoma", + "workout": "Entrenamiento", + "note": "Nota" + }, + "source": { + "mood": "Nota de ánimo", + "measurement": "Nota de una medición", + "illness": "Diario de enfermedad" + } } }, "shortcuts": { diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..491a9c5d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Recherche", - "description": "Trouver une page, un réglage, un module ou une action.", - "placeholder": "Rechercher des pages, réglages, modules…", + "description": "Trouver une page, un réglage, un module, une action ou une entrée de votre dossier.", + "placeholder": "Rechercher des pages, réglages, votre dossier…", "results": "Résultats", "noResults": "Aucun résultat pour « {query} ».", "moduleOn": "Activé", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Paramètres", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "Dans votre dossier" }, "actions": { "capture": "Nouvelle saisie", @@ -12583,6 +12584,28 @@ "backup": "Créer une sauvegarde", "today": "Ouvrir aujourd’hui", "shortcuts": "Afficher les raccourcis clavier" + }, + "records": { + "loading": "Recherche dans votre dossier…", + "empty": "Rien dans votre dossier pour « {query} ».", + "error": "Votre dossier n’a pas pu être parcouru. Réessayez dans un instant.", + "truncated": "Seules les entrées les plus récentes ont été parcourues.", + "kind": { + "visit": "Consultation", + "condition": "Maladie", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Résultat d’analyse", + "medication": "Médicament", + "symptom": "Symptôme", + "workout": "Entraînement", + "note": "Note" + }, + "source": { + "mood": "Note d’humeur", + "measurement": "Note sur une mesure", + "illness": "Journal de maladie" + } } }, "shortcuts": { diff --git a/messages/it.json b/messages/it.json index 6937f4746..702fa739c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Cerca", - "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", - "placeholder": "Cerca pagine, impostazioni, moduli…", + "description": "Trova una pagina, un’impostazione, un modulo, un’azione o una voce dei tuoi dati.", + "placeholder": "Cerca pagine, impostazioni, i tuoi dati…", "results": "Risultati", "noResults": "Nessun risultato per «{query}».", "moduleOn": "Attivo", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Impostazioni", "modules": "Moduli", - "actions": "Azioni" + "actions": "Azioni", + "records": "Nei tuoi dati" }, "actions": { "capture": "Nuova voce", @@ -12583,6 +12584,28 @@ "backup": "Crea un backup", "today": "Apri oggi", "shortcuts": "Mostra scorciatoie da tastiera" + }, + "records": { + "loading": "Ricerca nei tuoi dati…", + "empty": "Niente nei tuoi dati per «{query}».", + "error": "Non è stato possibile cercare nei tuoi dati. Riprova tra un momento.", + "truncated": "Sono state cercate solo le voci più recenti.", + "kind": { + "visit": "Visita", + "condition": "Malattia", + "document": "Documento", + "vaccination": "Vaccinazione", + "lab": "Esame di laboratorio", + "medication": "Farmaco", + "symptom": "Sintomo", + "workout": "Allenamento", + "note": "Nota" + }, + "source": { + "mood": "Nota sull’umore", + "measurement": "Nota su una misurazione", + "illness": "Diario di malattia" + } } }, "shortcuts": { diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..6d01f8128 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "검색", - "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", - "placeholder": "페이지, 설정, 모듈 검색…", + "description": "페이지, 설정, 모듈, 작업 또는 내 기록의 항목을 찾습니다.", + "placeholder": "페이지, 설정, 내 기록 검색…", "results": "결과", "noResults": "“{query}”에 대한 결과가 없습니다.", "moduleOn": "켜짐", @@ -12575,7 +12575,8 @@ "insights": "인사이트", "settings": "설정", "modules": "모듈", - "actions": "작업" + "actions": "작업", + "records": "내 기록에서" }, "actions": { "capture": "새 항목", @@ -12583,6 +12584,28 @@ "backup": "백업 만들기", "today": "오늘 열기", "shortcuts": "키보드 단축키 보기" + }, + "records": { + "loading": "내 기록을 검색하는 중…", + "empty": "내 기록에서 “{query}”에 대한 결과가 없습니다.", + "error": "내 기록을 검색할 수 없습니다. 잠시 후 다시 시도하세요.", + "truncated": "최근 항목만 검색했습니다.", + "kind": { + "visit": "진료", + "condition": "질환", + "document": "문서", + "vaccination": "예방접종", + "lab": "검사 결과", + "medication": "약", + "symptom": "증상", + "workout": "운동", + "note": "메모" + }, + "source": { + "mood": "기분 메모", + "measurement": "측정값 메모", + "illness": "질병 일지" + } } }, "shortcuts": { diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..2bdd9c0ce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Szukaj", - "description": "Znajdź stronę, ustawienie, moduł lub akcję.", - "placeholder": "Szukaj stron, ustawień, modułów…", + "description": "Znajdź stronę, ustawienie, moduł, akcję lub wpis w swoich danych.", + "placeholder": "Szukaj stron, ustawień, swoich wpisów…", "results": "Wyniki", "noResults": "Brak wyników dla „{query}”.", "moduleOn": "Wł.", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Ustawienia", "modules": "Moduły", - "actions": "Akcje" + "actions": "Akcje", + "records": "W twoich wpisach" }, "actions": { "capture": "Nowy wpis", @@ -12583,6 +12584,28 @@ "backup": "Utwórz kopię zapasową", "today": "Otwórz dzisiaj", "shortcuts": "Pokaż skróty klawiszowe" + }, + "records": { + "loading": "Przeszukiwanie twoich wpisów…", + "empty": "Brak wyników w twoich wpisach dla „{query}”.", + "error": "Nie udało się przeszukać twoich wpisów. Spróbuj ponownie za chwilę.", + "truncated": "Przeszukano tylko najnowsze wpisy.", + "kind": { + "visit": "Wizyta", + "condition": "Choroba", + "document": "Dokument", + "vaccination": "Szczepienie", + "lab": "Wynik badania", + "medication": "Lek", + "symptom": "Objaw", + "workout": "Trening", + "note": "Notatka" + }, + "source": { + "mood": "Notatka o nastroju", + "measurement": "Notatka do pomiaru", + "illness": "Dziennik choroby" + } } }, "shortcuts": { diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 77a2197cd..e97672d7c 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -69,6 +69,14 @@ export default function CheckupsPageClient() { const [view, setView] = useState( linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", ); + // A link to a visit can arrive while the page is already open (the search + // opens one from wherever the person is): it moves to the visits tab then + // too, rather than only on the first render. + const [observedVisit, setObservedVisit] = useState(linkedVisit); + if (linkedVisit !== observedVisit) { + setObservedVisit(linkedVisit); + if (linkedVisit) setView("visits"); + } const refresh = useCallback( () => diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx index cd23ee416..aeeb64b5f 100644 --- a/src/components/command-palette/__tests__/command-palette.test.tsx +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -4,6 +4,7 @@ import { Activity, Moon, Plus, Smile } from "lucide-react"; import { I18nProvider } from "@/lib/i18n/context"; import type { PaletteEntry } from "@/lib/command-palette/build-index"; +import type { SearchResponse } from "@/lib/search/contract"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), @@ -69,6 +70,18 @@ vi.mock("../use-palette-index", () => ({ usePaletteIndex: () => ENTRIES, })); +const recordSearch = vi.hoisted(() => ({ + state: { + query: null as string | null, + data: undefined as SearchResponse | undefined, + loading: false, + error: false, + }, +})); +vi.mock("../use-record-search", () => ({ + useRecordSearch: () => recordSearch.state, +})); + import { PaletteBody, arrangeResults } from "../command-palette"; function render(isMobile = false) { @@ -122,7 +135,9 @@ describe("", () => { const html = render(); expect(html).toContain('role="group"'); expect(html).toContain('aria-label="Search"'); - expect(html).toContain('placeholder="Search pages, settings, modules…"'); + expect(html).toContain( + 'placeholder="Search pages, settings, your records…"', + ); expect(html).toContain(">Actions<"); }); @@ -133,3 +148,104 @@ describe("", () => { expect(phone).toContain(">Cancel<"); }); }); + +const HITS: SearchResponse = { + groups: [ + { + kind: "visit", + more: false, + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Arthroskopie", marks: [] }, + context: [{ text: "Schulter (links)", marks: [[0, 8]] }], + snippet: null, + day: "2026-03-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + }, + { + kind: "note", + more: false, + hits: [ + { + id: "moodNote:m1", + kind: "note", + title: null, + context: [], + snippet: { text: "Die SCHULTER zwickt", marks: [[7, 15]] }, + day: "2026-03-05", + path: "/mood?day=2026-03-05", + source: "mood", + }, + ], + }, + ], + truncated: true, +}; + +describe(" in your records", () => { + it("lists the record hits after the local results as options", () => { + recordSearch.state = { + query: "schulter", + data: HITS, + loading: false, + error: false, + }; + const html = render(); + expect(html).toContain('data-group="records"'); + expect(html).toContain(">In your records<"); + expect(html).toContain('data-entry="record:visit:v1"'); + expect(html).toContain('data-entry="record:moodNote:m1"'); + // After every local option. + expect(html.indexOf('data-entry="action:today"')).toBeLessThan( + html.indexOf('data-entry="record:visit:v1"'), + ); + // The matched part is marked, as text. + expect(html).toContain("Schulter (links)"); + // A note without a name is called by what it was written on. + expect(html).toContain("Mood note"); + // Record text is never parsed as markup. + expect(html).not.toContain(""); + expect(html).toContain("<b>"); + expect(html).toContain("Only the most recent entries were searched."); + }); + + it("says when it is searching, and when nothing matched", () => { + recordSearch.state = { + query: "schulter", + data: undefined, + loading: true, + error: false, + }; + expect(render()).toContain("Searching your records…"); + recordSearch.state = { + query: "schulter", + data: { groups: [], truncated: false }, + loading: false, + error: false, + }; + expect(render()).toContain("Nothing in your records for “schulter”."); + recordSearch.state = { + query: "schulter", + data: undefined, + loading: false, + error: true, + }; + expect(render()).toContain("Your records could not be searched."); + }); + + it("shows nothing of the records while no search applies", () => { + recordSearch.state = { + query: null, + data: undefined, + loading: false, + error: false, + }; + expect(render()).not.toContain('data-group="records"'); + }); +}); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index f3d97849f..bb284242c 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -36,10 +36,13 @@ import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useTranslations } from "@/lib/i18n/context"; +import type { SearchHit } from "@/lib/search/contract"; import { cn } from "@/lib/utils"; import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; +import { RecordHitContent } from "./record-hit"; import { usePaletteIndex } from "./use-palette-index"; +import { useRecordSearch } from "./use-record-search"; /** * The command palette: one field, and below it the places, settings, module @@ -47,6 +50,11 @@ import { usePaletteIndex } from "./use-palette-index"; * Escape closes. A module's entry is a switch: Enter flips it and the palette * stays open. * + * After the local results comes "In your records": the server's search over + * the record (`useRecordSearch`, `GET /api/search`), once the typing pauses. + * Its hits are options like any other, after the local ones, so the arrows + * and Enter work across both; each opens the page that shows the record. + * * Accessibility follows the combobox pattern: the field is the * `role="combobox"` that keeps focus, the results are a `listbox` of grouped * `option`s, and `aria-activedescendant` names the highlighted one. @@ -177,8 +185,15 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { () => sections.flatMap((section) => section.entries), [sections], ); - const activeIndex = - flat.length === 0 ? -1 : Math.min(active, flat.length - 1); + const records = useRecordSearch(query); + const recordHits = useMemo( + () => records.data?.groups.flatMap((group) => group.hits) ?? [], + [records.data], + ); + const showRecords = records.query !== null; + const shownHits = showRecords ? recordHits : []; + const total = flat.length + shownHits.length; + const activeIndex = total === 0 ? -1 : Math.min(active, total - 1); const optionId = (i: number) => `${listId}-option-${i}`; // Keep the highlighted option in view as the arrows move through a list @@ -231,19 +246,33 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { } } + function openRecord(path: string) { + setCommandPaletteOpen(false); + router.push(path); + } + + function runAt(index: number) { + if (index < 0) return; + if (index < flat.length) { + run(flat[index]); + return; + } + const hit = shownHits[index - flat.length]; + if (hit) openRecord(hit.path); + } + function onKeyDown(event: React.KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - if (flat.length === 0) return; + if (total === 0) return; const step = event.key === "ArrowDown" ? 1 : -1; - setActive((activeIndex + step + flat.length) % flat.length); + setActive((activeIndex + step + total) % total); return; } if (event.key === "Enter") { event.preventDefault(); - const entry = flat[activeIndex]; - if (entry) run(entry); + runAt(activeIndex); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { @@ -278,7 +307,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { autoFocus type="text" role="combobox" - aria-expanded={flat.length > 0} + aria-expanded={total > 0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={ @@ -319,7 +348,9 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { data-slot="command-palette-results" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2" > - {sections.length === 0 ? ( + {sections.length === 0 && + (!showRecords || + (!records.loading && !records.error && shownHits.length === 0)) ? (

+ ) : null} {isMobile ? null : ( @@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { ); } + +/** + * "In your records": the server's hits after the local results. A status line + * (searching, nothing found, failed, older entries not read) is plain text, + * not an option, so the arrows skip it. + */ +function RecordSection({ + headingId, + hits, + firstIndex, + activeIndex, + optionId, + loading, + error, + truncated, + query, + onHover, + onOpen, +}: { + headingId: string; + hits: ReadonlyArray; + firstIndex: number; + activeIndex: number; + optionId: (i: number) => string; + loading: boolean; + error: boolean; + truncated: boolean; + query: string; + onHover: (i: number) => void; + onOpen: (path: string) => void; +}) { + const { t } = useTranslations(); + const status = error + ? t("palette.records.error") + : hits.length === 0 + ? loading + ? t("palette.records.loading") + : t("palette.records.empty", { query }) + : null; + return ( +

+
+ {t("palette.groups.records")} +
+ {hits.map((hit, n) => { + const i = firstIndex + n; + const selected = i === activeIndex; + return ( +
{ + if (!selected) onHover(i); + }} + onMouseDown={(event) => { + // Keep focus in the field; the click opens the record. + event.preventDefault(); + }} + onClick={() => onOpen(hit.path)} + className={cn( + "flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm", + selected && "bg-accent text-accent-foreground", + )} + > + +
+ ); + })} + {status ? ( +

+ {status} +

+ ) : null} + {truncated && !error ? ( +

+ {t("palette.records.truncated")} +

+ ) : null} +
+ ); +} diff --git a/src/components/command-palette/record-hit.tsx b/src/components/command-palette/record-hit.tsx new file mode 100644 index 000000000..cc7d769a8 --- /dev/null +++ b/src/components/command-palette/record-hit.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { + Activity, + Dumbbell, + FileText, + FlaskConical, + NotebookPen, + Pill, + Stethoscope, + Syringe, + Thermometer, + type LucideIcon, +} from "lucide-react"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + SearchHit, + SearchHitKind, + SearchMarkedText, +} from "@/lib/search/contract"; + +export const RECORD_HIT_ICON: Record = { + visit: Stethoscope, + condition: Thermometer, + document: FileText, + vaccination: Syringe, + lab: FlaskConical, + medication: Pill, + symptom: Activity, + workout: Dumbbell, + note: NotebookPen, +}; + +/** + * Text with the ranges the query matched set in ``. Plain React text + * children only: the ranges are character offsets the server computed, and + * nothing here is parsed as markup. + */ +export function Marked({ value }: { value: SearchMarkedText }) { + const parts: React.ReactNode[] = []; + let at = 0; + for (const [start, end] of value.marks) { + if (start < at || end > value.text.length || end <= start) continue; + if (start > at) parts.push(value.text.slice(at, start)); + parts.push( + + {value.text.slice(start, end)} + , + ); + at = end; + } + if (at < value.text.length) parts.push(value.text.slice(at)); + return <>{parts}; +} + +/** What a hit is called when its record has no name of its own. */ +export function useRecordHitLabel(): (hit: SearchHit) => string { + const { t } = useTranslations(); + return (hit) => + hit.source + ? t(`palette.records.source.${hit.source}`) + : t(`palette.records.kind.${hit.kind}`); +} + +/** The body of one record option: title, kind and date, context, snippet. */ +export function RecordHitContent({ hit }: { hit: SearchHit }) { + const fmt = useFormatters(); + const label = useRecordHitLabel()(hit); + const Icon = RECORD_HIT_ICON[hit.kind]; + return ( + <> +