@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.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/benchmarks/retry-corrections.ts +231 -0
- package/dates.ts +36 -0
- package/document-model.ts +96 -0
- package/document-read.ts +171 -0
- package/factors-edit.ts +161 -0
- package/finding-assemble.ts +803 -0
- package/finding-generate.ts +1439 -0
- package/finding-regroup.ts +167 -0
- package/imaging-catalog.ts +108 -0
- package/index.ts +4 -0
- package/ingest-core.ts +115 -0
- package/item-registry.ts +69 -0
- package/marker-deltas.ts +84 -0
- package/marker-groups-prompt.ts +198 -0
- package/package.json +69 -0
- package/parsers-report.ts +21 -0
- package/pinned-queries.ts +113 -0
- package/ranges-prompt.ts +228 -0
- package/ranges.ts +94 -0
- package/report-extract.ts +337 -0
- package/report-merge.ts +372 -0
- package/report-title.ts +29 -0
- package/section-labels.ts +20 -0
- package/system-groups.ts +43 -0
- package/treatment-bucket.ts +366 -0
- package/treatment-infer.ts +237 -0
- package/treatment-normalize.ts +91 -0
- package/treatment-product.ts +111 -0
- package/treatment-timing-rules.ts +90 -0
- package/types.ts +647 -0
- package/unit-systems.ts +214 -0
- package/vitest.config.ts +8 -0
package/ranges.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Client, PersonalizedRange } from "./types";
|
|
2
|
+
|
|
3
|
+
export function resolveRange(marker: string, client: Client): PersonalizedRange | null {
|
|
4
|
+
return client.personalizedRanges?.[marker] ?? null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A patient's age in whole years — THE implementation. Four more existed.
|
|
9
|
+
*
|
|
10
|
+
* Every copy used to read the DOB with LOCAL getters after parsing it as UTC. `new Date("2000-06-29")`
|
|
11
|
+
* is midnight UTC; west of Greenwich `.getDate()` then answers 28. Measured, for a patient evaluated
|
|
12
|
+
* the day before their birthday:
|
|
13
|
+
*
|
|
14
|
+
* UTC 25 (correct)
|
|
15
|
+
* America/Los_Angeles 26
|
|
16
|
+
* Asia/Tokyo 25
|
|
17
|
+
*
|
|
18
|
+
* This value goes into the clinical prompt, and age drives reference-range reasoning — so a patient's
|
|
19
|
+
* stated age depended on which Cloudflare PoP served the request, or on the operator's laptop.
|
|
20
|
+
*
|
|
21
|
+
* Both sides are read in UTC now. A date of birth is a CALENDAR DATE, not an instant, and comparing
|
|
22
|
+
* it against a local wall clock is the category error underneath this. UTC on both sides makes the
|
|
23
|
+
* answer identical everywhere, which matters more here than matching any one operator's midnight:
|
|
24
|
+
* the alternative is a number that silently differs between the browser, the CLI and a Worker.
|
|
25
|
+
*/
|
|
26
|
+
export function ageYears(dob: string, asOf: Date = new Date()): number | null {
|
|
27
|
+
if (!dob) return null;
|
|
28
|
+
const d = new Date(dob);
|
|
29
|
+
if (isNaN(d.getTime())) return null;
|
|
30
|
+
let age = asOf.getUTCFullYear() - d.getUTCFullYear();
|
|
31
|
+
const m = asOf.getUTCMonth() - d.getUTCMonth();
|
|
32
|
+
if (m < 0 || (m === 0 && asOf.getUTCDate() < d.getUTCDate())) age--;
|
|
33
|
+
return age;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Zones {
|
|
37
|
+
safeLow: number;
|
|
38
|
+
safeHigh: number;
|
|
39
|
+
warnLowBound: number | null;
|
|
40
|
+
warnHighBound: number | null;
|
|
41
|
+
dangerLowBound: number | null;
|
|
42
|
+
dangerHighBound: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Independent per side: the safe zone is the personalized bound, falling back to the
|
|
46
|
+
// general bound, falling back to open-ended. A warn zone only exists where a personalized
|
|
47
|
+
// bound is stricter than its general counterpart (the gap between them); a danger zone only
|
|
48
|
+
// exists where a general bound is known (either as that gap's outer edge, or — with no
|
|
49
|
+
// personalized bound at all — directly at the general bound).
|
|
50
|
+
export function computeZones(
|
|
51
|
+
personal: Pick<PersonalizedRange, "low" | "high" | "generalLow" | "generalHigh"> | null,
|
|
52
|
+
): Zones {
|
|
53
|
+
const low = personal?.low;
|
|
54
|
+
const high = personal?.high;
|
|
55
|
+
const generalLow = personal?.generalLow;
|
|
56
|
+
const generalHigh = personal?.generalHigh;
|
|
57
|
+
|
|
58
|
+
const lowStricter = low != null && generalLow != null && low > generalLow;
|
|
59
|
+
const lowDanger = lowStricter || (generalLow != null && (low == null || low <= generalLow));
|
|
60
|
+
|
|
61
|
+
const highStricter = high != null && generalHigh != null && high < generalHigh;
|
|
62
|
+
const highDanger = highStricter || (generalHigh != null && (high == null || high >= generalHigh));
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
safeLow: low ?? generalLow ?? -Infinity,
|
|
66
|
+
safeHigh: high ?? generalHigh ?? Infinity,
|
|
67
|
+
warnLowBound: lowStricter ? generalLow! : null,
|
|
68
|
+
warnHighBound: highStricter ? generalHigh! : null,
|
|
69
|
+
dangerLowBound: lowDanger ? generalLow! : null,
|
|
70
|
+
dangerHighBound: highDanger ? generalHigh! : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ZoneStatus = "safe" | "warn" | "danger" | "unknown";
|
|
75
|
+
|
|
76
|
+
// The *current* reading's zone — unlike the retired markerStatus() trend signal, this only
|
|
77
|
+
// looks at the latest value, no history. Raw-value comparison, no unit conversion (mirrors
|
|
78
|
+
// markerStatus()'s pre-existing assumption that the row is already in the range's unit).
|
|
79
|
+
export function currentZoneStatus(latestValue: number | null, personal: PersonalizedRange | null): ZoneStatus {
|
|
80
|
+
if (latestValue == null || personal == null) return "unknown";
|
|
81
|
+
const z = computeZones(personal);
|
|
82
|
+
if (latestValue >= z.safeLow && latestValue <= z.safeHigh) return "safe";
|
|
83
|
+
const warnLow = z.warnLowBound ?? z.safeLow;
|
|
84
|
+
const warnHigh = z.warnHighBound ?? z.safeHigh;
|
|
85
|
+
if (latestValue >= warnLow && latestValue <= warnHigh) return "warn";
|
|
86
|
+
return "danger";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CONCERN_RANK: Record<ZoneStatus, number> = { danger: 0, warn: 1, safe: 2, unknown: 3 };
|
|
90
|
+
|
|
91
|
+
// Descending concern: danger < warn < safe < unknown (unknown/no-range sorts last).
|
|
92
|
+
export function concernRank(status: ZoneStatus): number {
|
|
93
|
+
return CONCERN_RANK[status];
|
|
94
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// The report LLM extraction, pure of Node/process/env so both a CLI and a
|
|
2
|
+
// serverless function call the exact same schema + prompt + validation. The Anthropic client is
|
|
3
|
+
// INJECTED (a CLI passes its env-keyed singleton; a function passes one built
|
|
4
|
+
// from its own env var), and the model is a required arg (no default), so
|
|
5
|
+
// this module never touches process.env or any host-side inference config.
|
|
6
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
7
|
+
import { ageYears } from "./ranges";
|
|
8
|
+
import type { Client } from "./types";
|
|
9
|
+
import { CANONICAL_IMAGING_MARKERS } from "./imaging-catalog";
|
|
10
|
+
import { readDocumentAsJson, type DocumentSource, type UsageRecorder } from "./document-model";
|
|
11
|
+
|
|
12
|
+
// Only the fields systemPromptFor reads. The CLI passes a full Client; the Function
|
|
13
|
+
// passes a minimized {dob, gender, factors:{diseases}} so the whole vault never
|
|
14
|
+
// transits the server (the report itself does — a documented, bounded exposure).
|
|
15
|
+
// `diseases` is intentionally narrower than the real DiseaseEntry — only diagnostic/date are ever
|
|
16
|
+
// read here (for naming-consistency context), so callers don't need a real id/pinned to build one.
|
|
17
|
+
export interface ReportPatient {
|
|
18
|
+
dob: Client["dob"];
|
|
19
|
+
gender: Client["gender"];
|
|
20
|
+
factors?: { diseases?: { diagnostic: string; date: string }[] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const REPORT_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
// The import gate. Reports is the one surface that must refuse a non-report — Chat and Notes
|
|
27
|
+
// accept any PDF for discussion, which is only safe because this keeps Reports from becoming a
|
|
28
|
+
// dumping ground. Asked in the SAME call as the extraction, so the check is free.
|
|
29
|
+
isMedicalReport: { type: "boolean" },
|
|
30
|
+
notReportReason: { type: "string" },
|
|
31
|
+
studyType: { type: "string" },
|
|
32
|
+
diseases: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
date: { type: "string" },
|
|
38
|
+
diagnostic: { type: "string" },
|
|
39
|
+
summary: { type: "string" },
|
|
40
|
+
confidence: { type: "number" },
|
|
41
|
+
},
|
|
42
|
+
required: ["date", "diagnostic", "summary", "confidence"],
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
comorbidities: {
|
|
47
|
+
type: "array",
|
|
48
|
+
items: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
code: { type: "string" },
|
|
52
|
+
label: { type: "string" },
|
|
53
|
+
description: { type: "string" },
|
|
54
|
+
confidence: { type: "number" },
|
|
55
|
+
},
|
|
56
|
+
required: ["code", "label", "description", "confidence"],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
priorComparisons: {
|
|
61
|
+
type: "array",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
marker: { type: "string" },
|
|
66
|
+
priorValue: { type: "number" },
|
|
67
|
+
priorDate: { type: "string" },
|
|
68
|
+
currentValue: { type: "number" },
|
|
69
|
+
unit: { type: "string" },
|
|
70
|
+
confidence: { type: "number" },
|
|
71
|
+
},
|
|
72
|
+
required: ["marker", "priorValue", "priorDate", "currentValue", "unit", "confidence"],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
markers: {
|
|
77
|
+
type: "array",
|
|
78
|
+
items: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
marker: { type: "string" },
|
|
82
|
+
value: { type: "number" },
|
|
83
|
+
unit: { type: "string" },
|
|
84
|
+
date: { type: "string" },
|
|
85
|
+
group: { type: "string" },
|
|
86
|
+
confidence: { type: "number" },
|
|
87
|
+
},
|
|
88
|
+
required: ["marker", "value", "unit", "date", "group", "confidence"],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: ["isMedicalReport", "notReportReason", "studyType", "diseases", "comorbidities", "priorComparisons", "markers"],
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
// `summary` is optional here and required nowhere: validate() (below) rejects a FRESH
|
|
98
|
+
// extraction that lacks one, while a cached ImagingExtraction from the client record (types.ts, the
|
|
99
|
+
// mirror of this shape) has always declared it optional. Requiring it made the two shapes
|
|
100
|
+
// mutually unassignable, which a host ingest path hits on every re-import of a cached PDF.
|
|
101
|
+
export interface ProposedDiseaseEntry {
|
|
102
|
+
date: string;
|
|
103
|
+
diagnostic: string;
|
|
104
|
+
summary?: string;
|
|
105
|
+
confidence: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ProposedComorbidity {
|
|
109
|
+
code: string;
|
|
110
|
+
label: string;
|
|
111
|
+
// Optional so a cached extraction from before this field stays assignable.
|
|
112
|
+
description?: string;
|
|
113
|
+
confidence: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ProposedMarkerEntry {
|
|
117
|
+
marker: string;
|
|
118
|
+
value: number;
|
|
119
|
+
unit: string;
|
|
120
|
+
date: string;
|
|
121
|
+
group: string;
|
|
122
|
+
confidence: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ProposedPriorComparison {
|
|
126
|
+
marker: string;
|
|
127
|
+
priorValue: number;
|
|
128
|
+
priorDate: string;
|
|
129
|
+
currentValue: number;
|
|
130
|
+
unit: string;
|
|
131
|
+
confidence: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ProposedReport {
|
|
135
|
+
// Optional so a cached extraction from before the gate existed stays assignable — validate()
|
|
136
|
+
// only rejects an EXPLICIT false, never an absent field.
|
|
137
|
+
isMedicalReport?: boolean;
|
|
138
|
+
notReportReason?: string;
|
|
139
|
+
studyType: string;
|
|
140
|
+
diseases: ProposedDiseaseEntry[];
|
|
141
|
+
// Optional so a cached extraction (ImagingExtraction) from before these fields
|
|
142
|
+
// existed stays assignable; fresh extractions always carry them (schema-required).
|
|
143
|
+
comorbidities?: ProposedComorbidity[];
|
|
144
|
+
priorComparisons?: ProposedPriorComparison[];
|
|
145
|
+
markers: ProposedMarkerEntry[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Both moved to document-model.ts when the transport was generalized (a second reader,
|
|
149
|
+
// document-read.ts, needs the identical source union and usage hook). Re-exported under their
|
|
150
|
+
// original names so every existing importer of this module is unaffected.
|
|
151
|
+
export type { UsageRecorder, DocumentSource } from "./document-model";
|
|
152
|
+
export type ReportSource = DocumentSource;
|
|
153
|
+
|
|
154
|
+
function describeFactors(client: ReportPatient): string {
|
|
155
|
+
const age = ageYears(client.dob);
|
|
156
|
+
const parts: string[] = [`${age ?? "unknown age"}-year-old ${client.gender}`];
|
|
157
|
+
const f = client.factors ?? {};
|
|
158
|
+
if (f.diseases && f.diseases.length > 0) {
|
|
159
|
+
parts.push(`existing on-file diagnoses (for naming consistency only): ${f.diseases.map((d) => `${d.diagnostic} (${d.date})`).join("; ")}`);
|
|
160
|
+
}
|
|
161
|
+
return parts.join("; ");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function systemPromptFor(client: ReportPatient, today: string): string {
|
|
165
|
+
return [
|
|
166
|
+
"You read ONE narrative medical report (typically a radiology or imaging study,",
|
|
167
|
+
"e.g. an Epic MyChart 'Test Details' report with HISTORY / TECHNIQUE / FINDINGS /",
|
|
168
|
+
"IMPRESSION sections) and extract its clinical content as strict JSON matching the",
|
|
169
|
+
"requested schema. The output drives a patient's Diagnosed Disease list and trended",
|
|
170
|
+
"imaging markers, so be precise and conservative — never invent.",
|
|
171
|
+
"",
|
|
172
|
+
`Today is ${today}.`,
|
|
173
|
+
"",
|
|
174
|
+
"Extract:",
|
|
175
|
+
"- isMedicalReport: FIRST decide whether this document is a clinical report about a patient",
|
|
176
|
+
" that states results — a lab panel, an imaging/radiology study, a pathology report, a",
|
|
177
|
+
" diagnostic test result. It is FALSE for a product label or package insert, a research paper,",
|
|
178
|
+
" a bill or explanation of benefits, an appointment or insurance letter, a consent form,",
|
|
179
|
+
" marketing material, or anything that does not report this patient's own measured results.",
|
|
180
|
+
" When it is false, say so in notReportReason in ONE sentence naming what the document is",
|
|
181
|
+
" instead, emit an empty string for studyType and empty arrays everywhere, and extract nothing",
|
|
182
|
+
" — do NOT try to salvage clinical-sounding content out of a document that is not a report.",
|
|
183
|
+
" When it is true, emit an empty string for notReportReason and extract as below.",
|
|
184
|
+
"- studyType: a short label for the study, Title Case (e.g. 'Coronary CTA',",
|
|
185
|
+
" 'Abdominal Ultrasound', 'Renal Ultrasound', 'Chest CT').",
|
|
186
|
+
"- diseases: one entry per DISTINCT clinically meaningful finding or impression in",
|
|
187
|
+
" the report. Each:",
|
|
188
|
+
" • date: the date the EXAM WAS PERFORMED (study/collection date, not the order or",
|
|
189
|
+
" result-release date). Emit ISO YYYY-MM-DD when the report states an unambiguous",
|
|
190
|
+
" study date; if the date is ambiguous or absent, emit the report's own date text",
|
|
191
|
+
" verbatim rather than guessing.",
|
|
192
|
+
" • diagnostic: a TERSE one-line impression in the clinician's shorthand, matching",
|
|
193
|
+
" the house style of these examples: 'CAC: 12; CAD-RADS 2 in the proximal RCA',",
|
|
194
|
+
" '1-24% Diffuse hepatic steatosis', 'Non Alcoholic Fatty Liver Disease'. Fold the",
|
|
195
|
+
" key quantitative result into the line where one exists. Prefer the report's",
|
|
196
|
+
" IMPRESSION wording. ONE entry per distinct clinical finding — combine a",
|
|
197
|
+
" quantitative result and its interpretation/category/grade into a SINGLE line",
|
|
198
|
+
" (a calcium score and its CAD-RADS grade are one entry, e.g. 'CAC: 12; CAD-RADS",
|
|
199
|
+
" 2 in the proximal RCA'). Do NOT emit a second entry that merely restates the same",
|
|
200
|
+
" finding with a percentile, risk phrase, or interpretation.",
|
|
201
|
+
" • summary: 1–3 complete sentences giving the clinically meaningful NATURE",
|
|
202
|
+
" and METRICS behind the terse diagnostic, drawn from the report's FINDINGS",
|
|
203
|
+
" and IMPRESSION — the detail a physician would want when reasoning about it.",
|
|
204
|
+
" Include the concrete numbers and descriptors: e.g. for coronary, the total",
|
|
205
|
+
" and per-vessel calcium score, the CAD-RADS grade, the stenosis %, plaque",
|
|
206
|
+
" type and location; for a valve, the bicuspid/tricuspid morphology, gradients,",
|
|
207
|
+
" regurgitation; for fatty liver, the steatosis grade/extent, echogenicity,",
|
|
208
|
+
" any fibrosis or accompanying findings. Be specific and factual to THIS",
|
|
209
|
+
" report; do not speculate beyond it or restate generic risk boilerplate.",
|
|
210
|
+
" • confidence: 0..1, how clearly the report states this finding.",
|
|
211
|
+
" Do NOT emit entries for normal/unremarkable structures or boilerplate. An entirely",
|
|
212
|
+
" normal report yields an empty diseases array.",
|
|
213
|
+
"- comorbidities: the report header's CODED diagnosis list — the structured",
|
|
214
|
+
" encounter/visit Diagnosis section that pairs an ICD-10 code with a label",
|
|
215
|
+
" (e.g. 'I25.10 Atherosclerotic heart disease of native coronary artery without",
|
|
216
|
+
" angina', 'E78.5 Hyperlipidemia, unspecified'). These are the patient's standing",
|
|
217
|
+
" problem-list conditions, NOT findings of this study. Each:",
|
|
218
|
+
" • code: the ICD-10 code verbatim (e.g. 'I25.10').",
|
|
219
|
+
" • label: the condition name, concise (e.g. 'Coronary artery disease',",
|
|
220
|
+
" 'Hyperlipidemia'). Prefer the common clinical name over the verbose ICD wording.",
|
|
221
|
+
" • description: the report's OWN full descriptor for this diagnosis, verbatim",
|
|
222
|
+
" but WITHOUT the bracketed code (e.g. 'Arteriosclerotic coronary artery",
|
|
223
|
+
" disease', 'Hyperlipidemia, unspecified hyperlipidemia type'). This is the",
|
|
224
|
+
" header text as written; do not paraphrase, expand, or add clinical detail the",
|
|
225
|
+
" header does not state. If the header gives only the bare label, repeat it.",
|
|
226
|
+
" • confidence: 0..1.",
|
|
227
|
+
" Only emit a comorbidity that appears as a coded header diagnosis. Do NOT restate a",
|
|
228
|
+
" finding you already put in `diseases`, and do NOT invent codes. No coded header",
|
|
229
|
+
" list yields an empty comorbidities array.",
|
|
230
|
+
"- markers: one entry per GENUINELY QUANTIFIED imaging value the report states as a",
|
|
231
|
+
" number with a clear meaning over time — e.g. total CAC/Agatston score, a measured",
|
|
232
|
+
" dimension (common bile duct mm), a percent stenosis. Each:",
|
|
233
|
+
" • marker: a canonical name — name the SAME physical measurement",
|
|
234
|
+
" IDENTICALLY every time so it forms one trend line across reports. When",
|
|
235
|
+
" the value is one of these known markers, copy the name VERBATIM:",
|
|
236
|
+
` ${CANONICAL_IMAGING_MARKERS.join("; ")}.`,
|
|
237
|
+
" Only coin a new name for a measurement genuinely absent from that list,",
|
|
238
|
+
" and keep it terse and consistent.",
|
|
239
|
+
" • value: the number. unit: the unit, or '' for index/score-like values that have",
|
|
240
|
+
" no unit (mirroring how DEXA T/Z scores carry no unit).",
|
|
241
|
+
" • date: same study-performed date as above.",
|
|
242
|
+
" • group: a short modality/organ group (e.g. 'Cardiac Imaging', 'Liver Imaging').",
|
|
243
|
+
" • confidence: 0..1.",
|
|
244
|
+
" Do NOT fabricate a number for a qualitative finding (e.g. 'fatty liver' with no",
|
|
245
|
+
" grade is a disease entry, not a marker). A report with no quantified value yields",
|
|
246
|
+
" an empty markers array. Do NOT turn a CATEGORY or range into a single number: a",
|
|
247
|
+
" CAD-RADS stenosis category written as '1-24%' is a grade, not a measured 24% — it",
|
|
248
|
+
" belongs in the disease line, not as a numeric marker.",
|
|
249
|
+
"- priorComparisons: the report's OWN explicit comparisons to a prior study — phrases",
|
|
250
|
+
" like 'increased from 8 to 11 mmHg', 'compared to 2.9 cm on 3/14/2019', 'prior",
|
|
251
|
+
" velocity 1.85 m/s'. Emit one ONLY when the report states BOTH a current value AND a",
|
|
252
|
+
" dated prior value for the same measurement. Each:",
|
|
253
|
+
" • marker: the canonical name, using the SAME rules and known-name list as",
|
|
254
|
+
" `markers` above, so the prior value joins that marker's single trend line.",
|
|
255
|
+
" • priorValue / currentValue: the two numbers (prior and current).",
|
|
256
|
+
" • priorDate: the prior study's date — ISO YYYY-MM-DD when the report gives one;",
|
|
257
|
+
" otherwise the report's own date text verbatim. unit: the unit (or '').",
|
|
258
|
+
" • confidence: 0..1.",
|
|
259
|
+
" Do NOT infer a prior value the report does not explicitly state, and do NOT invent a",
|
|
260
|
+
" date. A report that makes no explicit prior comparison yields an empty array.",
|
|
261
|
+
"",
|
|
262
|
+
`Patient: ${describeFactors(client)}.`,
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function proposeFromReport(
|
|
267
|
+
anthropic: Anthropic,
|
|
268
|
+
source: ReportSource,
|
|
269
|
+
sourceFile: string,
|
|
270
|
+
client: ReportPatient,
|
|
271
|
+
today: string,
|
|
272
|
+
model: string,
|
|
273
|
+
usage?: UsageRecorder,
|
|
274
|
+
): Promise<ProposedReport> {
|
|
275
|
+
const parsed = await readDocumentAsJson<ProposedReport>({
|
|
276
|
+
anthropic,
|
|
277
|
+
source,
|
|
278
|
+
sourceFile,
|
|
279
|
+
system: systemPromptFor(client, today),
|
|
280
|
+
schema: REPORT_SCHEMA,
|
|
281
|
+
instruction: "Extract the report as JSON.",
|
|
282
|
+
model,
|
|
283
|
+
maxTokens: 4096,
|
|
284
|
+
usage,
|
|
285
|
+
});
|
|
286
|
+
validate(sourceFile, parsed);
|
|
287
|
+
return parsed;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function validate(sourceFile: string, r: ProposedReport): void {
|
|
291
|
+
// The gate, checked before anything else: a document the model says is not a report has nothing
|
|
292
|
+
// worth validating, and the reason is what the user needs to see. Only an EXPLICIT false rejects
|
|
293
|
+
// — an extraction cached from before this field existed carries none and stays valid.
|
|
294
|
+
if (r.isMedicalReport === false) {
|
|
295
|
+
const why = r.notReportReason?.trim() || "it does not report a patient's own results";
|
|
296
|
+
throw new Error(`report "${sourceFile}" is not a medical report: ${why}`);
|
|
297
|
+
}
|
|
298
|
+
if (typeof r.studyType !== "string" || r.studyType.trim() === "") {
|
|
299
|
+
throw new Error(`report "${sourceFile}" missing studyType`);
|
|
300
|
+
}
|
|
301
|
+
if (!Array.isArray(r.diseases)) throw new Error(`report "${sourceFile}" diseases not an array`);
|
|
302
|
+
if (!Array.isArray(r.comorbidities)) throw new Error(`report "${sourceFile}" comorbidities not an array`);
|
|
303
|
+
if (!Array.isArray(r.priorComparisons)) throw new Error(`report "${sourceFile}" priorComparisons not an array`);
|
|
304
|
+
if (!Array.isArray(r.markers)) throw new Error(`report "${sourceFile}" markers not an array`);
|
|
305
|
+
for (const [i, d] of r.diseases.entries()) {
|
|
306
|
+
if (!d.diagnostic || d.diagnostic.trim() === "") throw new Error(`report "${sourceFile}" diseases[${i}] missing diagnostic`);
|
|
307
|
+
if (!d.summary || d.summary.trim() === "") throw new Error(`report "${sourceFile}" diseases[${i}] missing summary`);
|
|
308
|
+
if (!d.date || d.date.trim() === "") throw new Error(`report "${sourceFile}" diseases[${i}] missing date`);
|
|
309
|
+
if (!isConfidence(d.confidence)) throw new Error(`report "${sourceFile}" diseases[${i}] confidence out of [0,1]`);
|
|
310
|
+
}
|
|
311
|
+
for (const [i, c] of (r.comorbidities ?? []).entries()) {
|
|
312
|
+
if (!c.label || c.label.trim() === "") throw new Error(`report "${sourceFile}" comorbidities[${i}] missing label`);
|
|
313
|
+
if (typeof c.code !== "string") throw new Error(`report "${sourceFile}" comorbidities[${i}] code not a string`);
|
|
314
|
+
if (c.description !== undefined && typeof c.description !== "string") throw new Error(`report "${sourceFile}" comorbidities[${i}] description not a string`);
|
|
315
|
+
if (!isConfidence(c.confidence)) throw new Error(`report "${sourceFile}" comorbidities[${i}] confidence out of [0,1]`);
|
|
316
|
+
}
|
|
317
|
+
for (const [i, p] of (r.priorComparisons ?? []).entries()) {
|
|
318
|
+
if (!p.marker || p.marker.trim() === "") throw new Error(`report "${sourceFile}" priorComparisons[${i}] missing marker`);
|
|
319
|
+
if (!Number.isFinite(p.priorValue)) throw new Error(`report "${sourceFile}" priorComparisons[${i}] priorValue not finite`);
|
|
320
|
+
if (!Number.isFinite(p.currentValue)) throw new Error(`report "${sourceFile}" priorComparisons[${i}] currentValue not finite`);
|
|
321
|
+
if (!p.priorDate || p.priorDate.trim() === "") throw new Error(`report "${sourceFile}" priorComparisons[${i}] missing priorDate`);
|
|
322
|
+
if (typeof p.unit !== "string") throw new Error(`report "${sourceFile}" priorComparisons[${i}] unit not a string`);
|
|
323
|
+
if (!isConfidence(p.confidence)) throw new Error(`report "${sourceFile}" priorComparisons[${i}] confidence out of [0,1]`);
|
|
324
|
+
}
|
|
325
|
+
for (const [i, m] of r.markers.entries()) {
|
|
326
|
+
if (!m.marker || m.marker.trim() === "") throw new Error(`report "${sourceFile}" markers[${i}] missing marker`);
|
|
327
|
+
if (!Number.isFinite(m.value)) throw new Error(`report "${sourceFile}" markers[${i}] value not finite`);
|
|
328
|
+
if (typeof m.unit !== "string") throw new Error(`report "${sourceFile}" markers[${i}] unit not a string`);
|
|
329
|
+
if (!m.date || m.date.trim() === "") throw new Error(`report "${sourceFile}" markers[${i}] missing date`);
|
|
330
|
+
if (!m.group || m.group.trim() === "") throw new Error(`report "${sourceFile}" markers[${i}] missing group`);
|
|
331
|
+
if (!isConfidence(m.confidence)) throw new Error(`report "${sourceFile}" markers[${i}] confidence out of [0,1]`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isConfidence(n: unknown): boolean {
|
|
336
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
|
|
337
|
+
}
|