@pablotech/akesi 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ // Isomorphic marker->body-system prompt/schema + multi-pass convergence loop, so a Node caller
2
+ // and a zero-knowledge web path can share one implementation. The actual model call stays with
3
+ // each caller (an SDK singleton vs a runtime-bound key) — this module only builds requests and
4
+ // reconciles responses, injected via `callModel`.
5
+ import type { Client } from "./types";
6
+ import { UNCATEGORIZED } from "./system-groups";
7
+
8
+ export const GROUPS_SCHEMA = {
9
+ type: "object",
10
+ properties: {
11
+ groups: {
12
+ type: "array",
13
+ items: {
14
+ type: "object",
15
+ properties: {
16
+ group: { type: "string" },
17
+ markers: { type: "array", items: { type: "string" } },
18
+ },
19
+ required: ["group", "markers"],
20
+ additionalProperties: false,
21
+ },
22
+ },
23
+ },
24
+ required: ["groups"],
25
+ additionalProperties: false,
26
+ } as const;
27
+
28
+ export interface GroupsAIResponse {
29
+ groups: { group: string; markers: string[] }[];
30
+ }
31
+
32
+ // The full marker universe to place: every distinct marker with data, plus any
33
+ // watchlisted marker that has no reading yet (so it still gets a home).
34
+ export function distinctMarkerNames(client: Client): string[] {
35
+ const names = new Set<string>();
36
+ for (const r of client.results) names.add(r.marker);
37
+ for (const m of client.watchlist) names.add(m);
38
+ return [...names].sort((a, b) => a.localeCompare(b));
39
+ }
40
+
41
+ // cyrb53 — a small, non-cryptographic, deterministic string hash (this is a cache/staleness
42
+ // key, not a security boundary, so no need for node:crypto: that import isn't available in
43
+ // the browser, and this module is isomorphic — shared by the client, the Workers Function, and
44
+ // the Node ingest script). https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
45
+ function cyrb53(str: string, seed = 0): number {
46
+ let h1 = 0xdeadbeef ^ seed;
47
+ let h2 = 0x41c6ce57 ^ seed;
48
+ for (let i = 0; i < str.length; i++) {
49
+ const ch = str.charCodeAt(i);
50
+ h1 = Math.imul(h1 ^ ch, 2654435761);
51
+ h2 = Math.imul(h2 ^ ch, 1597334677);
52
+ }
53
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
54
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
55
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
56
+ }
57
+
58
+ // Hash over BOTH the marker set and the disease-group set: a new marker or a changed System
59
+ // Analysis both invalidate the grouping.
60
+ export function markerGroupsHashOf(markerNames: string[], systemGroups: string[]): string {
61
+ const a = [...markerNames].sort().join("\n");
62
+ const b = [...systemGroups].sort().join("\n");
63
+ return cyrb53(`${a}␞${b}`).toString(16).padStart(12, "0").slice(0, 12);
64
+ }
65
+
66
+ export const SYSTEM_PROMPT = [
67
+ "You assign a patient's lab/biometric markers to the patient's own body systems",
68
+ "(the AI Finding's disease areas), so each marker is read alongside the others",
69
+ "that speak to the same underlying system. This is cross-source: a marker may come",
70
+ "from bloodwork, a scan, or a body-composition scale — assign by what it MEANS",
71
+ "clinically, not by the panel or device it came from (e.g. an aortic-root diameter",
72
+ "and ApoB both belong to Cardiovascular Risk; Android % Fat belongs to Body",
73
+ "Composition).",
74
+ "",
75
+ "You are given the patient's BODY SYSTEMS. Assign EVERY marker to EXACTLY ONE of",
76
+ "them, using the system name VERBATIM — never rename, merge, abbreviate, or invent",
77
+ "a system. If a marker genuinely fits none of the listed systems, place it in a",
78
+ `group named exactly "${UNCATEGORIZED}". Reply with strict JSON matching the schema.`,
79
+ "",
80
+ "Rules:",
81
+ "- Output EVERY provided marker exactly once — none omitted, none invented, names",
82
+ " verbatim. The total marker count across your groups MUST equal the number given.",
83
+ "- EVERY listed name is a DISTINCT marker you must place, even when it closely",
84
+ " resembles another one. A free vs total, a percentage vs mass vs area, a ratio vs",
85
+ " its components, a high-sensitivity vs standard assay, a per-segment vs whole-body",
86
+ " measure are DIFFERENT markers — place ALL of them (e.g. Apolipoprotein B, Apo B :",
87
+ " Apo A-1, and Apolipoprotein A-1 are three separate Cardiovascular markers; hsCRP",
88
+ " and CRP are both Inflammation; Free testosterone and Testosterone are both",
89
+ " Hormonal). NEVER drop a marker as redundant.",
90
+ "- Every group name MUST be one of the listed body systems verbatim, or the literal",
91
+ ` "${UNCATEGORIZED}". Do not create any other group name.`,
92
+ "- Put each marker in the SINGLE system where observing it next to its group-mates",
93
+ " is most informative for this patient.",
94
+ "- Within a group, order markers from most to least central to the system.",
95
+ ].join("\n");
96
+
97
+ export function contextBlock(client: Client, markers: string[], leftover: boolean): string {
98
+ const lines: string[] = [];
99
+ const valueByMarker = new Map<string, string>();
100
+ for (const r of [...client.results].sort((a, b) => a.date.localeCompare(b.date))) {
101
+ valueByMarker.set(r.marker, r.valueText ?? `${r.value} ${r.unit}`.trim());
102
+ }
103
+ const watch = new Set(client.watchlist);
104
+
105
+ lines.push("BODY SYSTEMS (assign every marker to one of these, verbatim):");
106
+ for (const d of client.finding?.disease ?? []) {
107
+ const gist = (d.finding ?? "").trim().split(/(?<=[.!?])\s+/)[0] ?? "";
108
+ lines.push(` - ${d.group}${gist ? ` — ${gist}` : ""}`);
109
+ }
110
+
111
+ lines.push(
112
+ leftover
113
+ ? "\nThese markers were NOT assigned on the first pass. EVERY marker below belongs to" +
114
+ " one of the systems above — assign each to its SINGLE CLOSEST system. Every listed" +
115
+ " marker MUST appear under exactly one body system. Do NOT output a" +
116
+ ` "${UNCATEGORIZED}" group — it is not permitted in this response; if a marker seems` +
117
+ " to fit no system, choose the one it relates to most (every marker relates to some" +
118
+ " system — e.g. a red-cell index relates to the system its anemia/pathology bears on):"
119
+ : "\nMARKERS TO ASSIGN (place every one of these exactly once):",
120
+ );
121
+ for (const m of markers) {
122
+ const v = valueByMarker.get(m);
123
+ const w = watch.has(m) ? " [watchlist]" : "";
124
+ lines.push(` - ${m}${w}${v ? ` (latest ${v})` : " (no data on file)"}`);
125
+ }
126
+
127
+ const studies = leftover ? [] : client.study?.entries ?? [];
128
+ if (studies.length > 0) {
129
+ lines.push("\nPURSUED STUDIES (assignment hints):");
130
+ for (const s of studies) lines.push(` - ${s.focus}: ${s.detail}`);
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+
135
+ // Reconcile the model's grouping against the real marker set and the allowed body
136
+ // systems: keep only real markers, keep only verbatim-system group names (an
137
+ // invented/renamed group has its markers swept), dedupe (first placement wins),
138
+ // and sweep any unplaced marker into a trailing "Not yet categorized" group so
139
+ // coverage is exactly-once and complete.
140
+ export function reconcileGroups(
141
+ markerNames: string[],
142
+ systemGroups: string[],
143
+ raw: { group: string; markers: string[] }[],
144
+ ): { group: string; markers: string[] }[] {
145
+ const want = new Set(markerNames);
146
+ const allowed = new Set(systemGroups);
147
+ const placed = new Set<string>();
148
+ const out: { group: string; markers: string[] }[] = [];
149
+ for (const g of raw) {
150
+ const name = (g.group ?? "").trim();
151
+ if (name !== UNCATEGORIZED && !allowed.has(name)) continue; // reject non-verbatim systems
152
+ const markers = (g.markers ?? []).filter((m) => want.has(m) && !placed.has(m));
153
+ markers.forEach((m) => placed.add(m));
154
+ if (markers.length > 0) out.push({ group: name, markers });
155
+ }
156
+ const leftover = markerNames.filter((m) => !placed.has(m));
157
+ if (leftover.length > 0) {
158
+ const existing = out.find((g) => g.group === UNCATEGORIZED);
159
+ if (existing) existing.markers.push(...leftover);
160
+ else out.push({ group: UNCATEGORIZED, markers: leftover });
161
+ }
162
+ return out;
163
+ }
164
+
165
+ export type CallModel = (markers: string[], leftover: boolean) => Promise<{ group: string; markers: string[] }[]>;
166
+
167
+ // The absorb/completeness-round loop: accumulate marker->system placements across passes
168
+ // (first placement wins), then drive the residue to ZERO with up to 3 forced re-passes over
169
+ // only the still-missing markers. Each caller supplies `callModel` — its own Anthropic-calling
170
+ // glue (Node SDK vs Workers-bound key) — built from the shared SYSTEM_PROMPT/GROUPS_SCHEMA/
171
+ // contextBlock above. Returns the final reconciled groups (including the trailing
172
+ // UNCATEGORIZED bucket for any residue the reconcile safety-net still catches).
173
+ export async function runMarkerGroupingPasses(
174
+ client: Client,
175
+ systems: string[],
176
+ callModel: CallModel,
177
+ ): Promise<{ group: string; markers: string[] }[]> {
178
+ const markerNames = distinctMarkerNames(client);
179
+ const place = new Map<string, string>();
180
+ const absorb = (subset: string[], groups: { group: string; markers: string[] }[]): void => {
181
+ for (const g of reconcileGroups(subset, systems, groups)) {
182
+ if (g.group === UNCATEGORIZED) continue;
183
+ for (const n of g.markers) if (!place.has(n)) place.set(n, g.group);
184
+ }
185
+ };
186
+
187
+ absorb(markerNames, await callModel(markerNames, false));
188
+ for (let round = 0; round < 3; round++) {
189
+ const missing = markerNames.filter((n) => !place.has(n));
190
+ if (missing.length === 0) break;
191
+ const before = place.size;
192
+ absorb(missing, await callModel(missing, true));
193
+ if (place.size === before) break;
194
+ }
195
+
196
+ const raw = systems.map((s) => ({ group: s, markers: markerNames.filter((n) => place.get(n) === s) }));
197
+ return reconcileGroups(markerNames, systems, raw);
198
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@pablotech/akesi",
3
+ "version": "0.1.22",
4
+ "description": "Schema-constrained clinical reasoning over lab and marker data, where every constraint stated to the model in prose is re-enforced in code on the response.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "types": "index.ts",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "files": [
12
+ "*.ts",
13
+ "benchmarks/**/*.ts",
14
+ "!tests"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.ts",
18
+ "./types": "./types.ts",
19
+ "./treatment-normalize": "./treatment-normalize.ts",
20
+ "./treatment-bucket": "./treatment-bucket.ts",
21
+ "./treatment-product": "./treatment-product.ts",
22
+ "./treatment-timing-rules": "./treatment-timing-rules.ts",
23
+ "./marker-deltas": "./marker-deltas.ts",
24
+ "./unit-systems": "./unit-systems.ts",
25
+ "./dates": "./dates.ts",
26
+ "./ranges": "./ranges.ts",
27
+ "./ranges-prompt": "./ranges-prompt.ts",
28
+ "./item-registry": "./item-registry.ts",
29
+ "./report-title": "./report-title.ts",
30
+ "./imaging-catalog": "./imaging-catalog.ts",
31
+ "./system-groups": "./system-groups.ts",
32
+ "./factors-edit": "./factors-edit.ts",
33
+ "./report-extract": "./report-extract.ts",
34
+ "./report-merge": "./report-merge.ts",
35
+ "./document-model": "./document-model.ts",
36
+ "./document-read": "./document-read.ts",
37
+ "./ingest-core": "./ingest-core.ts",
38
+ "./pdf-node": "./parsers-report.ts",
39
+ "./marker-groups-prompt": "./marker-groups-prompt.ts",
40
+ "./treatment-infer": "./treatment-infer.ts",
41
+ "./finding-generate": "./finding-generate.ts",
42
+ "./finding-assemble": "./finding-assemble.ts",
43
+ "./finding-regroup": "./finding-regroup.ts",
44
+ "./pinned-queries": "./pinned-queries.ts",
45
+ "./section-labels": "./section-labels.ts",
46
+ "./benchmarks/retry-corrections": "./benchmarks/retry-corrections.ts"
47
+ },
48
+ "scripts": {
49
+ "test": "vitest run",
50
+ "prompt:golden": "tsx tests/gen-prompt-golden.ts",
51
+ "bench:retry": "tsx benchmarks/retry-corrections.ts --preview"
52
+ },
53
+ "peerDependencies": {
54
+ "@anthropic-ai/sdk": "^0.88.0",
55
+ "pdfjs-dist": "^6.2.108"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@anthropic-ai/sdk": {
59
+ "optional": true
60
+ },
61
+ "pdfjs-dist": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "tsx": "^4.22.3",
67
+ "vitest": "^4.1.8"
68
+ }
69
+ }
@@ -0,0 +1,21 @@
1
+ import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
2
+
3
+ // Flatten a narrative report PDF (e.g. an Epic MyChart "Test Details" radiology
4
+ // report) to plain text. Unlike parseDexa, which keeps positional {str,x,y}
5
+ // items to read a fixed table, here we just want the prose for an LLM to read.
6
+ export async function extractReportText(bytes: Uint8Array): Promise<string> {
7
+ // isEvalSupported:false is a real pdfjs runtime option (disables eval), just missing from the
8
+ // legacy build's DocumentInitParameters typing — cast to the param type to keep it.
9
+ const pdf = await getDocument({ data: bytes, isEvalSupported: false } as Parameters<typeof getDocument>[0]).promise;
10
+ const pages: string[] = [];
11
+ for (let p = 1; p <= pdf.numPages; p++) {
12
+ const content = await (await pdf.getPage(p)).getTextContent();
13
+ const parts: string[] = [];
14
+ for (const it of content.items) {
15
+ if (!("str" in it)) continue;
16
+ parts.push(it.str as string);
17
+ }
18
+ pages.push(parts.join(" "));
19
+ }
20
+ return pages.join("\n\n").replace(/[ \t]+/g, " ").trim();
21
+ }
@@ -0,0 +1,113 @@
1
+ // Every pinned item in one client, as AREAS OF QUERY.
2
+ //
3
+ // This is the single place that answers "what has the user starred?", and it exists because that
4
+ // answer is needed in three places that must not be allowed to disagree: the clinical prompt
5
+ // (finding-generate.ts), the staleness hash (factors-hash.ts), and the UI notice that tells the
6
+ // user their pins will steer the next Finding.
7
+ //
8
+ // WHAT A PIN MEANS — this is the load-bearing distinction in this file:
9
+ //
10
+ // A pin says WHAT TO LOOK INTO. It never says what is true.
11
+ //
12
+ // A pinned question is not an answered question. A pinned glossary term is not a diagnosis. A
13
+ // pinned report is not a summary of that report — the report's own extracted data reaches the model
14
+ // through the ordinary evidence sections, and pinning it adds nothing to that evidence, only
15
+ // emphasis on the topic. Nothing in this file is a clinical record, a finding, or a source of fact,
16
+ // and the prompt block built from it says so in as many words.
17
+ //
18
+ // The stakes are concrete: a patient pinning "Could this be early kidney disease?" must never
19
+ // cause the model to write as though early kidney disease were established. They asked a question.
20
+ import type { Client } from "./types";
21
+ import { reportTitleOf } from "./report-title";
22
+ import { pinnedItems } from "./item-registry";
23
+ import { SECTION_LABEL } from "./section-labels";
24
+
25
+ export interface PinnedQuery {
26
+ /** The section it was pinned in, shown to the model as the area's context. */
27
+ section: string;
28
+ label: string;
29
+ }
30
+
31
+ const MAX_LABEL = 160;
32
+
33
+ function clip(s: string): string {
34
+ const t = s.trim().replace(/\s+/g, " ");
35
+ return t.length > MAX_LABEL ? t.slice(0, MAX_LABEL) + "…" : t;
36
+ }
37
+
38
+ // A generated item's kind, as the section KEY it belongs to — the label itself comes from
39
+ // section-labels' SECTION_LABEL, so this file cannot spell a section differently from the app.
40
+ const GENERATED_SECTION_KEY: Record<string, string> = {
41
+ question: "docInference",
42
+ glossary: "definitions",
43
+ exploration: "exploration",
44
+ analysis: "analysis",
45
+ recommendedMarkers: "healthMarkers",
46
+ };
47
+
48
+ /**
49
+ * Every pinned item, in a stable section order.
50
+ *
51
+ * `client.watchlist` is deliberately NOT here. Watchlisted markers already reach the prompt as
52
+ * their own section and are already in the staleness hash; folding them in too would double them
53
+ * in the prompt and, worse, change the hash of every client who has ever watchlisted a marker —
54
+ * a mass regeneration bought for nothing.
55
+ */
56
+ export function pinnedQueries(client: Client): PinnedQuery[] {
57
+ const f = client.factors ?? {};
58
+ const out: PinnedQuery[] = [];
59
+ const push = (section: string, label: string | undefined | null) => {
60
+ const t = label?.trim();
61
+ if (t) out.push({ section, label: clip(t) });
62
+ };
63
+
64
+ // M74 reversed, on the owner's instruction (2026-08-20): a ratio name used to be barred from the
65
+ // prompt outright. As an area of query it is safe and useful — "look at the TG/HDL ratio" is a
66
+ // topic, not a reading — and the block below is explicit that it is not data.
67
+ for (const name of client.pinnedRatios ?? []) push("Marker ratios", name);
68
+ const label = (key: string) => SECTION_LABEL[key] ?? key;
69
+ for (const s of client.sources ?? []) if (s.pinned) push(label("clinicalReports"), reportTitleOf(s));
70
+ for (const r of pinnedItems(client)) push(label(GENERATED_SECTION_KEY[r.kind] ?? r.kind), r.label);
71
+ for (const n of f.noteEntries ?? []) if (n.pinned) push(label("notes"), n.text);
72
+ for (const e of client.study?.entries ?? []) if (e.pinned) push(label("study"), e.focus);
73
+ for (const t of f.treatments ?? []) if (t.pinned) push(label("treatment"), t.name);
74
+ for (const d of f.decisions ?? []) if (d.pinned) push(label("futureTreatment"), d.intervention);
75
+ for (const a of f.allergies ?? []) if (a.pinned) push(label("allergies"), a.allergen);
76
+ for (const h of f.familyHistory ?? []) if (h.pinned) push(label("familyHistory"), h.relation);
77
+ return out;
78
+ }
79
+
80
+ /** Canonical, sorted, de-duplicated — the form the staleness hash uses. */
81
+ export function pinnedQueryLines(client: Client): string[] {
82
+ return [...new Set(pinnedQueries(client).map((q) => `${q.section}|${q.label}`))].sort();
83
+ }
84
+
85
+ /**
86
+ * The prompt section, or null when nothing is pinned — the caller omits the section entirely rather
87
+ * than sending "Areas of query: (none)", which would be a sentence about nothing.
88
+ */
89
+ export function pinnedQueryBlock(client: Client): string | null {
90
+ const items = pinnedQueries(client);
91
+ if (items.length === 0) return null;
92
+ const bySection = new Map<string, string[]>();
93
+ for (const q of items) bySection.set(q.section, [...(bySection.get(q.section) ?? []), q.label]);
94
+ const body = [...bySection]
95
+ .map(([section, labels]) => ` ${section}:\n` + labels.map((l) => ` - ${l}`).join("\n"))
96
+ .join("\n");
97
+ return (
98
+ `Areas of query (${items.length} item${items.length === 1 ? "" : "s"} the patient or their ` +
99
+ `provider has STARRED, asking you to look into them).\n\n` +
100
+ `READ THIS BEFORE USING THE LIST. These are questions, not answers:\n` +
101
+ ` • They tell you WHAT TO LOOK INTO. They never tell you what is true.\n` +
102
+ ` • They are NOT evidence, NOT clinical record, NOT data, and NOT a source you may cite. ` +
103
+ `Every fact you state must still come from the evidence sections above — the markers, ` +
104
+ `reports, treatments, study notes and profile.\n` +
105
+ ` • A starred question is an OPEN question. Never write as though it were settled. A starred ` +
106
+ `glossary term, report or topic means "this is on their mind", nothing more.\n` +
107
+ ` • If the evidence does not support saying anything about a starred area, say that plainly ` +
108
+ `rather than inventing a finding to fill it.\n` +
109
+ ` • Where the evidence DOES speak to a starred area, give it more of your attention and more ` +
110
+ `of your words than you otherwise would. That is the entire effect of a star.\n\n` +
111
+ body
112
+ );
113
+ }
@@ -0,0 +1,228 @@
1
+ // The Node/process-free edge of Ranges generation, mirroring report-extract.ts's split: the
2
+ // schema, prompt and validation live here, so a Node caller (which brings its own env-keyed
3
+ // client) and an edge-runtime caller (which injects one) run the exact same logic.
4
+ //
5
+ // This module must never import the Finding inference graph. That is the whole point: a host
6
+ // serving only Ranges can then keep that graph out of its bundle entirely.
7
+ import { ageYears } from "./ranges";
8
+ import type { Client, ClientFactors, MarkerResult } from "./types";
9
+ import { treatmentsOf } from "./treatment-normalize";
10
+ import { bucketOf, todayISODate } from "./treatment-bucket";
11
+
12
+ export const RANGE_SCHEMA = {
13
+ type: "object",
14
+ properties: {
15
+ low: { type: ["number", "null"] },
16
+ high: { type: ["number", "null"] },
17
+ unit: { type: "string" },
18
+ meaning: { type: "string" },
19
+ explanation: { type: "string" },
20
+ explanationImperial: { type: ["string", "null"] },
21
+ generalLow: { type: ["number", "null"] },
22
+ generalHigh: { type: ["number", "null"] },
23
+ generalExplanation: { type: "string" },
24
+ },
25
+ required: ["low", "high", "unit", "meaning", "explanation", "explanationImperial", "generalLow", "generalHigh", "generalExplanation"],
26
+ additionalProperties: false,
27
+ } as const;
28
+
29
+ export interface RangeAIResponse {
30
+ low: number | null;
31
+ high: number | null;
32
+ unit: string;
33
+ meaning: string;
34
+ explanation: string;
35
+ explanationImperial: string | null;
36
+ generalLow: number | null;
37
+ generalHigh: number | null;
38
+ generalExplanation: string;
39
+ }
40
+
41
+ export const IMPERIAL_CONVERTS: Record<string, string> = {
42
+ kg: "lb",
43
+ g: "lb",
44
+ cm: "in",
45
+ "cm²": "in²",
46
+ "cm³": "in³",
47
+ "g/L": "mg/dL",
48
+ };
49
+
50
+ function describeFactors(client: Client): string {
51
+ const age = ageYears(client.dob);
52
+ const parts: string[] = [];
53
+ parts.push(`${age ?? "unknown age"}-year-old ${client.gender}`);
54
+ const f: ClientFactors = client.factors ?? {};
55
+ if (f.diseases && f.diseases.length > 0) {
56
+ const fmt = f.diseases.map((d) => `${d.diagnostic} (${d.date})`).join(", ");
57
+ parts.push(`prior diagnoses: ${fmt}`);
58
+ }
59
+ const today = todayISODate();
60
+ const ongoing = treatmentsOf(client).filter((t) => bucketOf(t, today) === "ongoing");
61
+ if (ongoing.length > 0) {
62
+ const fmt = ongoing
63
+ .map((t) => `${[t.name, t.dose].filter(Boolean).join(" ")}${t.start ? ` [${t.start}]` : ""}`.trim())
64
+ .join(", ");
65
+ parts.push(`current treatments: ${fmt}`);
66
+ }
67
+ if (f.pregnancy && f.pregnancy !== "none") parts.push(f.pregnancy);
68
+ if (f.athletic) parts.push(`${f.athletic} activity level`);
69
+ if (f.height) parts.push(`height ${f.height}`);
70
+ if (typeof f.bmi === "number") parts.push(`BMI ${f.bmi}`);
71
+ if (f.smoking) parts.push(`${f.smoking} smoker`);
72
+ if (f.ethnicity) parts.push(`ethnicity: ${f.ethnicity}`);
73
+ if (f.goal) parts.push(`personal health goal: ${f.goal}`);
74
+ if (f.focus) parts.push(`current clinical focus: ${f.focus}`);
75
+ return parts.join("; ");
76
+ }
77
+
78
+ export function systemPromptFor(client: Client): string {
79
+ return [
80
+ "You are advising on optimal/functional reference ranges for blood and body markers,",
81
+ "personalized to one specific patient. You will be given a marker name and the unit",
82
+ "in which results are reported. Reply with strict JSON matching the requested schema.",
83
+ "",
84
+ "Guidance:",
85
+ "- Use peer-reviewed medical literature. Prefer functional/optimal ranges over the broad",
86
+ " lab 'normal' range when they meaningfully differ.",
87
+ "- The 'unit' field in your response MUST match the unit provided in the user message,",
88
+ " with values scaled accordingly. Do not change units.",
89
+ "- When the same unit string appears across multiple assays with very different",
90
+ " reference ranges (notably Free testosterone in pg/mL, Insulin, IGF-1 across",
91
+ " ages, some hormones), use the patient's recent measured values shown in the",
92
+ " user message to decide which assay produced them, and target a reference range",
93
+ " from that same assay. The chosen low/high MUST be on the same scale as those",
94
+ " measured values. If the patient's measurements lie entirely above or entirely",
95
+ " below the range you're considering, you have picked the wrong assay scale.",
96
+ "- low and high are numbers in that unit, or null if the marker has only an upper or",
97
+ " lower bound (e.g. 'less than 5 mg/L'). At least one of low/high must be a number.",
98
+ "- meaning is a short, patient-INDEPENDENT definition of what the marker is and what it",
99
+ " reflects physiologically — one plain phrase, ~8-18 words, no numbers or ranges. It",
100
+ " answers 'what is this marker?' for a layperson (e.g. \"ApoB counts the atherogenic",
101
+ " particles that drive plaque; the core lipid causal to heart disease\"). Do NOT discuss",
102
+ " this patient, their factors, or their target here — that is the explanation's job.",
103
+ "- explanation is the DISCUSSION of why the PERSONALIZED range is more relevant for",
104
+ " THIS patient than the general range — 2-4 plain sentences (target 400-700",
105
+ " characters, hard cap 900). Name the specific factors below that shift the",
106
+ " personalized range and why they matter for this person. You MAY refer to \"the",
107
+ " general range\" by name when you contrast, but do NOT re-quote its numbers — they",
108
+ " are already shown on the separate 'General range' line, so e.g. \"tighter than the",
109
+ " general range\" suffices. If no factor meaningfully shifts the range from the",
110
+ " general one, say so plainly.",
111
+ "- generalLow / generalHigh: the GENERAL reference range for this marker based ONLY on",
112
+ " the patient's age, gender, and height. EXPLICITLY IGNORE this patient's conditions,",
113
+ " prior diagnoses, medications, supplements, stated goal, and clinical focus for these",
114
+ " two fields — those shape ONLY the personalized low/high above. Same unit and scale as",
115
+ " low/high (null where a side is unbounded; at least one must be a number). Use a",
116
+ " standard population/clinical-guideline range, or a simple guideline formula where one",
117
+ " is conventional (e.g. waist circumference target < half of height). This is the",
118
+ " baseline a typical same-age, same-sex, same-height person would be measured against.",
119
+ "- generalExplanation: 1-2 plain sentences stating the general range and its age/gender/",
120
+ " height basis (the guideline or population reference). Do NOT mention this patient's",
121
+ " conditions, medications, supplements, or goals here.",
122
+ "- explanationImperial: if the lab unit is one of kg, g, cm, cm², cm³, g/L (i.e. has",
123
+ " an American/imperial equivalent: lb, in, in², in³, mg/dL), provide the same 2-4",
124
+ " sentence explanation but with every numeric mention converted to the imperial",
125
+ " unit (and any reference-range parenthetical also in imperial). Otherwise (e.g.",
126
+ " units like mg/dL, ng/mL, mmol/L, %, IU/L that don't convert), return null —",
127
+ " the metric explanation will be reused for both unit systems.",
128
+ "- If the patient states a personal health goal (e.g. Health span, Longevity,",
129
+ " Performance, Fertility, Weight loss), weight the range toward that goal: prefer",
130
+ " tighter optimal ranges that maximize the stated outcome, and call out in the",
131
+ " explanation how the goal influenced the bounds.",
132
+ "- If the patient states a current clinical focus (e.g. lower visceral fat,",
133
+ " lower LDL/ApoB, raise HDL, improve insulin sensitivity), treat it as a more",
134
+ " specific aim on top of the broader goal: tighten the range for markers",
135
+ " directly relevant to that focus, and name the focus in the explanation when",
136
+ " it shifts the bound. For markers unrelated to the focus, defer to the goal.",
137
+ "- Medications and supplements do NOT shift the personalized range. The range",
138
+ " is the patient's goal-aligned target (e.g. healthspan-optimized ApoB), and",
139
+ " is the same whether or not they take any medication or supplement. Do not",
140
+ " 'predict' the patient's level after treatment by tightening or loosening",
141
+ " the range. The range is set by age, gender, conditions, goal, and the other",
142
+ " factors below — never by what the patient is currently taking.",
143
+ "- However, the explanation should describe what level may be anticipated as",
144
+ " a result of any relevant current medication or supplement, so the patient",
145
+ " can judge whether their regimen is moving them toward the goal range. For",
146
+ " example: 'tirzepatide can be expected to bring HbA1c toward ~5%';",
147
+ " 'ezetimibe typically lowers ApoB by ~20-25% from baseline'; 'glycine 3-15g",
148
+ " can lower HbA1c by ~0.2-0.5 points and improve sleep-driven glucose control'.",
149
+ " Be specific about anticipated direction and magnitude when relevant.",
150
+ "",
151
+ `Patient: ${describeFactors(client)}.`,
152
+ ].join("\n");
153
+ }
154
+
155
+ /** The unit a marker is measured in: the LATEST reading's, by date. `""` for a dimensionless ratio.
156
+ * Whether a `""` here means "dimensionless" or "never ingested" is the host's call, not this
157
+ * module's — see the caller's NoMeasuredUnitError. */
158
+ export function unitForMarker(client: Client, marker: string): { unit: string; rows: MarkerResult[] } {
159
+ const rows = client.results
160
+ .filter((r) => r.marker === marker)
161
+ .sort((a, b) => a.date.localeCompare(b.date));
162
+ return { unit: rows.length > 0 ? rows[rows.length - 1].unit : "", rows };
163
+ }
164
+
165
+ /** The prompt's unit line — spelled out for a dimensionless ratio marker (unit "") so the model
166
+ * can't read it as missing data. Named and exported so the benchmark's "after" arm scores this
167
+ * exact shipped text rather than a hand-copied snapshot that could drift from it. */
168
+ export function unitLineFor(unit: string): string {
169
+ return unit
170
+ ? `Unit: ${unit}`
171
+ : `Unit: (dimensionless ratio — this marker has no physical unit; return "" for the unit field)`;
172
+ }
173
+
174
+ /** The Ranges user message, the other half of the prompt `systemPromptFor` starts. Lives here for
175
+ * the same reason the system prompt does: the CLI and the Function must never word it two
176
+ * different ways, and a golden fixture can only cover text this package owns. */
177
+ export function rangesUserMessage(client: Client, marker: string): string {
178
+ const { unit, rows } = unitForMarker(client, marker);
179
+ const recent = rows.slice(-5);
180
+ const measuredLine =
181
+ recent.length > 0
182
+ ? `Recent measured values for this patient (use these to disambiguate assay/unit scale when the same unit appears across assays with different reference ranges): ${recent.map((r) => `${r.date}: ${r.value}`).join(", ")}`
183
+ : `Recent measured values for this patient: (none on file)`;
184
+ const imperialUnit = IMPERIAL_CONVERTS[unit];
185
+ const imperialReminder = imperialUnit
186
+ ? `\nThis unit (${unit}) has an imperial equivalent (${imperialUnit}); explanationImperial is REQUIRED, not null — at least 20 chars converting every number to ${imperialUnit}.`
187
+ : "";
188
+ return `Marker: ${marker}\n${unitLineFor(unit)}${imperialReminder}\n${measuredLine}\n\nReturn the personalized reference range as JSON.`;
189
+ }
190
+
191
+ export function validate(marker: string, expectedUnit: string, r: RangeAIResponse): void {
192
+ if (r.low == null && r.high == null) {
193
+ throw new Error(`range for "${marker}" has neither low nor high`);
194
+ }
195
+ if (r.low != null && r.high != null && r.low >= r.high) {
196
+ throw new Error(`range for "${marker}" has low (${r.low}) >= high (${r.high})`);
197
+ }
198
+ if (expectedUnit !== "" && (!r.unit || r.unit.trim() === "")) {
199
+ throw new Error(`range for "${marker}" missing unit`);
200
+ }
201
+ if (normalizeUnit(r.unit) !== normalizeUnit(expectedUnit)) {
202
+ throw new Error(
203
+ `range for "${marker}" returned unit "${r.unit}" but lab data is in "${expectedUnit}"`,
204
+ );
205
+ }
206
+ if (!r.meaning || r.meaning.trim().length === 0) {
207
+ throw new Error(`range for "${marker}" missing meaning`);
208
+ }
209
+ if (!r.explanation || r.explanation.trim().length === 0) {
210
+ throw new Error(`range for "${marker}" missing explanation`);
211
+ }
212
+ if (IMPERIAL_CONVERTS[expectedUnit] && (!r.explanationImperial || r.explanationImperial.trim().length === 0)) {
213
+ throw new Error(`range for "${marker}" (unit ${expectedUnit}) missing imperial explanation`);
214
+ }
215
+ if (r.generalLow == null && r.generalHigh == null) {
216
+ throw new Error(`range for "${marker}" has neither generalLow nor generalHigh`);
217
+ }
218
+ if (r.generalLow != null && r.generalHigh != null && r.generalLow >= r.generalHigh) {
219
+ throw new Error(`range for "${marker}" has generalLow (${r.generalLow}) >= generalHigh (${r.generalHigh})`);
220
+ }
221
+ if (!r.generalExplanation || r.generalExplanation.trim().length === 0) {
222
+ throw new Error(`range for "${marker}" missing generalExplanation`);
223
+ }
224
+ }
225
+
226
+ function normalizeUnit(u: string): string {
227
+ return u.toLowerCase().replace(/\s+/g, "").replace(/μ/g, "u");
228
+ }