Merge branch 'v1422/search' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:06:07 +02:00
commit 334cb4e979
40 files changed
+3913 -326

No files matched your search

+2 -2
View File
@@ -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 => ({
+148 -270
View File
@@ -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<Set<RecordKind>> {
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<string>;
/** 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<string> = new Set([
"mein",
"meine",
"meinen",
"meinem",
"meiner",
"mine",
"our",
"all",
"any",
"some",
"last",
"latest",
"recent",
"letzte",
"letzten",
"letzter",
"letztes",
"show",
"find",
"list",
"get",
]);
/**
* Body parts, German and English, for matching a body site or a label. A site
* is typed in the owner's language and the question often arrives in 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<ReadonlyArray<string>> = [
["knee", "knees", "knie", "kniegelenk"],
["shoulder", "shoulders", "schulter", "schultergelenk"],
["hip", "hips", "hufte", "huftgelenk"],
["ankle", "sprunggelenk", "knochel"],
["foot", "feet", "fuss"],
["hand", "hands", "hande"],
["wrist", "handgelenk"],
["elbow", "ellbogen", "ellenbogen"],
["back", "rucken", "wirbelsaule", "spine"],
["neck", "nacken", "hals"],
["head", "kopf"],
["eye", "eyes", "auge", "augen"],
["ear", "ears", "ohr", "ohren"],
["tooth", "teeth", "zahn", "zahne"],
["heart", "herz"],
["lung", "lungs", "lunge"],
["stomach", "magen"],
["bowel", "colon", "darm"],
["skin", "haut"],
["arm", "arms", "arme"],
["leg", "legs", "bein", "beine"],
["finger", "fingers"],
["toe", "toes", "zeh", "zehe", "zehen"],
["breast", "brust"],
["thyroid", "schilddruse"],
];
const BODY_PART_BY_WORD: ReadonlyMap<string, ReadonlyArray<string>> = (() => {
const map = new Map<string, string[]>();
for (const group of BODY_PART_GROUPS) {
for (const word of group) map.set(word, [...group]);
}
return map;
})();
/** A query word's alternatives: the vault's synonyms plus body parts. */
function synonymsOf(word: string): string[] {
const out = new Set([
...expandQueryTokens([word]),
...(BODY_PART_BY_WORD.get(word) ?? []),
]);
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<string>;
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<SearchCandidate, "fields" | "contentWords">,
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<RecordKind, number> = {
interface Owner {
locale: Locale;
timezone: string;
/** The language of the in-app display data (`SearchCandidate.hit`). */
display: Locale;
}
async function loadOwner(userId: string): Promise<Owner> {
async function loadOwner(userId: string, display?: Locale): Promise<Owner> {
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 | null | undefined>): 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<Map<string, Set<string>>> {
const byDocument = new Map<string, Set<string>>();
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<SearchCandidate[]> {
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<string, Set<string>>()),
// 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,60 @@ 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<RecordKind>;
/** 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;
/** Newest rows read per kind; defaults to `MAX_ROWS_PER_KIND`. */
rowsPerKind?: number;
}
/**
* 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<SearchCandidate[]> {
const words = queryWords(query);
const empty = normalizeSearchText(query).length === 0;
const take = empty ? EMPTY_QUERY_PER_KIND : MAX_ROWS_PER_KIND;
const take = empty
? EMPTY_QUERY_PER_KIND
: (options.rowsPerKind ?? 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)
+3 -1
View File
@@ -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";
@@ -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<string>(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<string>([
...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);
});
+18
View File
@@ -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<string, ModuleKey>;
/** A surface id the map declares literally. */
+1
View File
@@ -348,6 +348,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"report-selection.leaves.unknown",
],
route: ["route.retired"],
search: ["search.invalid"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
+2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
// 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).
//
+40
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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(),
},
},
},
};
+2
View File
@@ -43,6 +43,7 @@ import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { timelineKeys } from "./timeline";
import { searchKeys } from "./search";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
@@ -80,6 +81,7 @@ export const queryKeys = {
...aiRunKeys,
...dayKeys,
...timelineKeys,
...searchKeys,
};
export { recordSettingsKeys } from "./record-settings";
+15
View File
@@ -0,0 +1,15 @@
/**
* Query keys — the in-app record search (`GET /api/search`, v1.42.2).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* The query text is part of the key, which keeps it in memory only: the
* `["search"]` head is not on the persister's allowlist, so neither the
* query nor its hits are written to disk.
*/
export const searchKeys = {
/** Root prefix. */
searchRoot: () => ["search"] as const,
/** `GET /api/search?q=&limit=`. */
recordSearch: (query: string, limit: number) =>
["search", "records", query, limit] as const,
};
+141
View File
@@ -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<SearchCandidate, "fields"> {
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);
});
});
@@ -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");
});
});
+82
View File
@@ -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: `<source>:<record id>`. */
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;
}
+492
View File
@@ -0,0 +1,492 @@
/**
* 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<string>;
/** True for the record kinds; bounds `MAX_RECORD_RESULTS`. */
record?: boolean;
/** Display data for the in-app search. */
hit?: CandidateHit;
}
// ── Folding ──────────────────────────────────────────────────────────
/**
* The German keyboard spellings, folded onto the plain vowel. Applied to text
* that `normalizeSearchText` already folded, so "ä" is "a" by now and only
* the typed digraph is left to fold.
*/
function foldGerman(folded: string): string {
return folded
.replace(/ß/g, "ss")
.replace(/ae/g, "a")
.replace(/oe/g, "o")
.replace(/ue/g, "u");
}
/** The form a field and a query are compared in. */
export function foldForMatch(text: string): string {
return foldGerman(normalizeSearchText(text));
}
/**
* Words the index tokeniser keeps but a search box does not need. The
* tokeniser's own list already drops the common function words.
*/
const QUERY_STOPWORDS: ReadonlySet<string> = new Set([
"mein",
"meine",
"meinen",
"meinem",
"meiner",
"mine",
"our",
"all",
"any",
"some",
"last",
"latest",
"recent",
"letzte",
"letzten",
"letzter",
"letztes",
"show",
"find",
"list",
"get",
]);
/**
* Body parts, German and English, for matching a body site or a label. A site
* is typed in the owner's language and the question often arrives in another,
* so "knee" has to find "Knie". Search-time only; the document vault's synonym
* list is left as it is. Same normal form as the tokeniser: lower case,
* accents stripped.
*/
const BODY_PART_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [
["knee", "knees", "knie", "kniegelenk"],
["shoulder", "shoulders", "schulter", "schultergelenk"],
["hip", "hips", "hufte", "huftgelenk"],
["ankle", "sprunggelenk", "knochel"],
["foot", "feet", "fuss"],
["hand", "hands", "hande"],
["wrist", "handgelenk"],
["elbow", "ellbogen", "ellenbogen"],
["back", "rucken", "wirbelsaule", "spine"],
["neck", "nacken", "hals"],
["head", "kopf"],
["eye", "eyes", "auge", "augen"],
["ear", "ears", "ohr", "ohren"],
["tooth", "teeth", "zahn", "zahne"],
["heart", "herz"],
["lung", "lungs", "lunge"],
["stomach", "magen"],
["bowel", "colon", "darm"],
["skin", "haut"],
["arm", "arms", "arme"],
["leg", "legs", "bein", "beine"],
["finger", "fingers"],
["toe", "toes", "zeh", "zehe", "zehen"],
["breast", "brust"],
["thyroid", "schilddruse"],
];
const BODY_PART_BY_WORD: ReadonlyMap<string, ReadonlyArray<string>> = (() => {
const map = new Map<string, string[]>();
for (const group of BODY_PART_GROUPS) {
for (const word of group) map.set(word, [...group]);
}
return map;
})();
/** A query word's alternatives: the vault's synonyms plus body parts. */
function synonymsOf(word: string): string[] {
const out = new Set(
[
...expandQueryTokens([word]),
...(BODY_PART_BY_WORD.get(word) ??
BODY_PART_BY_WORD.get(foldGerman(word)) ??
[]),
].map(foldGerman),
);
out.delete(foldGerman(word));
return [...out];
}
/**
* The words of a query that take part in matching: folded, split, short and
* filler words dropped, deduplicated, capped. These are also the words the
* blind document index is asked for, so they keep the tokeniser's own form.
*/
export function queryWords(query: string): string[] {
return tokenise(normalizeSearchText(query))
.filter((word) => !QUERY_STOPWORDS.has(word))
.slice(0, MAX_QUERY_WORDS);
}
interface PreparedField {
folded: string;
tokens: ReadonlySet<string>;
weight: number;
}
function prepareField(field: SearchField): PreparedField {
const folded = foldForMatch(field.text);
return {
folded,
tokens: new Set(folded.split(/[^\p{L}\p{N}]+/u).filter(Boolean)),
weight: field.weight,
};
}
export interface CandidateScore {
/** Query words that hit anything. */
matched: number;
/** Sum of each matched word's best field weight, plus the phrase bonus. */
score: number;
/** The whole query appears as typed inside one field. */
phrase: boolean;
}
/** Bonus for the whole query appearing inside one field. */
const PHRASE_BONUS = 5;
/** Query words shorter than this match whole words or word starts only. */
export const MIN_SUBSTRING_WORD = 4;
/**
* Whether a query word hits a field by its own spelling. A word of four
* letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has
* to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm".
*/
function hitsField(field: PreparedField, word: string): boolean {
if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word);
for (const token of field.tokens) if (token.startsWith(word)) return true;
return false;
}
/**
* Score one candidate against the query.
*
* A word hits a field when the field contains it (so "knee" finds "Knee
* replacement") or when one of its curated synonyms is a whole word of the
* field (so "Zucker" finds "glucose"). Synonyms match whole words only: they
* are wider than the word the person typed, and a substring match on them
* would find "rate" inside "moderate".
*/
export function scoreCandidate(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
words: readonly string[],
foldedQuery: string,
): CandidateScore {
const fields = candidate.fields
.filter((f) => f.text.trim().length > 0)
.map(prepareField);
const query = foldGerman(foldedQuery);
let matched = 0;
let score = 0;
for (const word of words) {
const folded = foldGerman(word);
const synonyms = synonymsOf(word);
let best = 0;
for (const field of fields) {
if (field.weight <= best) continue;
if (
hitsField(field, folded) ||
synonyms.some((s) => field.tokens.has(s))
) {
best = field.weight;
}
}
if (best < WEIGHT.tertiary && candidate.contentWords?.has(word)) {
best = WEIGHT.tertiary;
}
if (best > 0) {
matched += 1;
score += best;
}
}
const phrase =
query.length > 0 &&
fields.some((field) =>
query.includes(" ")
? field.folded.includes(query)
: hitsField(field, query),
);
if (phrase) score += PHRASE_BONUS;
return { matched, score, phrase };
}
/** `scoreCandidate` for a raw query. */
export function scoreForQuery(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
query: string,
): CandidateScore {
return scoreCandidate(
candidate,
queryWords(query),
normalizeSearchText(query),
);
}
/** Whether a scored candidate stays in the result at all. */
export function isHit(score: CandidateScore): boolean {
return score.matched > 0 || score.phrase;
}
/**
* Filter and order candidates for one query. Pure and deterministic.
*
* An empty query keeps every candidate. Otherwise a candidate stays when a
* query word hit it or the whole query appears in one of its fields (the
* substring match the older search kinds were built on, kept so a short
* query like "hr" still finds what it found before).
*/
export function rankCandidates(
candidates: readonly SearchCandidate[],
query: string,
maxRecords: number = MAX_RECORD_RESULTS,
): SearchCandidate[] {
const foldedQuery = normalizeSearchText(query);
const words = queryWords(query);
const scored = candidates.flatMap((candidate) => {
if (!foldedQuery) {
return [{ candidate, matched: 0, score: 0 }];
}
const s = scoreCandidate(candidate, words, foldedQuery);
if (!isHit(s)) return [];
return [{ candidate, matched: s.matched, score: s.score }];
});
scored.sort(
(a, b) =>
b.matched - a.matched ||
b.score - a.score ||
a.candidate.kindOrder - b.candidate.kindOrder ||
(b.candidate.date ?? 0) - (a.candidate.date ?? 0) ||
(a.candidate.id < b.candidate.id
? -1
: a.candidate.id > b.candidate.id
? 1
: 0),
);
let records = 0;
return scored.flatMap(({ candidate }) => {
if (!candidate.record) return [candidate];
records += 1;
return records <= maxRecords ? [candidate] : [];
});
}
// ── Highlighting and snippets ────────────────────────────────────────
/** A half-open `[start, end)` character range of the original text. */
export type MarkRange = [number, number];
export interface MarkedText {
text: string;
/** Ranges of `text` a query word matched, sorted and disjoint. */
marks: MarkRange[];
}
/**
* Fold `text` the way `foldForMatch` does, keeping for every folded character
* the range of the original it came from. The two have different lengths
* ("ß" becomes two characters, "ue" one), so a match found in the folded form
* is mapped back through this table.
*/
function foldWithMap(text: string): {
folded: string;
from: number[];
to: number[];
} {
// One entry per UTF-16 unit of the folded string, so an index `indexOf`
// returns can be looked up directly, astral characters included.
const units: string[] = [];
const from: number[] = [];
const to: number[] = [];
// Whether the vowel at that position has already taken in its "e".
const absorbed: boolean[] = [];
let index = 0;
for (const ch of text) {
const end = index + ch.length;
let piece = ch.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
if (/\s/u.test(ch)) piece = " ";
piece = piece.replace(/ß/g, "ss");
for (const out of piece) {
const prev = units.length - 1;
// "ae", "oe", "ue" fold onto the vowel: the "e" widens the vowel's
// source range instead of adding a character.
if (
out === "e" &&
prev >= 0 &&
/[aou]/.test(units[prev]) &&
!absorbed[prev]
) {
to[prev] = end;
absorbed[prev] = true;
continue;
}
for (let k = 0; k < out.length; k++) {
units.push(out[k]);
from.push(index);
to.push(end);
absorbed.push(false);
}
}
index = end;
}
return { folded: units.join(""), from, to };
}
/**
* The ranges of `text` the query's words hit by their own spelling, under the
* same rules as the matcher: a word of four letters or more anywhere, a
* shorter one only at the start of a word. Synonym hits are not marked (the
* text holds a different word), so a result can match with nothing marked.
*/
export function markMatches(text: string, query: string): MarkRange[] {
// The matcher's words; a query too short for the tokeniser ("Hb") is
// found by the phrase rule, so it is marked by its folded self.
const tokens = queryWords(query);
const words = [
...new Set(
(tokens.length > 0 ? tokens : foldForMatch(query).split(" ")).map((w) =>
foldGerman(w),
),
),
].filter((w) => w.length >= 2);
if (words.length === 0 || text.length === 0) return [];
const { folded, from, to } = foldWithMap(text);
const ranges: MarkRange[] = [];
for (const word of words) {
let at = folded.indexOf(word);
while (at !== -1) {
const startsWord = at === 0 || !/[\p{L}\p{N}]/u.test(folded[at - 1]);
if (word.length >= MIN_SUBSTRING_WORD || startsWord) {
ranges.push([from[at], to[at + word.length - 1]]);
}
at = folded.indexOf(word, at + 1);
}
}
ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
const merged: MarkRange[] = [];
for (const range of ranges) {
const last = merged[merged.length - 1];
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
else merged.push([range[0], range[1]]);
}
return merged;
}
/** `text` with its marks for `query`. */
export function marked(text: string, query: string): MarkedText {
return { text, marks: markMatches(text, query) };
}
/** Characters a snippet holds at most, before the ellipses. */
export const SNIPPET_CHARS = 140;
/** Characters a snippet keeps ahead of its first mark. */
const SNIPPET_LEAD = 40;
/**
* A short window of free text around its first match, whitespace collapsed,
* cut at word boundaries and marked with "…" where it was cut. Null when the
* query hits nothing in the text by its own spelling.
*/
export function snippetOf(
raw: string,
query: string,
max: number = SNIPPET_CHARS,
): MarkedText | null {
const text = raw.replace(/\s+/g, " ").trim();
const marks = markMatches(text, query);
if (marks.length === 0) return null;
if (text.length <= max) return { text, marks };
let start = Math.max(0, marks[0][0] - SNIPPET_LEAD);
if (start > 0) {
const space = text.indexOf(" ", start);
if (space !== -1 && space < marks[0][0]) start = space + 1;
}
let end = Math.min(text.length, start + max);
if (end < text.length) {
const space = text.lastIndexOf(" ", end);
if (space > marks[0][1]) end = space;
}
const lead = start > 0 ? "…" : "";
const tail = end < text.length ? "…" : "";
const shift = lead.length - start;
return {
text: `${lead}${text.slice(start, end)}${tail}`,
marks: marks
.filter(([s, e]) => s >= start && e <= end)
.map(([s, e]) => [s + shift, e + shift] as MarkRange),
};
}
+960
View File
@@ -0,0 +1,960 @@
/**
* 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, 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 = 5000;
/**
* Newest rows of each clinical kind (visits, conditions, documents,
* vaccinations) one search reads. Wider than the MCP bound: a person's own
* search is asked about the operation of ten years ago.
*/
export const RECORD_ROWS_PER_KIND = 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<Row extends { id: string }>(args: {
budget: ScanBudget;
wanted: number;
maxRows?: number;
batch?: number;
read: (cursor: string | null, take: number) => Promise<Row[]>;
match: (row: Row) => SearchCandidate | null;
}): Promise<SearchCandidate[]> {
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<void>>,
limit: number,
): Promise<void> {
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<SearchCandidate[]> {
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<SearchCandidate[]> {
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<SearchCandidate[]> {
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<Map<string, string | null>> {
let pending: Promise<Map<string, string | null>> | 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<Map<string, string | null>>,
): Promise<SearchCandidate[]> {
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<Map<string, string | null>>,
): Promise<SearchCandidate[]> {
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}`,
}),
);
},
});
}
/** "stairClimber" → "stair climber". */
function sportWords(sportType: string): string {
return sportType
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.toLowerCase();
}
/** 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 ? sportWords(sportType) : label;
}
async function workoutCandidates(
ctx: SearchContext,
): Promise<SearchCandidate[]> {
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"),
sportWords(sportType),
].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"),
sportWords(row.sportType),
].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<SearchCandidate[]> {
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<SearchCandidate[]> {
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<SearchCandidate[]> {
// 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<SearchSourceKey & RecordKind> = [
"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<void> {
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<SearchResponse & { stats: SearchStats }> {
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<void>> = [];
const add = (key: SearchSourceKey, run: () => Promise<SearchCandidate[]>) => {
if (!sources.has(key)) return;
tasks.push(async () => {
collected.push(...(await run()));
});
};
const recordKinds = new Set<RecordKind>(
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,
rowsPerKind: RECORD_ROWS_PER_KIND,
},
);
budget.rows += candidates.length;
// Each kind reads its newest `RECORD_ROWS_PER_KIND`; a kind that
// filled the read may have older rows the search did not see.
const perKind = new Map<string, number>();
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 >= RECORD_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<SearchHitKind, SearchGroup>();
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,
},
};
}
+72
View File
@@ -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:<source>` 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<SearchSourceKey, SearchHitKind> = {
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<SearchSourceKey, ShareDomain> = {
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",
};
+76
View File
@@ -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" });