mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(mcp): share one record-link builder and list labs and medications on a document
The links fetch lists beside a visit, condition, document or vaccination move out of record-search into record-links, so the lab and medication reads can list theirs through the same builder. A linked document imported from Paperless-ngx or Papra now names its source system and its id there, never the instance address. fetch document: also lists the lab results read from it and the medications filed against it, each behind its own module.
This commit is contained in:
1 parent
577e43004f
commit
ea7c6c860e
3 files changed
+288
-100
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94362,
|
||||
"total": 94292,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -69,7 +69,7 @@
|
||||
"lib/jobs/offhost-backup.ts": 1094,
|
||||
"lib/jobs/reaction-line.ts": 803,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1467,
|
||||
"lib/mcp/record-search.ts": 1397,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
"lib/measurements/dense-intraday-retention.ts": 1016,
|
||||
"lib/measurements/import-apple-health-export.ts": 1722,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The one-hop links an MCP read lists beside a record: `fetch` on a visit,
|
||||
* condition, document, vaccination, lab or medication, and the lab and
|
||||
* medication list reads. One builder, so every read names a linked record the
|
||||
* same way: an id `fetch` accepts, a label, a date, and for an imported
|
||||
* document the system it came from and its id there.
|
||||
*
|
||||
* Reads go through the link service only (`@/lib/links`), which narrows the
|
||||
* link rows AND the targets to the caller's account, so another account's
|
||||
* record cannot appear here. A target kind whose module is switched off is not
|
||||
* read at all, the way `fetch` has always treated a switched-off kind.
|
||||
*
|
||||
* Labels are the person's own words (a document title, an analyte, a
|
||||
* medication name): scrubbed of forged fence markers in structured fields, and
|
||||
* fenced in the prose sentence.
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
|
||||
import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { listTargetsBySource } from "@/lib/links";
|
||||
import type {
|
||||
LinkSourceKind,
|
||||
LinkTargetKind,
|
||||
LinkedTarget,
|
||||
} from "@/lib/links/link-service";
|
||||
import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
toDocumentSourceSystem,
|
||||
type DocumentSourceSystemValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** One-hop links one record lists. */
|
||||
export const MAX_LINKS = 25;
|
||||
|
||||
/** What a medication lists as linked, in this order. */
|
||||
export const MEDICATION_LINK_TARGETS = [
|
||||
"conditionEpisode",
|
||||
"document",
|
||||
"encounter",
|
||||
] as const;
|
||||
|
||||
export type RecordLinkKind =
|
||||
"visit" | "condition" | "document" | "vaccination" | "lab" | "medication";
|
||||
|
||||
export interface RecordLink {
|
||||
id: string | null;
|
||||
kind: RecordLinkKind;
|
||||
label: string;
|
||||
date: string | null;
|
||||
/**
|
||||
* For a document imported from Paperless-ngx or Papra: the system and the
|
||||
* document's id there. Never the instance address.
|
||||
*/
|
||||
sourceSystem?: DocumentSourceSystemValue;
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
/** The module each link target belongs to; a visit belongs to none. */
|
||||
const TARGET_MODULE: Record<LinkTargetKind, ModuleKey | null> = {
|
||||
encounter: null,
|
||||
document: "inboundDocuments",
|
||||
conditionEpisode: "illness",
|
||||
labResult: "labs",
|
||||
medication: "medications",
|
||||
vaccination: "vaccinations",
|
||||
};
|
||||
|
||||
/** The link kind and the `fetch` id prefix of each target kind. */
|
||||
const TARGET_LINK: Record<
|
||||
Exclude<LinkTargetKind, "labResult">,
|
||||
{ kind: RecordLinkKind; prefix: string }
|
||||
> = {
|
||||
encounter: { kind: "visit", prefix: "visit" },
|
||||
document: { kind: "document", prefix: "document" },
|
||||
conditionEpisode: { kind: "condition", prefix: "condition" },
|
||||
medication: { kind: "medication", prefix: "med" },
|
||||
vaccination: { kind: "vaccination", prefix: "vaccination" },
|
||||
};
|
||||
|
||||
function linkDate(iso: string | null): string | null {
|
||||
return iso ? iso.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
|
||||
/** A lab result is fetched by its analyte, so the link id names that. */
|
||||
async function labLinks(
|
||||
userId: string,
|
||||
targets: readonly LinkedTarget[],
|
||||
): Promise<Map<string, RecordLink>> {
|
||||
// Asked here as well as by every caller: an analyte name is lab data.
|
||||
if (!(await isModuleEnabled(userId, "labs"))) return new Map();
|
||||
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]));
|
||||
const out = new Map<string, RecordLink>();
|
||||
for (const target of targets) {
|
||||
const name = analyte.get(target.id);
|
||||
if (!name) continue;
|
||||
out.set(target.id, {
|
||||
id: `lab:${name}`,
|
||||
kind: "lab",
|
||||
label: scrubFenceMarkers(target.label),
|
||||
date: linkDate(target.date),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** An imported document says where it came from: system and id, no address. */
|
||||
async function documentSources(
|
||||
userId: string,
|
||||
targets: readonly LinkedTarget[],
|
||||
): Promise<Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>> {
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where: { userId, id: { in: targets.map((t) => t.id) } },
|
||||
select: { id: true, sourceSystem: true, sourceId: true },
|
||||
});
|
||||
const out = new Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>();
|
||||
for (const row of rows) {
|
||||
const system = toDocumentSourceSystem(row.sourceSystem);
|
||||
if (system && row.sourceId) {
|
||||
out.set(row.id, {
|
||||
sourceSystem: system,
|
||||
sourceId: scrubFenceMarkers(row.sourceId),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resolve one target kind's targets into links, keyed by target id. */
|
||||
async function resolveLinks(
|
||||
userId: string,
|
||||
targetKind: LinkTargetKind,
|
||||
targets: readonly LinkedTarget[],
|
||||
): Promise<Map<string, RecordLink>> {
|
||||
if (targets.length === 0) return new Map();
|
||||
if (targetKind === "labResult") return labLinks(userId, targets);
|
||||
const { kind, prefix } = TARGET_LINK[targetKind];
|
||||
const sources =
|
||||
targetKind === "document"
|
||||
? await documentSources(userId, targets)
|
||||
: new Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>();
|
||||
return new Map(
|
||||
targets.map((target) => [
|
||||
target.id,
|
||||
{
|
||||
id: `${prefix}:${target.id}`,
|
||||
kind,
|
||||
label: scrubFenceMarkers(
|
||||
targetKind === "encounter" ? visitLinkLabel(target) : target.label,
|
||||
),
|
||||
date: linkDate(target.date),
|
||||
...sources.get(target.id),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Links for targets already read by the caller, in their order. */
|
||||
export async function linksFor(
|
||||
userId: string,
|
||||
targetKind: LinkTargetKind,
|
||||
targets: readonly LinkedTarget[],
|
||||
): Promise<RecordLink[]> {
|
||||
const resolved = await resolveLinks(userId, targetKind, targets);
|
||||
return targets.flatMap((t) => resolved.get(t.id) ?? []);
|
||||
}
|
||||
|
||||
/** The target kinds whose module is on for this account, in order. */
|
||||
export async function visibleTargetKinds(
|
||||
userId: string,
|
||||
kinds: readonly LinkTargetKind[],
|
||||
): Promise<LinkTargetKind[]> {
|
||||
const on = await Promise.all(
|
||||
kinds.map((kind) => {
|
||||
const owner = TARGET_MODULE[kind];
|
||||
return owner ? isModuleEnabled(userId, owner) : true;
|
||||
}),
|
||||
);
|
||||
return kinds.filter((_, i) => on[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The links of many sources at once, keyed by source id, unbounded (bound
|
||||
* them per record with {@link boundLinks}). One link read and one target read
|
||||
* per target kind for the whole set, so a list of readings costs the same few
|
||||
* round trips as one. A source with no link maps to an empty list.
|
||||
*/
|
||||
export async function linksBySource(
|
||||
userId: string,
|
||||
sourceKind: LinkSourceKind,
|
||||
sourceIds: readonly string[],
|
||||
targetKinds: readonly LinkTargetKind[],
|
||||
): Promise<Map<string, RecordLink[]>> {
|
||||
const out = new Map<string, RecordLink[]>(sourceIds.map((id) => [id, []]));
|
||||
if (sourceIds.length === 0) return out;
|
||||
const visible = await visibleTargetKinds(userId, targetKinds);
|
||||
const perKind = await Promise.all(
|
||||
visible.map(async (targetKind) => {
|
||||
const bySource = await listTargetsBySource(prisma, {
|
||||
userId,
|
||||
sourceKind,
|
||||
sourceIds: [...sourceIds],
|
||||
targetKind,
|
||||
});
|
||||
const resolved = await resolveLinks(
|
||||
userId,
|
||||
targetKind,
|
||||
[...bySource.values()].flat(),
|
||||
);
|
||||
return { bySource, resolved };
|
||||
}),
|
||||
);
|
||||
for (const { bySource, resolved } of perKind) {
|
||||
for (const [sourceId, targets] of bySource) {
|
||||
const list = out.get(sourceId);
|
||||
if (!list) continue;
|
||||
list.push(...targets.flatMap((t) => resolved.get(t.id) ?? []));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The prose form of a record's links, with the same empty answer everywhere. */
|
||||
export function linksSentence(links: readonly RecordLink[]): string {
|
||||
if (links.length === 0) return "Linked records: none.";
|
||||
return `Linked records: ${links
|
||||
.map((l) => {
|
||||
const detail = [
|
||||
l.date,
|
||||
l.sourceSystem && l.sourceId
|
||||
? `${l.sourceSystem} id ${fenceUserText(l.sourceId)}`
|
||||
: null,
|
||||
].filter((d): d is string => d !== null);
|
||||
return `${l.kind} ${fenceUserText(l.label)}${
|
||||
detail.length > 0 ? ` (${detail.join(", ")})` : ""
|
||||
}`;
|
||||
})
|
||||
.join("; ")}.`;
|
||||
}
|
||||
|
||||
export function boundLinks(links: readonly RecordLink[]): {
|
||||
links: RecordLink[];
|
||||
linksTruncated: boolean;
|
||||
} {
|
||||
return {
|
||||
links: links.slice(0, MAX_LINKS),
|
||||
linksTruncated: links.length > MAX_LINKS,
|
||||
};
|
||||
}
|
||||
@@ -58,7 +58,13 @@ 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 {
|
||||
boundLinks,
|
||||
linksBySource,
|
||||
linksFor,
|
||||
linksSentence,
|
||||
type RecordLink,
|
||||
} from "./record-links";
|
||||
import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
@@ -83,8 +89,6 @@ export const MAX_ROWS_PER_KIND = 500;
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
@@ -923,13 +927,6 @@ export async function loadRecordCandidates(
|
||||
|
||||
// ── Fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RecordLink {
|
||||
id: string | null;
|
||||
kind: RecordKind | "lab";
|
||||
label: string;
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
export interface FetchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -938,72 +935,6 @@ export interface FetchResult {
|
||||
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,
|
||||
@@ -1075,9 +1006,9 @@ async function fetchVisit(
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const all: RecordLink[] = [
|
||||
...toLinks("condition", conditions),
|
||||
...toLinks("document", documents),
|
||||
...(await labLinks(userId, labs)),
|
||||
...(await linksFor(userId, "conditionEpisode", conditions)),
|
||||
...(await linksFor(userId, "document", documents)),
|
||||
...(await linksFor(userId, "labResult", labs)),
|
||||
...doses.map((dose) => ({
|
||||
id: `vaccination:${dose.id}`,
|
||||
kind: "vaccination" as const,
|
||||
@@ -1171,8 +1102,8 @@ async function fetchCondition(
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const { links, linksTruncated } = boundLinks([
|
||||
...toLinks("visit", visits, visitLinkLabel),
|
||||
...toLinks("document", documents),
|
||||
...(await linksFor(userId, "encounter", visits)),
|
||||
...(await linksFor(userId, "document", documents)),
|
||||
]);
|
||||
|
||||
const bodySite = decryptText(row.bodySiteEncrypted);
|
||||
@@ -1246,12 +1177,20 @@ async function documentExcerpt(
|
||||
}
|
||||
}
|
||||
|
||||
/** What a document lists as linked, in this order. */
|
||||
const DOCUMENT_LINK_TARGETS = [
|
||||
"encounter",
|
||||
"conditionEpisode",
|
||||
"vaccination",
|
||||
"labResult",
|
||||
"medication",
|
||||
] as const;
|
||||
|
||||
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.
|
||||
@@ -1270,24 +1209,15 @@ async function fetchDocument(
|
||||
// and still are.
|
||||
const deferred = row.aiReadDeferred;
|
||||
|
||||
const source = { userId, sourceKind: "document" as const, sourceId: rid };
|
||||
const [content, visits, conditions, doses] = await Promise.all([
|
||||
// The lab results read from this document and the medications filed
|
||||
// against it are listed too, each behind its own module like the rest.
|
||||
const [content, linked] = 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),
|
||||
linksBySource(userId, "document", [rid], DOCUMENT_LINK_TARGETS),
|
||||
]);
|
||||
const { links, linksTruncated } = boundLinks(linked.get(rid) ?? []);
|
||||
|
||||
const kindLabel = documentKindLabel(row.kind, "en");
|
||||
const name = row.title ?? row.filename ?? kindLabel;
|
||||
@@ -1371,7 +1301,7 @@ async function fetchVaccination(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...toLinks("document", documents),
|
||||
...(await linksFor(userId, "document", documents)),
|
||||
]);
|
||||
|
||||
const catalog = vaccineCatalogName(row.antigenSlug, "en");
|
||||
@@ -1460,7 +1390,7 @@ export async function fetchRecord(
|
||||
await loadOwner(userId),
|
||||
);
|
||||
case "document":
|
||||
return fetchDocument(userId, id, rid, origin, kinds);
|
||||
return fetchDocument(userId, id, rid, origin);
|
||||
case "vaccination":
|
||||
return fetchVaccination(userId, id, rid, origin, kinds);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user