@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,231 @@
1
+ // A sampled benchmark on the one retry claim this package actually rests on: when an attempt is
2
+ // rejected, does sending EVERY prior rejection beat sending only the latest?
3
+ //
4
+ // README.md § "When the model gets it wrong" states it as fact — "the model fixed each named
5
+ // problem and broke a different one" — from a single six-attempt run. That is an anecdote, and an
6
+ // anecdote about a discipline that exists to catch failures cannot be checked in a regime where
7
+ // nothing fails. So the cases below are chosen to make the validator fire: lab units a model has a
8
+ // strong prior against (a lab reporting protein in g/L when the model wants g/dL), several of which
9
+ // ALSO require an imperial explanation. Two independent conditions is the structure the claim is
10
+ // about — fix one, break the other.
11
+ //
12
+ // Why this is the right subject where a binary rubric was not:
13
+ // - it compares two strategies, which is the question compareBrains exists for;
14
+ // - the outcome has a gradient (attempts until valid, censored), so it does not saturate;
15
+ // - the verdict is the package's own shipped validate(), not a rubric written to be passed.
16
+ //
17
+ // The two strategies differ ONLY in what is passed to correctionSuffix — the same shipped function,
18
+ // the same wording, one list against its last element. So the comparison isolates accumulation and
19
+ // cannot quietly be measuring a reworded prompt. ../tests/retry-corrections-bench.test.ts asserts
20
+ // exactly that, offline.
21
+ //
22
+ // Never part of `npm test`: it issues real model calls. No client is constructed here and no key is
23
+ // read; the host supplies both, because ../../ARCHITECTURE.md is explicit that neither package
24
+ // imports the other and a host is what joins them.
25
+ //
26
+ // npx tsx benchmarks/retry-corrections.ts --preview # the prompt, both suffixes, the call budget
27
+ import type Anthropic from "@anthropic-ai/sdk";
28
+ import { fileURLToPath } from "node:url";
29
+ import { systemPromptFor, rangesUserMessage, RANGE_SCHEMA, validate, type RangeAIResponse } from "../ranges-prompt";
30
+ import { correctionSuffix } from "../finding-generate";
31
+ import type { Client } from "../types";
32
+
33
+ export const MAX_TOKENS = 1024;
34
+
35
+ /** The shipped ceiling. README.md § "When the model gets it wrong" derives it from cost, so the
36
+ * benchmark measures the loop that actually runs rather than an idealized unbounded one. */
37
+ export const MAX_ATTEMPTS = 3;
38
+
39
+ /** The score for a case that never validated. One worse than the ceiling, so "failed" is ordered
40
+ * after "succeeded on the last attempt" without pretending to know how many more it would need. */
41
+ export const CENSORED = MAX_ATTEMPTS + 1;
42
+
43
+ export interface RetryCase {
44
+ label: string;
45
+ client: Client;
46
+ marker: string;
47
+ /** What the lab reported in, and therefore what validate() requires back. */
48
+ expectedUnit: string;
49
+ }
50
+
51
+ export interface Strategy {
52
+ label: "accumulate" | "replace";
53
+ /** What gets appended to the user message after `priorRejections` failed attempts. */
54
+ suffix: (priorRejections: string[]) => string;
55
+ }
56
+
57
+ export const VERSIONS: Strategy[] = [
58
+ // The shipped behaviour: every rejection so far.
59
+ { label: "accumulate", suffix: correctionSuffix },
60
+ // The plausible alternative, and what the two call sites sent before this was fixed: the latest
61
+ // rejection only. Same function, so the wording is byte-identical for a given list.
62
+ { label: "replace", suffix: (prior) => correctionSuffix(prior.slice(-1)) },
63
+ ];
64
+
65
+ const DATES = ["2024-02-14", "2024-09-03", "2025-04-21", "2025-11-08", "2026-05-19"];
66
+ const GOALS = ["lower ApoB", "improve insulin sensitivity", "preserve lean mass", "lower inflammation"];
67
+ const FOCUS = ["cardiovascular", "metabolic", "body composition", "inflammatory"];
68
+
69
+ function labCase(
70
+ i: number,
71
+ marker: string,
72
+ unit: string,
73
+ dob: string,
74
+ gender: "male" | "female",
75
+ values: number[],
76
+ ): RetryCase {
77
+ const client: Client = {
78
+ displayName: `Bench ${String(i).padStart(2, "0")}`,
79
+ dob,
80
+ gender,
81
+ watchlist: [marker],
82
+ results: values.map((value, j) => ({ marker, group: "Chemistry", source: "lab", date: DATES[j], value, unit })),
83
+ factors: { goal: GOALS[i % GOALS.length], focus: FOCUS[i % FOCUS.length], athletic: "moderate" },
84
+ };
85
+ return { label: `${marker} in ${unit}`, client, marker, expectedUnit: unit };
86
+ }
87
+
88
+ // Twelve synthetic patients, no PHI. Every unit here is one a lab really reports and a model really
89
+ // tends to answer in something else — SI where the model reaches for conventional US units, and in
90
+ // four cases (g/L, cm, kg) a unit that ALSO makes explanationImperial mandatory. That second
91
+ // condition is the point: it is what lets an attempt fix the unit and lose the imperial line.
92
+ export const CASES: RetryCase[] = [
93
+ labCase(1, "Total Protein", "g/L", "1958-03-11", "male", [68, 71, 70]),
94
+ labCase(2, "Albumin", "g/L", "1990-07-22", "female", [42, 44, 43, 45]),
95
+ labCase(3, "Hemoglobin", "g/L", "1975-01-05", "male", [148, 152, 150]),
96
+ labCase(4, "Glucose", "mmol/L", "1982-11-30", "female", [5.1, 5.4]),
97
+ labCase(5, "Total Cholesterol", "mmol/L", "1965-06-18", "male", [4.8, 5.2, 5.0, 4.9, 5.1]),
98
+ labCase(6, "Triglycerides", "mmol/L", "1988-09-02", "female", [1.1, 1.3, 1.2]),
99
+ labCase(7, "Creatinine", "µmol/L", "1970-04-14", "male", [82, 88, 85, 90]),
100
+ labCase(8, "Vitamin D, 25-OH", "nmol/L", "1993-12-25", "female", [62, 71, 68]),
101
+ labCase(9, "Ferritin", "µg/L", "1960-02-09", "male", [120, 145]),
102
+ labCase(10, "Testosterone, Total", "nmol/L", "1978-08-17", "male", [15.2, 16.8, 14.9]),
103
+ labCase(11, "Waist Circumference", "cm", "1985-05-27", "female", [82, 80, 79, 81]),
104
+ labCase(12, "Body Weight", "kg", "1955-10-03", "male", [78.4, 77.1, 76.5]),
105
+ ];
106
+
107
+ /** k independent samples per case, expressed as k copies of the case list. compareBrains scores a
108
+ * flat array, so replicates need no API change — and variance across identical cases is the only
109
+ * way to tell a real difference from one sampling run. */
110
+ export function withReplicates(cases: RetryCase[], k: number): RetryCase[] {
111
+ return Array.from({ length: k }, (_, r) => cases.map((c) => ({ ...c, label: `${c.label} #${r + 1}` }))).flat();
112
+ }
113
+
114
+ export interface RetryOutcome {
115
+ /** 1..MAX_ATTEMPTS when it validated; CENSORED when it never did. */
116
+ attempts: number;
117
+ ok: boolean;
118
+ /** The validator's own messages, in order — what each strategy had available to send. */
119
+ rejections: string[];
120
+ }
121
+
122
+ /** The retry loop, shaped exactly like generateFindingResponse's: build the message, call, validate,
123
+ * and on rejection append the validator's message and go again. The only thing the strategy
124
+ * changes is which of those messages the next attempt gets to see. */
125
+ export async function runCase(
126
+ anthropic: Anthropic,
127
+ model: string,
128
+ c: RetryCase,
129
+ strategy: Strategy,
130
+ ): Promise<RetryOutcome> {
131
+ const rejections: string[] = [];
132
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
133
+ const response = await anthropic.messages.create({
134
+ model,
135
+ max_tokens: MAX_TOKENS,
136
+ system: [{ type: "text", text: systemPromptFor(c.client) }],
137
+ output_config: { format: { type: "json_schema", schema: RANGE_SCHEMA } },
138
+ messages: [{ role: "user", content: rangesUserMessage(c.client, c.marker) + strategy.suffix(rejections) }],
139
+ });
140
+ const textBlock = response.content.find((b) => b.type === "text");
141
+ let parsed: RangeAIResponse | null = null;
142
+ if (textBlock && textBlock.type === "text") {
143
+ try {
144
+ parsed = JSON.parse(textBlock.text) as RangeAIResponse;
145
+ } catch (e) {
146
+ rejections.push(`response was not valid JSON: ${(e as Error).message}`);
147
+ continue;
148
+ }
149
+ }
150
+ if (!parsed) {
151
+ rejections.push("no text block in response");
152
+ continue;
153
+ }
154
+ try {
155
+ validate(c.marker, c.expectedUnit, parsed);
156
+ return { attempts: attempt, ok: true, rejections };
157
+ } catch (e) {
158
+ rejections.push((e as Error).message);
159
+ }
160
+ }
161
+ return { attempts: CENSORED, ok: false, rejections };
162
+ }
163
+
164
+ /** Attempts until the package's own validate() passed, censored at CENSORED. Lower is better —
165
+ * the one place in this repo where a lower mean is the better result, which BENCHMARKS.md says
166
+ * next to the table rather than leaving to be inferred. */
167
+ export function score(result: RetryOutcome): number {
168
+ return result.attempts;
169
+ }
170
+
171
+ /** Reported alongside the mean, never instead of it: a mean over censored values hides a difference
172
+ * that is entirely in the failure rate. */
173
+ export function successRate(outcomes: RetryOutcome[]): number {
174
+ return outcomes.length === 0 ? 0 : outcomes.filter((o) => o.ok).length / outcomes.length;
175
+ }
176
+
177
+ /** Wilson score interval — the right interval for a proportion at small n, where the textbook
178
+ * normal approximation puts the bound above 1 and reports [1.00, 1.00] for a clean sweep. */
179
+ export function wilson(successes: number, n: number, z = 1.96): [number, number] {
180
+ if (n === 0) return [0, 1];
181
+ const p = successes / n;
182
+ const d = 1 + (z * z) / n;
183
+ const centre = p + (z * z) / (2 * n);
184
+ const half = z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n));
185
+ return [Math.max(0, (centre - half) / d), Math.min(1, (centre + half) / d)];
186
+ }
187
+
188
+ /** Exact two-sided sign test over the discordant pairs — the cases where the two strategies
189
+ * disagreed. Paired, because both strategies run the same case: the cases differ enormously in
190
+ * difficulty, and an unpaired comparison spends most of its power on that instead of on the
191
+ * strategy. Ties carry no information about direction and are excluded, which is the test. */
192
+ export function signTest(wins: number, discordant: number): number {
193
+ if (discordant === 0) return 1;
194
+ const tail = Math.min(wins, discordant - wins);
195
+ let sum = 0;
196
+ let c = 1;
197
+ for (let i = 0; i <= tail; i++) {
198
+ sum += c;
199
+ c = (c * (discordant - i)) / (i + 1);
200
+ }
201
+ return Math.min(1, (2 * sum) / Math.pow(2, discordant));
202
+ }
203
+
204
+ /** The smallest number of discordant wins that would reach p < 0.05 — the minimum detectable
205
+ * effect, pre-registered rather than discovered afterwards. Returns Infinity when no split of
206
+ * `discordant` pairs can reach significance, which is the honest answer at very small n and the
207
+ * reason to compute it BEFORE spending on a run. */
208
+ export function minimumDetectableWins(discordant: number, alpha = 0.05): number {
209
+ for (let w = Math.ceil(discordant / 2); w <= discordant; w++) if (signTest(w, discordant) < alpha) return w;
210
+ return Infinity;
211
+ }
212
+
213
+ function main(): void {
214
+ const replicates = process.argv.includes("--replicates") ? Number(process.argv[process.argv.indexOf("--replicates") + 1]) : 1;
215
+ const cases = withReplicates(CASES, replicates);
216
+ const rejections = [
217
+ `range for "Total Protein" returned unit "g/dL" but lab data is in "g/L"`,
218
+ `range for "Total Protein" (unit g/L) missing imperial explanation`,
219
+ ];
220
+ const [accumulate, replace] = VERSIONS;
221
+ process.stdout.write(
222
+ `retry-corrections: ${cases.length} cases × ${VERSIONS.length} strategies × up to ${MAX_ATTEMPTS} attempts ` +
223
+ `= at most ${cases.length * VERSIONS.length * MAX_ATTEMPTS} calls\n` +
224
+ `This module makes no calls itself; a host drives it through compareBrains (see BENCHMARKS.md).\n\n` +
225
+ `--- the user message (case 1, attempt 1) ---\n${rangesUserMessage(CASES[0].client, CASES[0].marker)}\n\n` +
226
+ `--- after two rejections, "${accumulate.label}" appends ---\n${accumulate.suffix(rejections)}\n\n` +
227
+ `--- after the same two, "${replace.label}" appends ---\n${replace.suffix(rejections)}\n`,
228
+ );
229
+ }
230
+
231
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
package/dates.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Authored-date helpers. Dates entered in the app are ISO strings; legacy values were month-only
2
+ // (`YYYY-MM`, from the old month pickers). The convention: a partial date means the last day of that
3
+ // period (the agreed migration rule), so both display and comparison are deterministic.
4
+
5
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
6
+
7
+ // Coerce a partial ISO date to the last day of its month/year: "2026-04" → "2026-04-30",
8
+ // "2026" → "2026-12-31". Full dates and "" pass through. Idempotent — safe to apply repeatedly.
9
+ export function endOfMonth(iso: string | undefined | null): string {
10
+ if (!iso) return "";
11
+ const [y, m, d] = iso.trim().split("-");
12
+ if (!y) return "";
13
+ if (d) return iso.trim();
14
+ if (!m) return `${y}-12-31`;
15
+ // Day 0 of the next month is the last day of month `m` (1-based), so `new Date(y, m, 0)` — used
16
+ // only to count days, never for display, so no timezone drift.
17
+ const last = new Date(Number(y), Number(m), 0).getDate();
18
+ return `${y}-${m.padStart(2, "0")}-${String(last).padStart(2, "0")}`;
19
+ }
20
+
21
+ // Human, unambiguous date: "Apr 30, 2026". Partial values are coerced to end-of-month first. Parsed
22
+ // from the ISO string directly (not `new Date(iso)`), so it never drifts a day across timezones.
23
+ export function formatDay(iso: string | undefined | null): string {
24
+ const full = endOfMonth(iso);
25
+ if (!full) return "";
26
+ const [y, m, d] = full.split("-");
27
+ return `${MONTHS[Number(m) - 1]} ${Number(d)}, ${y}`;
28
+ }
29
+
30
+ // M107 — a type="date" input fires `change` on every keystroke inside an already-plausible year
31
+ // segment, zero-padded (e.g. typing just "2" of "2026" reports "0002-08-15") — not only once the
32
+ // full year is typed. A plain truthiness/length check on `value` fires on that first padded digit.
33
+ // Require a 4-digit year that isn't itself a padding artifact (<1000) before treating it as done.
34
+ export function isCompleteDate(value: string): boolean {
35
+ return !!value && Number(value.slice(0, 4)) >= 1000;
36
+ }
@@ -0,0 +1,96 @@
1
+ // The transport half of "send a document to Claude and get JSON back", lifted out of
2
+ // report-extract.ts so the structured report extraction and the new plain-prose document reading
3
+ // (document-read.ts) cannot drift apart in how they assemble content, cap output, or report a
4
+ // failure. Pure of Node/process/env for the same reason report-extract.ts is: a Node caller, an
5
+ // edge-runtime caller and a browser-facing relay all run the identical code path.
6
+ //
7
+ // The thrown message PREFIXES are load-bearing, not cosmetic: a caller separates "the model
8
+ // produced something unusable" from a transport error by matching on them. They are API.
9
+ import type Anthropic from "@anthropic-ai/sdk";
10
+
11
+ // The document to read: already-extracted plaintext (a Node caller, via pdfjs; or a .txt/.md
12
+ // attachment read straight through) OR the raw PDF bytes as base64, which go to Claude as a native
13
+ // `document` block — an edge runtime has no pdfjs.
14
+ export type DocumentSource = { text: string } | { pdfBase64: string };
15
+
16
+ // Structural — a caller's own usage accumulator satisfies this, so no cost-accounting module is
17
+ // dragged in. Lives here rather than in report-extract.ts so both readers can record usage without
18
+ // importing the report module.
19
+ export interface UsageRecorder {
20
+ // Field types mirror the Anthropic SDK's Usage (nullable numbers) so response.usage passes
21
+ // straight through.
22
+ record(
23
+ model: string,
24
+ usage:
25
+ | {
26
+ input_tokens?: number | null;
27
+ output_tokens?: number | null;
28
+ cache_creation_input_tokens?: number | null;
29
+ cache_read_input_tokens?: number | null;
30
+ }
31
+ | null
32
+ | undefined,
33
+ ): void;
34
+ }
35
+
36
+ export interface DocumentModelCall {
37
+ anthropic: Anthropic;
38
+ source: DocumentSource;
39
+ /** Only ever used to name the file in the prompt and in error messages. */
40
+ sourceFile: string;
41
+ system: string;
42
+ /** JSON Schema. Index-signature shaped, which is what the SDK's output_config requires. */
43
+ schema: { [key: string]: unknown };
44
+ /** The instruction that accompanies the document, e.g. "Extract the report as JSON." */
45
+ instruction: string;
46
+ model: string;
47
+ maxTokens: number;
48
+ usage?: UsageRecorder;
49
+ }
50
+
51
+ /**
52
+ * One document in, one validated-shape-free JSON object out. Callers own their own validation —
53
+ * this only guarantees the response was a complete, parseable JSON body.
54
+ */
55
+ export async function readDocumentAsJson<T>(call: DocumentModelCall): Promise<T> {
56
+ const { anthropic, source, sourceFile, system, schema, instruction, model, maxTokens, usage } = call;
57
+
58
+ // Document block first, text last — the ordering Anthropic recommends for document/image inputs,
59
+ // and the one leaf-regen-anthropic.ts mirrors for images.
60
+ const content =
61
+ "text" in source
62
+ ? `Document file: ${sourceFile}\n\n--- BEGIN DOCUMENT ---\n${source.text}\n--- END DOCUMENT ---\n\n${instruction}`
63
+ : [
64
+ {
65
+ type: "document" as const,
66
+ source: { type: "base64" as const, media_type: "application/pdf" as const, data: source.pdfBase64 },
67
+ },
68
+ { type: "text" as const, text: `Document file: ${sourceFile}\n\n${instruction}` },
69
+ ];
70
+
71
+ const response = await anthropic.messages.create({
72
+ model,
73
+ max_tokens: maxTokens,
74
+ system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
75
+ output_config: { format: { type: "json_schema", schema } },
76
+ messages: [{ role: "user", content }],
77
+ });
78
+
79
+ if (response.stop_reason === "max_tokens") {
80
+ throw new Error(`extraction truncated (hit max_tokens) for "${sourceFile}" — raise max_tokens`);
81
+ }
82
+ const textBlock = response.content.find((b) => b.type === "text");
83
+ if (!textBlock || textBlock.type !== "text") {
84
+ throw new Error(
85
+ `no text block in response for "${sourceFile}" — stop_reason=${response.stop_reason}, types=${response.content.map((b) => b.type).join(",")}`,
86
+ );
87
+ }
88
+ let parsed: T;
89
+ try {
90
+ parsed = JSON.parse(textBlock.text) as T;
91
+ } catch {
92
+ throw new Error(`invalid JSON for "${sourceFile}": ${textBlock.text.slice(0, 200)}`);
93
+ }
94
+ usage?.record(model, response.usage);
95
+ return parsed;
96
+ }
@@ -0,0 +1,171 @@
1
+ // Reading an attached document as PROSE — the general-purpose counterpart to report-extract.ts's
2
+ // structured extraction. Reports needs a schema (diseases/markers/comorbidities); a note, a chat
3
+ // turn or a treatment needs the document's own words, so they can be quoted and reasoned over.
4
+ //
5
+ // Text out, not a schema, because every consumer downstream wants something quotable: a leaf turn
6
+ // folds it in as context, chat discusses it, the UI shows how much was read. The one structured
7
+ // field that IS returned is `isMedicalReport` — the owner's decision that Reports must reject a
8
+ // non-report while Chat and Notes accept anything. Getting it from the same call the transcription
9
+ // comes from means the gate costs nothing extra.
10
+ import type Anthropic from "@anthropic-ai/sdk";
11
+ import { readDocumentAsJson, type DocumentSource, type UsageRecorder } from "./document-model";
12
+
13
+ // A transcription is bounded by the document, not by the model's inclination to keep writing; 16k
14
+ // output tokens is roughly 60 pages of dense clinical prose, comfortably past MAX_DOCUMENT_PAGES.
15
+ export const DOCUMENT_READ_MAX_TOKENS = 16_000;
16
+
17
+ // The page ceiling, checked in the browser BEFORE upload (attachment-store.ts, via pdf-render's
18
+ // openPdf().numPages). Without it a 200-page PDF is billed in full as input and only then fails on
19
+ // the output ceiling — you pay for everything and get nothing.
20
+ export const MAX_DOCUMENT_PAGES = 60;
21
+
22
+ export interface DocumentReading {
23
+ /** A short human label for what the document is, e.g. "Radiology report", "Supplement label". */
24
+ documentKind: string;
25
+ /** True only for a clinical/lab/imaging report about a patient — the Reports import gate. */
26
+ isMedicalReport: boolean;
27
+ /** Present when isMedicalReport is false: one sentence saying what this is instead. */
28
+ notReportReason?: string;
29
+ /** The document's content as prose. */
30
+ text: string;
31
+ }
32
+
33
+ /** What the R2 sidecar holds, and what /api/document-extract returns — the reading plus provenance. */
34
+ export interface StoredExtraction extends DocumentReading {
35
+ at: string;
36
+ chars: number;
37
+ model?: string;
38
+ }
39
+
40
+ // How much of one document's text may ride along with a turn, and how much may in total. A
41
+ // transcription is bounded by MAX_DOCUMENT_PAGES, but a patient with several long documents on one
42
+ // leaf could still assemble a request larger than the relay's body cap — and unlike an image, text
43
+ // truncates gracefully. Truncation is always announced in the injected text, never silent.
44
+ export const MAX_DOCUMENT_CHARS = 60_000;
45
+ export const MAX_DOCUMENTS_TOTAL_CHARS = 150_000;
46
+
47
+ /** One document's extracted text, named, ready to ride along with a turn. */
48
+ export interface DocumentText {
49
+ name: string;
50
+ text: string;
51
+ }
52
+
53
+ /**
54
+ * The per-document and total caps, applied.
55
+ *
56
+ * This was enforced in the BROWSER only (a host-side extraction client), so the caps described
57
+ * two lines up as bounding "a request" bounded nothing a caller could not opt out of: the relay
58
+ * validated `documents` for shape and passed it straight to Anthropic, leaving a CLI or a scripted
59
+ * caller bounded by the 8 MB body cap alone — 8 MB of text into a prompt whose max_tokens assumes
60
+ * far less. Same rule, same wording, both sides of the relay; the browser now calls this rather than
61
+ * carrying its own copy of the arithmetic.
62
+ *
63
+ * Truncates rather than rejects, because a document that is too long is still worth reading most of,
64
+ * and the cut is always announced IN the text so the model never treats an excerpt as complete.
65
+ */
66
+ export function capDocuments(docs: DocumentText[]): DocumentText[] {
67
+ const out: DocumentText[] = [];
68
+ let budget = MAX_DOCUMENTS_TOTAL_CHARS;
69
+ for (const d of docs) {
70
+ const limit = Math.min(MAX_DOCUMENT_CHARS, budget);
71
+ if (limit <= 0) break;
72
+ const text = d.text.length > limit ? `${d.text.slice(0, limit)}\n\n[document truncated here — it is longer than this excerpt]` : d.text;
73
+ budget -= Math.min(d.text.length, limit);
74
+ out.push({ name: d.name, text });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * One prompt-ready block naming each document and quoting it, or "" when there are none.
81
+ *
82
+ * Pure, and here rather than in document-extract-client.ts, because both sides of the relay need
83
+ * it: the browser to size a request, the Anthropic caller (leaf-regen-anthropic.ts, which also runs
84
+ * from a Node CLI) to build the actual message. One wording, one place.
85
+ */
86
+ export function documentsPromptBlock(docs: DocumentText[]): string {
87
+ if (docs.length === 0) return "";
88
+ return [
89
+ "The patient attached the following document(s). Their contents were transcribed and are",
90
+ "reproduced verbatim below. Treat them as evidence the patient has provided: you may quote them",
91
+ "and rely on them, but do not infer beyond what they say, and do not treat a document's own",
92
+ "claims as established clinical fact when it is not a clinical report.",
93
+ "",
94
+ ...docs.map((d) => `--- BEGIN DOCUMENT: ${d.name} ---\n${d.text}\n--- END DOCUMENT: ${d.name} ---`),
95
+ ].join("\n");
96
+ }
97
+
98
+ export const DOCUMENT_READ_SCHEMA = {
99
+ type: "object",
100
+ properties: {
101
+ documentKind: { type: "string" },
102
+ isMedicalReport: { type: "boolean" },
103
+ notReportReason: { type: "string" },
104
+ text: { type: "string" },
105
+ },
106
+ required: ["documentKind", "isMedicalReport", "notReportReason", "text"],
107
+ additionalProperties: false,
108
+ } as const;
109
+
110
+ export const DOCUMENT_READ_SYSTEM_PROMPT = [
111
+ "You transcribe ONE attached document into plain text so it can be quoted and reasoned over",
112
+ "later. You are a reader, not an interpreter: never add a finding, a conclusion, a diagnosis or a",
113
+ "number the document does not itself contain.",
114
+ "",
115
+ "Return:",
116
+ "- text: the document's content as readable prose, faithful to the original. Keep every heading,",
117
+ " label, date, value and unit exactly as printed — those are what a later step will quote. Render",
118
+ " a table as one line per row with its column labels, since the consumer sees text only. Keep the",
119
+ " document's own order. Do not summarize, do not shorten, do not editorialize, and do not add",
120
+ " commentary of your own. If part of the document is unreadable, write [unreadable] there rather",
121
+ " than guessing at it.",
122
+ "- documentKind: a short Title Case label for what this document IS, e.g. 'Radiology report',",
123
+ " 'Lab results', 'Supplement label', 'Insurance letter', 'Research paper', 'Receipt'.",
124
+ "- isMedicalReport: true ONLY when this is a clinical report ABOUT A PATIENT that states results",
125
+ " — a lab panel, an imaging/radiology study, a pathology report, a diagnostic test result. It is",
126
+ " FALSE for a product label, a package insert, a research paper, a bill or an explanation of",
127
+ " benefits, an appointment letter, a consent form, marketing material, or anything not reporting",
128
+ " a patient's own measured results.",
129
+ "- notReportReason: when isMedicalReport is false, ONE sentence naming what the document is",
130
+ " instead. Empty string when it is true.",
131
+ ].join("\n");
132
+
133
+ export function validateReading(sourceFile: string, r: DocumentReading): void {
134
+ if (!r || typeof r !== "object") throw new Error(`document "${sourceFile}" produced no object`);
135
+ if (typeof r.text !== "string" || r.text.trim() === "") {
136
+ throw new Error(`document "${sourceFile}" produced no text`);
137
+ }
138
+ if (typeof r.isMedicalReport !== "boolean") {
139
+ throw new Error(`document "${sourceFile}" missing isMedicalReport`);
140
+ }
141
+ if (typeof r.documentKind !== "string") {
142
+ throw new Error(`document "${sourceFile}" missing documentKind`);
143
+ }
144
+ }
145
+
146
+ export async function readDocument(
147
+ anthropic: Anthropic,
148
+ source: DocumentSource,
149
+ sourceFile: string,
150
+ model: string,
151
+ usage?: UsageRecorder,
152
+ ): Promise<DocumentReading> {
153
+ const reading = await readDocumentAsJson<DocumentReading>({
154
+ anthropic,
155
+ source,
156
+ sourceFile,
157
+ system: DOCUMENT_READ_SYSTEM_PROMPT,
158
+ schema: DOCUMENT_READ_SCHEMA,
159
+ instruction: "Transcribe this document as JSON.",
160
+ model,
161
+ maxTokens: DOCUMENT_READ_MAX_TOKENS,
162
+ usage,
163
+ });
164
+ validateReading(sourceFile, reading);
165
+ return reading;
166
+ }
167
+
168
+ // The prefixes readDocumentAsJson and validateReading throw with — everything here is the model
169
+ // failing to produce something usable, as opposed to a transport error worth retrying. A caller
170
+ // matches on this to decide which of the two it has, so the shape is API.
171
+ export const DOCUMENT_READ_FAILURE = /^document "|^extraction truncated|^invalid JSON for|^no text block/;