Files
HealthLog/src/lib/mcp/record-search.ts
T

1468 lines
47 KiB
TypeScript

/**
* The clinical records behind `search` and `fetch`: visits (procedures
* included), conditions, documents and vaccinations (v1.39.3, Discussion
* #1025).
*
* ChatGPT in its default mode can call only `search` and `fetch`. Before this
* module those two covered metric domains, medications and lab analytes, so a
* question like "when was my left knee operation" or "what did the discharge
* letter say" had nothing to land on: the visit history lived behind
* `get_visits`, which that mode never calls.
*
* Matching. Visit reasons, outcomes and body sites are ciphertext at rest, and
* so are condition sites and notes, so matching cannot run in SQL. It runs
* here, after the decrypt, over the account's own rows only and bounded per
* kind (`MAX_ROWS_PER_KIND`, the same bound the body-sites view uses). The
* folding is the body-sites service's (`normalizeSearchText`), so "Knie" and
* "knie " are one word here as they are one site there. A document is matched
* on its title, file name and kind, and, when the owner has a content index,
* 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 and folding live in `@/lib/search/match`, shared with the in-app
* search (`POST /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.
* Visits belong to no module. Links follow the same rule per target kind.
*
* AI capability. These are reads of the person's own data over their own MCP
* connection, like every other MCP read, so no AI capability gates them. The
* one piece of model-written text a document can carry, its stored summary,
* is deliberately not returned: showing stored model text is a capability
* question, and the indexed text is the document's own words.
*
* Free text. Everything a person or a document wrote is fenced in `text` and
* in the long metadata fields; short labels (titles, practitioner names, link
* labels) are scrubbed of forged markers instead, as the existing search
* titles are, because a host renders them in a list.
*/
import type {
EncounterKind,
IllnessLifecycle,
IllnessType,
InboundDocumentKind,
Laterality,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import {
decryptIndexText,
decryptVerbatimText,
hashQueryTokens,
} from "@/lib/documents/content-index";
import { normalizeSearchText } from "@/lib/encounters/procedures";
import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { listTargets } from "@/lib/links";
import type { LinkedTarget } from "@/lib/links/link-service";
import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import {
CUSTOM_VACCINE_RESOLVE_SELECT,
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;
/** 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. */
export const MAX_DOCUMENT_EXCERPT_CHARS = 1500;
/** One-hop links one `fetch` returns. */
export const MAX_LINKS = 25;
// ── Kinds and their modules ──────────────────────────────────────────
export type RecordKind = "visit" | "condition" | "document" | "vaccination";
export const RECORD_KINDS: readonly RecordKind[] = [
"visit",
"condition",
"document",
"vaccination",
];
/**
* The module that owns each record kind. A visit belongs to none: the visit
* history and the procedure list are never switched off.
*/
export const RECORD_KIND_MODULE: Record<RecordKind, ModuleKey | null> = {
visit: null,
condition: "illness",
document: "inboundDocuments",
vaccination: "vaccinations",
};
export function isRecordKind(value: string): value is RecordKind {
return (RECORD_KINDS as readonly string[]).includes(value);
}
/** Which record kinds this account can read right now. */
async function readableKinds(userId: string): Promise<Set<RecordKind>> {
const out = new Set<RecordKind>();
await Promise.all(
RECORD_KINDS.map(async (kind) => {
const owner = RECORD_KIND_MODULE[kind];
if (!owner || (await isModuleEnabled(userId, owner))) out.add(kind);
}),
);
return out;
}
// ── Matching and ranking ─────────────────────────────────────────────
// Shared with the in-app search; see `@/lib/search/match`.
// ── Labels ───────────────────────────────────────────────────────────
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/** "DISCHARGE_LETTER" → "discharge letter". */
function enumWords(value: string): string {
return value.toLowerCase().replace(/_/g, " ");
}
/**
* Words a person uses for a visit kind that neither the enum nor the bundle
* label carries. "Operation" is the common one: nobody asks for their "left
* knee procedure or surgery".
*/
const ENCOUNTER_KIND_ALIASES: Partial<Record<EncounterKind, string>> = {
PROCEDURE: "surgery surgical operation operiert eingriff",
HOSPITAL: "inpatient krankenhaus klinik",
EMERGENCY: "notaufnahme notfall",
};
const LATERALITY_WORDS: Record<Laterality, string> = {
LEFT: "left",
RIGHT: "right",
BOTH: "both sides",
};
function sideTerms(
laterality: Laterality | null,
owner: Locale,
): string | null {
if (!laterality) return null;
const key =
laterality === "LEFT"
? "encounters.laterality.left"
: laterality === "RIGHT"
? "encounters.laterality.right"
: "encounters.laterality.both";
const en = getServerTranslator("en").t(key);
const own = getServerTranslator(owner).t(key);
return `${LATERALITY_WORDS[laterality]} ${en} ${own}`;
}
function documentKindLabel(kind: InboundDocumentKind, locale: Locale): string {
return getServerTranslator(locale).t(`documents.kind.${kind}`);
}
function illnessTypeLabel(type: IllnessType, locale: Locale): string {
return getServerTranslator(locale).t(`illness.type.${type}`);
}
function lifecycleLabel(lifecycle: IllnessLifecycle): string {
return getServerTranslator("en").t(`illness.lifecycle.${lifecycle}`);
}
/** The catalogue name of a dose's slug, or null for a slug it does not know. */
function vaccineCatalogName(
slug: string | null,
locale: Locale,
): string | null {
const entry = resolveVaccineEntry({ antigenSlug: slug });
if (!entry?.slug) return null;
return getServerTranslator(locale).t(`vaccinations.catalog.${entry.slug}`);
}
/** Decrypt a free-text `Bytes` column fail-soft: a rotation 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;
}
}
function clip(text: string, max: number): { text: string; truncated: boolean } {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= max) return { text: collapsed, truncated: false };
return { text: `${collapsed.slice(0, max).trimEnd()}…`, truncated: true };
}
function dateOnly(value: Date | null): string | null {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: text label mixing date-only columns and instants, read without a zone
return value ? value.toISOString().slice(0, 10) : null;
}
// ── Deep links ───────────────────────────────────────────────────────
// Pages behind the normal sign-in, never an API path that serves bytes.
export function recordUrl(
origin: string,
kind: RecordKind,
id: string,
): string {
const rid = encodeURIComponent(id);
switch (kind) {
case "visit":
return `${origin}/checkups?visit=${rid}`;
case "condition":
return `${origin}/illness/${rid}`;
case "document":
return `${origin}/documents?doc=${rid}`;
case "vaccination":
return `${origin}/vaccinations?dose=${rid}`;
}
}
/**
* Record kinds sort after every older search kind on a tie. The older kinds
* use their assembly position (at most a few thousand), so the base sits far
* above it.
*/
const KIND_ORDER_BASE = 1_000_000;
const KIND_ORDER: Record<RecordKind, number> = {
visit: KIND_ORDER_BASE,
condition: KIND_ORDER_BASE + 1,
document: KIND_ORDER_BASE + 2,
vaccination: KIND_ORDER_BASE + 3,
};
// ── Search: candidates per kind ──────────────────────────────────────
interface Owner {
locale: Locale;
timezone: string;
/** The language of the in-app display data (`SearchCandidate.hit`). */
display: Locale;
}
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,
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;
bodySite: string | null;
laterality: Laterality | null;
practitioner: string | null;
status: string;
}): string {
const parts = [encounterKindLabel(args.kind, "en")];
if (args.bodySite) {
parts.push(
args.laterality && args.laterality !== "BOTH"
? `${LATERALITY_WORDS[args.laterality]} ${args.bodySite}`
: args.bodySite,
);
}
if (args.practitioner) parts.push(args.practitioner);
const status = args.status === "DONE" ? "" : `, ${enumWords(args.status)}`;
return scrubFenceMarkers(`${parts.join(" · ")} (${args.day}${status})`);
}
/**
* The practitioner columns a record read selects. `deletedAt` rides along so
* a practitioner the person removed contributes nothing: the visit keeps its
* scalar id, but the name is no longer something the record says.
*/
const PRACTITIONER_SELECT = {
select: { name: true, specialty: true, deletedAt: true },
} as const;
type PractitionerRow = {
name: string;
specialty: string | null;
deletedAt: Date | null;
} | null;
/** The practitioner as the record may still show it, or null. */
export function livePractitioner(
row: PractitionerRow | undefined,
): { name: string; specialty: string | null } | null {
return row && row.deletedAt === null
? { name: row.name, specialty: row.specialty }
: null;
}
async function visitCandidates(
userId: string,
owner: Owner,
origin: string,
read: KindRead,
empty: boolean,
): Promise<SearchCandidate[]> {
const rows = await prisma.encounter.findMany({
// The empty-query listing is "what is in my record": things that
// happened. A booked appointment is still found by any query naming it.
where: {
userId,
deletedAt: null,
...(empty ? { occurredAt: { lte: new Date() } } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take: probeTake(read),
include: { practitioner: PRACTITIONER_SELECT },
});
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
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({
kind: row.kind,
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner: practitioner?.name ?? null,
status: row.status,
}),
url: recordUrl(origin, "visit", row.id),
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, body: true },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
text: [
"visit",
enumWords(row.kind),
encounterKindLabel(row.kind, "en"),
encounterKindLabel(row.kind, owner.locale),
ENCOUNTER_KIND_ALIASES[row.kind] ?? "",
].join(" "),
weight: WEIGHT.secondary,
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: outcome ?? "", weight: WEIGHT.secondary, body: true },
],
};
},
);
}
/**
* The condition columns MCP reads. An explicit list, and the note is not on
* it: a condition's note is never handed to an AI surface (the Coach illness
* snapshot and the doctor report leave it out too), and an outside assistant
* is one. `no-note-columns-guard.test.ts` holds every MCP read to this.
*/
const CONDITION_SELECT = {
id: true,
label: true,
type: true,
lifecycle: true,
onsetAt: true,
resolvedAt: true,
bodySiteEncrypted: true,
laterality: true,
} as const;
/** The dose columns MCP reads; the note is left out for the same reason. */
const VACCINATION_SELECT = {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
doseNumber: true,
seriesDoses: true,
lotNumber: true,
site: true,
practitioner: PRACTITIONER_SELECT,
// v1.42 (#1005) — the person's own definition, for its name and antigens.
customVaccineId: true,
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
} as const;
/**
* The person's own vaccine definition a dose names, when it is the answer
* (the catalogue wins when the dose also carries a slug). Its name is user
* text and is scrubbed or fenced by the caller.
*/
function ownVaccine(row: {
antigenSlug: string | null;
customVaccineId: string | null;
customVaccine: Parameters<typeof customLookupOf>[0][number];
}): { name: string; components: readonly string[] } | null {
const entry = resolveVaccineEntry(row, customLookupOf([row.customVaccine]));
return entry?.kind === "custom" && entry.name
? { name: entry.name, components: entry.components }
: null;
}
async function conditionCandidates(
userId: string,
owner: Owner,
origin: string,
read: KindRead,
): Promise<SearchCandidate[]> {
const rows = await prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
orderBy: [{ onsetAt: "desc" }, { id: "asc" }],
take: probeTake(read),
select: CONDITION_SELECT,
});
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.onsetAt, owner.timezone);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
id: `condition:${row.id}`,
title: scrubFenceMarkers(
`${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`,
),
url: recordUrl(origin, "condition", row.id),
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 },
{
text: [
"condition illness",
enumWords(row.type),
illnessTypeLabel(row.type, "en"),
illnessTypeLabel(row.type, owner.locale),
enumWords(row.lifecycle),
].join(" "),
weight: WEIGHT.secondary,
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
],
};
},
);
}
const DOCUMENT_SELECT = {
id: true,
title: true,
filename: true,
kind: true,
documentDate: true,
reportDate: true,
createdAt: true,
} as const;
/**
* The account's documents whose indexed text holds each query word, keyed by
* document id. Whole words only (the index is a set of one-way token hashes),
* with the vault's synonym expansion. Never reads the text itself.
*/
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(
words.map(async (word) => {
const hashes = hashQueryTokens(word);
if (hashes.length === 0) return;
const rows = await prisma.documentContentIndex.findMany({
where: {
userId,
searchTokens: { hasSome: hashes },
// A document held back from AI reading is not searched by its
// 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,
});
for (const row of rows) {
const set = byDocument.get(row.documentId) ?? new Set<string>();
set.add(word);
byDocument.set(row.documentId, set);
}
}),
);
return byDocument;
}
/**
* The terms a title or file name is looked up by in SQL: the folded query
* words plus the raw lower-cased ones, because the columns keep their accents
* and "Schädel" folds to "schadel". Short words stay out: a three-letter
* substring over every title is noise, and the in-memory pass still sees
* them for the newest documents.
*/
function documentNameTerms(query: string, words: readonly string[]): string[] {
const raw = query
.toLowerCase()
.split(/[^\p{L}\p{N}._-]+/u)
.filter((w) => w.length >= MIN_SUBSTRING_WORD);
return [
...new Set([
...words.filter((w) => w.length >= MIN_SUBSTRING_WORD),
...raw,
]),
].slice(0, MAX_QUERY_WORDS * 2);
}
async function documentCandidates(
userId: string,
owner: Owner,
origin: string,
read: KindRead,
query: string,
words: readonly string[],
includeAiDeferred: boolean,
): Promise<SearchCandidate[]> {
const nameTerms = documentNameTerms(query, words);
const [probed, content, named] = await Promise.all([
prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
select: DOCUMENT_SELECT,
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: probeTake(read),
}),
words.length > 0
? 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.
nameTerms.length > 0
? prisma.inboundDocument.findMany({
where: {
userId,
deletedAt: null,
OR: nameTerms.flatMap((term) => [
{ title: { contains: term, mode: "insensitive" as const } },
{ filename: { contains: term, mode: "insensitive" as const } },
]),
},
select: { id: true },
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: MAX_ROWS_PER_KIND,
})
: Promise.resolve([] as Array<{ id: string }>),
]);
// The cap applies to the newest read; the extra rows below are content and
// name hits beyond it, already bounded by their own read.
const rows = withinCap(probed, read);
// A content or name hit on a document older than the newest `take` still
// counts; the extra read is bounded like the others.
const seen = new Set(rows.map((r) => r.id));
const missing = [
...new Set([...content.keys(), ...named.map((r) => r.id)]),
].filter((id) => !seen.has(id));
const extra =
missing.length > 0
? await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null, id: { in: missing } },
select: DOCUMENT_SELECT,
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: MAX_ROWS_PER_KIND,
})
: [];
return mapUntilStopped([...rows, ...extra], read, (row): SearchCandidate => {
const dated = row.documentDate ?? row.reportDate;
const kindLabel = documentKindLabel(row.kind, "en");
const name = row.title ?? row.filename ?? kindLabel;
return {
id: `document:${row.id}`,
title: scrubFenceMarkers(
`${name} (${kindLabel}${dated ? `, ${dateOnly(dated)}` : ""})`,
),
url: recordUrl(origin, "document", row.id),
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 },
{ text: row.filename ?? "", weight: WEIGHT.primary },
{
text: [
"document",
enumWords(row.kind),
kindLabel,
documentKindLabel(row.kind, owner.locale),
].join(" "),
weight: WEIGHT.secondary,
},
],
};
});
}
async function vaccinationCandidates(
userId: string,
owner: Owner,
origin: string,
read: KindRead,
): Promise<SearchCandidate[]> {
const rows = await prisma.vaccinationRecord.findMany({
where: { userId, deletedAt: null },
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take: probeTake(read),
select: VACCINATION_SELECT,
});
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
row.vaccineName ??
catalogEn ??
own?.name ??
row.antigenSlug ??
"Vaccination";
const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : "";
const day = dateOnly(row.occurredAt);
return {
id: `vaccination:${row.id}`,
title: scrubFenceMarkers(`${name} (${dose}${day})`),
url: recordUrl(origin, "vaccination", row.id),
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 },
{
text: [
catalogEn ?? "",
vaccineCatalogName(row.antigenSlug, owner.locale) ?? "",
row.antigenSlug
? enumWords(row.antigenSlug.replace(/-/g, "_"))
: "",
...(entry?.synonyms ?? []),
own?.name ?? "",
...(own?.components ?? []).map((antigen) =>
enumWords(antigen.replace(/-/g, "_")),
),
].join(" "),
weight: WEIGHT.primary,
},
{
text: "vaccination vaccine immunization impfung",
weight: WEIGHT.secondary,
},
{ text: practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.secondary },
],
};
},
);
}
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;
/**
* Asked before each kind starts and before every row a kind decrypts.
* Once it says stop, the kind keeps what it has (its newest rows) and
* `onTruncated` is told. The in-app search hands its shared time budget
* and the request's abort signal in here.
*/
shouldStop?: () => boolean;
/**
* Told when a kind did not see all of its rows: it filled its newest
* `rowsPerKind` read, or `shouldStop` ended it part-way.
*/
onTruncated?: (kind: RecordKind) => void;
}
/** How one kind reads: its row cap, and the stop and truncation hooks. */
interface KindRead {
take: number;
shouldStop: () => boolean;
onTruncated: () => void;
}
/**
* A kind's newest read asks for one row past its cap. The extra row only
* answers whether anything older is left: a kind holding exactly `take` rows
* was read whole and is not reported cut short.
*/
function probeTake(read: KindRead): number {
return read.take + 1;
}
/** The rows within the cap, telling `onTruncated` when the probe row came. */
function withinCap<Row>(rows: Row[], read: KindRead): Row[] {
if (rows.length <= read.take) return rows;
read.onTruncated();
return rows.slice(0, read.take);
}
/**
* Map a kind's rows newest first until `shouldStop` says stop. The rows are
* read already; what this bounds is the decrypting and the building, which is
* where a large record spends its time.
*/
function mapUntilStopped<Row, Out>(
rows: readonly Row[],
read: KindRead,
build: (row: Row) => Out,
): Out[] {
const out: Out[] = [];
for (const row of rows) {
if (read.shouldStop()) {
read.onTruncated();
break;
}
out.push(build(row));
}
return out;
}
/**
* Every record candidate this account can see for one query. Unranked; the
* 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
: (options.rowsPerKind ?? MAX_ROWS_PER_KIND);
const [kinds, owner] = await Promise.all([
options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId),
loadOwner(userId, options.displayLocale),
]);
const shouldStop = options.shouldStop ?? (() => false);
const reading = (kind: RecordKind): KindRead | null => {
if (!kinds.has(kind)) return null;
const onTruncated = () => options.onTruncated?.(kind);
if (shouldStop()) {
onTruncated();
return null;
}
return { take, shouldStop, onTruncated };
};
const visit = reading("visit");
const condition = reading("condition");
const document = reading("document");
const vaccination = reading("vaccination");
const groups = await Promise.all([
visit
? visitCandidates(userId, owner, origin, visit, empty)
: Promise.resolve([]),
condition
? conditionCandidates(userId, owner, origin, condition)
: Promise.resolve([]),
document
? documentCandidates(
userId,
owner,
origin,
document,
query,
words,
options.includeAiDeferredText === true,
)
: Promise.resolve([]),
vaccination
? vaccinationCandidates(userId, owner, origin, vaccination)
: Promise.resolve([]),
]);
return groups.flat();
}
// ── Fetch ────────────────────────────────────────────────────────────
export interface RecordLink {
id: string | null;
kind: RecordKind | "lab";
label: string;
date: string | null;
}
export interface FetchResult {
id: string;
title: string;
text: string;
url: string;
metadata: Record<string, unknown>;
}
function linkDate(iso: string | null): string | null {
return iso ? iso.slice(0, 10) : null;
}
function toLinks(
kind: RecordKind,
targets: readonly LinkedTarget[],
label: (target: LinkedTarget) => string = (t) => t.label,
): RecordLink[] {
return targets.map((target) => ({
id: `${kind}:${target.id}`,
kind,
label: scrubFenceMarkers(label(target)),
date: linkDate(target.date),
}));
}
/** A linked visit is labelled by its kind, in English on this wire. */
function visitLinkLabel(target: LinkedTarget): string {
return encounterKindLabel(target.label as EncounterKind, "en");
}
async function labLinks(
userId: string,
targets: readonly LinkedTarget[],
): Promise<RecordLink[]> {
if (targets.length === 0) return [];
const rows = await prisma.labResult.findMany({
where: { userId, id: { in: targets.map((t) => t.id) }, deletedAt: null },
select: { id: true, analyte: true },
});
const analyte = new Map(rows.map((r) => [r.id, r.analyte]));
return targets.flatMap((target) => {
const name = analyte.get(target.id);
if (!name) return [];
return [
{
id: `lab:${name}`,
kind: "lab" as const,
label: scrubFenceMarkers(target.label),
date: linkDate(target.date),
},
];
});
}
function linksSentence(links: readonly RecordLink[]): string {
if (links.length === 0) return "Linked records: none.";
return `Linked records: ${links
.map(
(l) =>
`${l.kind} ${fenceUserText(l.label)}${l.date ? ` (${l.date})` : ""}`,
)
.join("; ")}.`;
}
function boundLinks(links: RecordLink[]): {
links: RecordLink[];
linksTruncated: boolean;
} {
return {
links: links.slice(0, MAX_LINKS),
linksTruncated: links.length > MAX_LINKS,
};
}
function moduleOff(
id: string,
kind: RecordKind,
origin: string,
module: ModuleKey,
): FetchResult {
return {
id,
title: "Not available",
text: `This record kind is switched off in HealthLog (module "${module}"), so nothing of it is shared.`,
url: `${origin}/insights`,
metadata: { type: kind, present: false, reason: "module_disabled" },
};
}
function notFound(id: string, kind: RecordKind, origin: string): FetchResult {
return {
id,
title: "Not found",
text: `No ${kind} matches this id.`,
url: `${origin}/insights`,
metadata: { type: kind, present: false, reason: "not_found" },
};
}
function fenced(value: string | null): string | null {
return value === null ? null : fenceUserText(value);
}
async function fetchVisit(
userId: string,
id: string,
rid: string,
origin: string,
kinds: ReadonlySet<RecordKind>,
owner: Owner,
): Promise<FetchResult> {
const row = await prisma.encounter.findFirst({
where: { id: rid, userId, deletedAt: null },
include: { practitioner: PRACTITIONER_SELECT },
});
if (!row) return notFound(id, "visit", origin);
const labsOn = await isModuleEnabled(userId, "labs");
const source = { userId, sourceKind: "encounter" as const, sourceId: rid };
const [documents, labs, conditions, doses] = await Promise.all([
kinds.has("document")
? listTargets(prisma, { ...source, targetKind: "document" })
: Promise.resolve([]),
labsOn
? listTargets(prisma, { ...source, targetKind: "labResult" })
: Promise.resolve([]),
kinds.has("condition")
? listTargets(prisma, { ...source, targetKind: "conditionEpisode" })
: Promise.resolve([]),
kinds.has("vaccination")
? prisma.vaccinationRecord.findMany({
where: { userId, encounterId: rid, deletedAt: null },
select: {
id: true,
vaccineName: true,
antigenSlug: true,
occurredAt: true,
customVaccineId: true,
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
},
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
})
: Promise.resolve([]),
]);
const all: RecordLink[] = [
...toLinks("condition", conditions),
...toLinks("document", documents),
...(await labLinks(userId, labs)),
...doses.map((dose) => ({
id: `vaccination:${dose.id}`,
kind: "vaccination" as const,
label: scrubFenceMarkers(
dose.vaccineName ??
vaccineCatalogName(dose.antigenSlug, "en") ??
ownVaccine(dose)?.name ??
dose.antigenSlug ??
"Vaccination",
),
date: dateOnly(dose.occurredAt),
})),
];
const { links, linksTruncated } = boundLinks(all);
const reason = decryptText(row.reasonEncrypted);
const outcome = decryptText(row.outcomeEncrypted);
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.occurredAt, owner.timezone);
const kindLabel = encounterKindLabel(row.kind, "en");
const live = livePractitioner(row.practitioner);
const practitioner = live?.name ?? null;
const specialty = live?.specialty ?? null;
const sentences = [
`Visit on ${day}: ${kindLabel}, status ${row.status}.`,
practitioner
? `Practitioner: ${fenceUserText(practitioner)}${specialty ? ` (${fenceUserText(specialty)})` : ""}.`
: "No practitioner recorded.",
bodySite
? `Body site: ${fenceUserText(bodySite)}${row.laterality ? `, side ${row.laterality}` : ""}.`
: null,
reason ? `Reason: ${fenceUserText(reason)}.` : "No reason recorded.",
outcome ? `Outcome: ${fenceUserText(outcome)}.` : "No outcome recorded.",
linksSentence(links),
].filter((s): s is string => s !== null);
return {
id,
title: visitTitle({
kind: row.kind,
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner,
status: row.status,
}),
text: sentences.join(" "),
url: recordUrl(origin, "visit", rid),
metadata: {
type: "visit",
present: true,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitioner: practitioner ? scrubFenceMarkers(practitioner) : null,
specialty: specialty ? scrubFenceMarkers(specialty) : null,
bodySite: fenced(bodySite),
laterality: row.laterality,
reason: fenced(reason),
outcome: fenced(outcome),
links,
linksTruncated,
},
};
}
async function fetchCondition(
userId: string,
id: string,
rid: string,
origin: string,
kinds: ReadonlySet<RecordKind>,
owner: Owner,
): Promise<FetchResult> {
const row = await prisma.illnessEpisode.findFirst({
where: { id: rid, userId, deletedAt: null },
select: CONDITION_SELECT,
});
if (!row) return notFound(id, "condition", origin);
const source = {
userId,
sourceKind: "conditionEpisode" as const,
sourceId: rid,
};
const [visits, documents] = await Promise.all([
listTargets(prisma, { ...source, targetKind: "encounter" }),
kinds.has("document")
? listTargets(prisma, { ...source, targetKind: "document" })
: Promise.resolve([]),
]);
const { links, linksTruncated } = boundLinks([
...toLinks("visit", visits, visitLinkLabel),
...toLinks("document", documents),
]);
const bodySite = decryptText(row.bodySiteEncrypted);
const onset = userDayKey(row.onsetAt, owner.timezone);
const resolved = row.resolvedAt
? userDayKey(row.resolvedAt, owner.timezone)
: null;
const typeLabel = illnessTypeLabel(row.type, "en");
const sentences = [
`Condition ${fenceUserText(row.label)}: ${typeLabel}, ${lifecycleLabel(row.lifecycle).toLowerCase()}, since ${onset}${resolved ? `, resolved ${resolved}` : ", not marked resolved"}.`,
bodySite
? `Body site: ${fenceUserText(bodySite)}${row.laterality ? `, side ${row.laterality}` : ""}.`
: null,
linksSentence(links),
].filter((s): s is string => s !== null);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
id,
title: scrubFenceMarkers(
`${row.label}${site ? ` · ${site}` : ""} (${typeLabel}, since ${onset})`,
),
text: sentences.join(" "),
url: recordUrl(origin, "condition", rid),
metadata: {
type: "condition",
present: true,
label: scrubFenceMarkers(row.label),
illnessType: row.type,
lifecycle: row.lifecycle,
onsetAt: row.onsetAt.toISOString(),
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
bodySite: fenced(bodySite),
laterality: row.laterality,
links,
linksTruncated,
},
};
}
/**
* The indexed text of one document, bounded. Verbatim text first (casing and
* accents intact), the normalised search text for a row indexed before the
* verbatim capture existed. A decrypt failure reads as no excerpt rather than
* a failed fetch; the metadata says which.
*/
async function documentExcerpt(
userId: string,
documentId: string,
): Promise<{
indexed: boolean;
excerpt: { text: string; truncated: boolean } | null;
}> {
const row = await prisma.documentContentIndex.findFirst({
where: { documentId, userId },
select: { textEncrypted: true, verbatimTextEncrypted: true },
});
if (!row) return { indexed: false, excerpt: null };
try {
const text =
row.verbatimTextEncrypted && row.verbatimTextEncrypted.byteLength > 0
? decryptVerbatimText(row.verbatimTextEncrypted)
: decryptIndexText(row.textEncrypted);
return {
indexed: true,
excerpt: text.trim() ? clip(text, MAX_DOCUMENT_EXCERPT_CHARS) : null,
};
} catch {
return { indexed: true, excerpt: null };
}
}
async function fetchDocument(
userId: string,
id: string,
rid: string,
origin: string,
kinds: ReadonlySet<RecordKind>,
): Promise<FetchResult> {
// Metadata only. The stored bytes and the model-written summary are never
// selected, so neither can reach this answer by accident.
const row = await prisma.inboundDocument.findFirst({
where: { id: rid, userId, deletedAt: null },
select: {
...DOCUMENT_SELECT,
mimeType: true,
byteSize: true,
aiReadDeferred: true,
},
});
if (!row) return notFound(id, "document", origin);
// Held back from AI reading at upload: the indexed text is not read, let
// alone sent. The title, kind, dates and links are the record's metadata
// and still are.
const deferred = row.aiReadDeferred;
const source = { userId, sourceKind: "document" as const, sourceId: rid };
const [content, visits, conditions, doses] = await Promise.all([
deferred
? Promise.resolve({ indexed: false, excerpt: null })
: documentExcerpt(userId, rid),
listTargets(prisma, { ...source, targetKind: "encounter" }),
kinds.has("condition")
? listTargets(prisma, { ...source, targetKind: "conditionEpisode" })
: Promise.resolve([]),
kinds.has("vaccination")
? listTargets(prisma, { ...source, targetKind: "vaccination" })
: Promise.resolve([]),
]);
const { links, linksTruncated } = boundLinks([
...toLinks("visit", visits, visitLinkLabel),
...toLinks("condition", conditions),
...toLinks("vaccination", doses),
]);
const kindLabel = documentKindLabel(row.kind, "en");
const name = row.title ?? row.filename ?? kindLabel;
const dated = row.documentDate ?? row.reportDate;
const sentences = [
`Document ${fenceUserText(name)}: ${kindLabel}, ${dated ? `dated ${dateOnly(dated)}` : "undated"}, added ${dateOnly(row.createdAt)}.`,
deferred
? "This document was stored without AI reading, so its text is not shared here."
: content.excerpt
? `Indexed text${content.excerpt.truncated ? " (excerpt)" : ""}: ${fenceUserText(content.excerpt.text)}`
: content.indexed
? "The indexed text could not be read."
: "No indexed text is stored for this document.",
linksSentence(links),
];
return {
id,
title: scrubFenceMarkers(
`${name} (${kindLabel}${dated ? `, ${dateOnly(dated)}` : ""})`,
),
text: sentences.join(" "),
url: recordUrl(origin, "document", rid),
metadata: {
type: "document",
present: true,
kind: row.kind,
title: row.title ? scrubFenceMarkers(row.title) : null,
filename: row.filename ? scrubFenceMarkers(row.filename) : null,
documentDate: dateOnly(row.documentDate),
reportDate: dateOnly(row.reportDate),
createdAt: row.createdAt.toISOString(),
mimeType: row.mimeType,
byteSize: row.byteSize,
...(deferred ? { reason: "ai_read_deferred" } : {}),
indexed: content.indexed,
excerpt: content.excerpt ? fenceUserText(content.excerpt.text) : null,
excerptTruncated: content.excerpt?.truncated ?? false,
links,
linksTruncated,
},
};
}
async function fetchVaccination(
userId: string,
id: string,
rid: string,
origin: string,
kinds: ReadonlySet<RecordKind>,
): Promise<FetchResult> {
const row = await prisma.vaccinationRecord.findFirst({
where: { id: rid, userId, deletedAt: null },
select: {
...VACCINATION_SELECT,
encounter: {
select: { id: true, kind: true, occurredAt: true, deletedAt: true },
},
},
});
if (!row) return notFound(id, "vaccination", origin);
const documents = kinds.has("document")
? await listTargets(prisma, {
userId,
sourceKind: "vaccination",
sourceId: rid,
targetKind: "document",
})
: [];
const visit =
row.encounter && row.encounter.deletedAt === null ? row.encounter : null;
const { links, linksTruncated } = boundLinks([
...(visit
? [
{
id: `visit:${visit.id}`,
kind: "visit" as const,
label: encounterKindLabel(visit.kind, "en"),
date: dateOnly(visit.occurredAt),
},
]
: []),
...toLinks("document", documents),
]);
const catalog = vaccineCatalogName(row.antigenSlug, "en");
const own = ownVaccine(row);
const name =
row.vaccineName ?? catalog ?? own?.name ?? row.antigenSlug ?? "Vaccination";
const practitioner = livePractitioner(row.practitioner);
const day = dateOnly(row.occurredAt);
const dose =
row.doseNumber !== null
? `dose ${row.doseNumber}${row.seriesDoses ? ` of ${row.seriesDoses}` : ""}`
: "dose number not recorded";
const sentences = [
`Vaccination ${fenceUserText(name)} on ${day}, ${dose}.`,
catalog && row.vaccineName ? `Catalogue entry: ${catalog}.` : null,
own
? `Logged against the person's own vaccine ${fenceUserText(own.name)}${
own.components.length > 0
? `, which protects against ${own.components.join(", ")}`
: ""
}.`
: null,
practitioner
? `Given by ${fenceUserText(practitioner.name)}.`
: "No practitioner recorded.",
linksSentence(links),
].filter((s): s is string => s !== null);
return {
id,
title: scrubFenceMarkers(
`${name} (${row.doseNumber ? `dose ${row.doseNumber}, ` : ""}${day})`,
),
text: sentences.join(" "),
url: recordUrl(origin, "vaccination", rid),
metadata: {
type: "vaccination",
present: true,
occurredAt: day,
vaccineName: row.vaccineName ? scrubFenceMarkers(row.vaccineName) : null,
antigenSlug: row.antigenSlug,
catalogName: catalog,
doseNumber: row.doseNumber,
seriesDoses: row.seriesDoses,
lotNumber: row.lotNumber ? scrubFenceMarkers(row.lotNumber) : null,
site: row.site,
practitioner: practitioner ? scrubFenceMarkers(practitioner.name) : null,
links,
linksTruncated,
},
};
}
/**
* Hydrate one record by its search id. The module check runs before any row
* is read, so a switched-off kind answers without touching its table.
*/
export async function fetchRecord(
userId: string,
kind: RecordKind,
rid: string,
origin: string,
): Promise<FetchResult> {
const id = `${kind}:${rid}`;
const kinds = await readableKinds(userId);
const owner = RECORD_KIND_MODULE[kind];
if (owner && !kinds.has(kind)) return moduleOff(id, kind, origin, owner);
switch (kind) {
case "visit":
return fetchVisit(
userId,
id,
rid,
origin,
kinds,
await loadOwner(userId),
);
case "condition":
return fetchCondition(
userId,
id,
rid,
origin,
kinds,
await loadOwner(userId),
);
case "document":
return fetchDocument(userId, id, rid, origin, kinds);
case "vaccination":
return fetchVaccination(userId, id, rid, origin, kinds);
}
}