@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The treatmentGroups finding node, extracted so a host web endpoint and a host
|
|
2
|
+
// CLI leaf-regen can regenerate just this leaf without the ~$5 monolithic call. This is the
|
|
3
|
+
// single source of truth for the prompt, the tool schema, the validator, and the id→text resolver.
|
|
4
|
+
//
|
|
5
|
+
// Two structural robustness fixes land here, where the node is small enough for both to
|
|
6
|
+
// be practical (the monolith's combined schema outgrew the grammar ceiling):
|
|
7
|
+
// #1 id-refs — every input carries a short stable id (S#/P#/A#/AI#); the model references the id,
|
|
8
|
+
// never the free text, so the "ref must match a presented string exactly" failure class is gone.
|
|
9
|
+
// #3 tool-schema — the output is emitted through a JSON-schema tool call, not hand-rolled JSON in a
|
|
10
|
+
// text block, so escaping/format failures largely disappear.
|
|
11
|
+
// Isomorphic: no node:/SDK imports, so both a Pages Function and the browser can build inputs, validate,
|
|
12
|
+
// and resolve. The Anthropic call itself lives in each caller (Function / CLI), which supply prompt+tool.
|
|
13
|
+
|
|
14
|
+
import type { Client, TreatmentGroup } from "./types";
|
|
15
|
+
import { treatmentsOf } from "./treatment-normalize";
|
|
16
|
+
import { bucketOf, treatmentLabel, todayISODate } from "./treatment-bucket";
|
|
17
|
+
|
|
18
|
+
export interface RegroupItem {
|
|
19
|
+
id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
purpose?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RegroupInputs {
|
|
24
|
+
systems: { id: string; name: string }[]; // S# — disease[].group, in order
|
|
25
|
+
patientHypotheses: RegroupItem[]; // P# — factors.decisions
|
|
26
|
+
planActions: RegroupItem[]; // A# — planned (future-dated) treatments
|
|
27
|
+
aiInterventions: RegroupItem[]; // AI# — finding.decisions.ai
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// id-ref'd group as the model emits it (before resolving ids back to text).
|
|
31
|
+
export interface GroupRef {
|
|
32
|
+
system: string; // an S# id
|
|
33
|
+
topic: string;
|
|
34
|
+
patient: string[]; // P#/A# ids
|
|
35
|
+
ai: string[]; // AI# ids
|
|
36
|
+
}
|
|
37
|
+
export interface RegroupResponse {
|
|
38
|
+
groups: GroupRef[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildRegroupInputs(client: Client): RegroupInputs {
|
|
42
|
+
const today = todayISODate();
|
|
43
|
+
return {
|
|
44
|
+
systems: (client.finding?.disease ?? []).map((d, i) => ({ id: `S${i + 1}`, name: d.group.trim() })),
|
|
45
|
+
patientHypotheses: (client.factors?.decisions ?? []).map((d, i) => ({ id: `P${i + 1}`, text: d.intervention.trim(), purpose: d.purpose })),
|
|
46
|
+
planActions: treatmentsOf(client)
|
|
47
|
+
.filter((t) => bucketOf(t, today) === "planned")
|
|
48
|
+
.map((t, i) => ({ id: `A${i + 1}`, text: treatmentLabel(t) })),
|
|
49
|
+
aiInterventions: (client.finding?.decisions?.ai ?? []).map((d, i) => ({ id: `AI${i + 1}`, text: d.intervention.trim(), purpose: d.purpose })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const REGROUP_SYSTEM_PROMPT =
|
|
54
|
+
"You partition a patient's proposed treatments into clinically-coherent clusters — the patient's " +
|
|
55
|
+
"view beside the AI's — so a doctor can see where they agree, differ, or have no counterpart. You " +
|
|
56
|
+
"reference every item by its short id (S#/P#/A#/AI#), never by copying its text. Emit the result " +
|
|
57
|
+
"ONLY through the emit_treatment_groups tool.";
|
|
58
|
+
|
|
59
|
+
export function regroupUserPrompt(inputs: RegroupInputs): string {
|
|
60
|
+
const sec: string[] = [];
|
|
61
|
+
sec.push("Body systems (reference by id; a group's `system` is exactly one of these ids), in order:");
|
|
62
|
+
for (const s of inputs.systems) sec.push(` ${s.id}: ${s.name}`);
|
|
63
|
+
sec.push("Patient hypotheses — interventions the patient is weighing (reference in `patient` by id):");
|
|
64
|
+
for (const p of inputs.patientHypotheses) sec.push(` ${p.id}: ${p.text}${p.purpose ? ` — ${p.purpose}` : ""}`);
|
|
65
|
+
sec.push("Patient Plan actions — committed steps (reference in `patient` by id; ignore any timing):");
|
|
66
|
+
for (const a of inputs.planActions) sec.push(` ${a.id}: ${a.text}`);
|
|
67
|
+
sec.push("AI interventions — the AI's proposed set (reference in `ai` by id):");
|
|
68
|
+
for (const ai of inputs.aiInterventions) sec.push(` ${ai.id}: ${ai.text}${ai.purpose ? ` — ${ai.purpose}` : ""}`);
|
|
69
|
+
sec.push(
|
|
70
|
+
[
|
|
71
|
+
"Rules:",
|
|
72
|
+
"- Each group is { system, topic, patient, ai }: an S# id, a drug-class label, patient ids on the",
|
|
73
|
+
" left, ai ids on the right.",
|
|
74
|
+
"- topic is the DRUG CLASS (≤6 words: \"Lipid-lowering\", \"ARB\", \"GLP-1 / incretin\", \"Androgen",
|
|
75
|
+
" support\", \"Methyl donors\"), NOT a hoped-for benefit. Cluster items sharing a class together;",
|
|
76
|
+
" never split a class across groups by benefit.",
|
|
77
|
+
"- HARD COVERAGE: every P#, every A#, and every AI# appears in EXACTLY ONE group. Never invent an id.",
|
|
78
|
+
" Many-to-many is fine — a patient item may sit with several ai items and vice versa. Fold a",
|
|
79
|
+
" \"Continue X\" plan action into the group of the therapy it continues.",
|
|
80
|
+
"- system is the body system the therapy primarily acts on.",
|
|
81
|
+
"- ORDER groups by their system id in the order listed above; all groups sharing a system MUST be",
|
|
82
|
+
" contiguous. A group may be patient-only (empty ai) or ai-only (empty patient), but not both empty.",
|
|
83
|
+
].join("\n"),
|
|
84
|
+
);
|
|
85
|
+
return sec.join("\n\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const TREATMENT_GROUPS_TOOL = {
|
|
89
|
+
name: "emit_treatment_groups",
|
|
90
|
+
description: "Emit the partition of proposed treatments into system/drug-class clusters, referencing every item by its id.",
|
|
91
|
+
input_schema: {
|
|
92
|
+
type: "object" as const,
|
|
93
|
+
properties: {
|
|
94
|
+
groups: {
|
|
95
|
+
type: "array",
|
|
96
|
+
description: "The clusters, ordered by system id, systems contiguous.",
|
|
97
|
+
items: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
system: { type: "string", description: "a body-system id (S1, S2, …)" },
|
|
101
|
+
topic: { type: "string", description: "the drug-class label (≤6 words)" },
|
|
102
|
+
patient: { type: "array", items: { type: "string" }, description: "patient item ids (P#/A#); may be empty" },
|
|
103
|
+
ai: { type: "array", items: { type: "string" }, description: "AI intervention ids (AI#); may be empty" },
|
|
104
|
+
},
|
|
105
|
+
required: ["system", "topic", "patient", "ai"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
required: ["groups"],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Id-based coverage / order / contiguity checks — the verbatim-match logic from the monolith
|
|
114
|
+
// (claude-finding.ts) ported to ids, so nothing depends on the model echoing free text exactly.
|
|
115
|
+
export function validateRegroup(resp: unknown, inputs: RegroupInputs): void {
|
|
116
|
+
const r = resp as RegroupResponse;
|
|
117
|
+
if (!r || !Array.isArray(r.groups)) throw new Error("groups missing or not an array");
|
|
118
|
+
|
|
119
|
+
const systemOrder = inputs.systems.map((s) => s.id);
|
|
120
|
+
const patientIds = new Set([...inputs.patientHypotheses, ...inputs.planActions].map((x) => x.id));
|
|
121
|
+
const aiIds = new Set(inputs.aiInterventions.map((x) => x.id));
|
|
122
|
+
const seenPatient = new Set<string>();
|
|
123
|
+
const seenAi = new Set<string>();
|
|
124
|
+
let prevSysIdx = -1;
|
|
125
|
+
const systemsSeen = new Set<string>();
|
|
126
|
+
|
|
127
|
+
for (const [i, g] of r.groups.entries()) {
|
|
128
|
+
if (!g || typeof g.topic !== "string" || g.topic.trim().length === 0) throw new Error(`groups[${i}].topic missing`);
|
|
129
|
+
if (typeof g.system !== "string") throw new Error(`groups[${i}] (${g.topic}) system missing`);
|
|
130
|
+
const sysIdx = systemOrder.indexOf(g.system);
|
|
131
|
+
if (sysIdx < 0) throw new Error(`groups[${i}] (${g.topic}) system "${g.system}" is not one of the system ids`);
|
|
132
|
+
if (sysIdx < prevSysIdx) throw new Error(`groups[${i}] (${g.topic}) system "${g.system}" is out of order`);
|
|
133
|
+
if (sysIdx !== prevSysIdx && systemsSeen.has(g.system)) {
|
|
134
|
+
throw new Error(`groups system "${g.system}" is not contiguous — all groups of a system must be adjacent`);
|
|
135
|
+
}
|
|
136
|
+
prevSysIdx = sysIdx;
|
|
137
|
+
systemsSeen.add(g.system);
|
|
138
|
+
if (!Array.isArray(g.patient) || !Array.isArray(g.ai)) throw new Error(`groups[${i}] (${g.topic}) patient and ai must be arrays`);
|
|
139
|
+
if (g.patient.length === 0 && g.ai.length === 0) throw new Error(`groups[${i}] (${g.topic}) has neither patient nor ai items`);
|
|
140
|
+
for (const ref of g.ai) {
|
|
141
|
+
if (!aiIds.has(ref)) throw new Error(`groups[${i}] (${g.topic}) ai ref "${ref}" is not an AI# id`);
|
|
142
|
+
if (seenAi.has(ref)) throw new Error(`ai ref "${ref}" appears in more than one group`);
|
|
143
|
+
seenAi.add(ref);
|
|
144
|
+
}
|
|
145
|
+
for (const ref of g.patient) {
|
|
146
|
+
if (!patientIds.has(ref)) throw new Error(`groups[${i}] (${g.topic}) patient ref "${ref}" is not a P#/A# id`);
|
|
147
|
+
if (seenPatient.has(ref)) throw new Error(`patient ref "${ref}" appears in more than one group`);
|
|
148
|
+
seenPatient.add(ref);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const id of aiIds) if (!seenAi.has(id)) throw new Error(`AI intervention ${id} is not placed in any group`);
|
|
152
|
+
for (const id of patientIds) if (!seenPatient.has(id)) throw new Error(`patient item ${id} is not placed in any group`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Resolve id-refs back to the stored TreatmentGroup[] shape (verbatim text), which the render-side
|
|
156
|
+
// resolver (treatment-groups.ts) and FutureTreatment already consume. Assumes validateRegroup passed.
|
|
157
|
+
export function resolveRegroup(resp: RegroupResponse, inputs: RegroupInputs): TreatmentGroup[] {
|
|
158
|
+
const sysName = new Map(inputs.systems.map((s) => [s.id, s.name]));
|
|
159
|
+
const patientText = new Map([...inputs.patientHypotheses, ...inputs.planActions].map((x) => [x.id, x.text]));
|
|
160
|
+
const aiText = new Map(inputs.aiInterventions.map((x) => [x.id, x.text]));
|
|
161
|
+
return resp.groups.map((g) => ({
|
|
162
|
+
system: sysName.get(g.system) ?? g.system,
|
|
163
|
+
topic: g.topic.trim(),
|
|
164
|
+
patient: g.patient.map((id) => patientText.get(id) ?? id),
|
|
165
|
+
ai: g.ai.map((id) => aiText.get(id) ?? id),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Canonical names for imaging-derived markers. LLM extraction (claude-report.ts)
|
|
2
|
+
// names the same metric inconsistently across runs/reports ("LVEF" vs "LV
|
|
3
|
+
// Ejection Fraction (Biplane Simpson)"), and because marker name is the de-facto
|
|
4
|
+
// primary key (ranges, dedup, trends), that drift fragments a metric into
|
|
5
|
+
// separate series. This is the imaging analogue of the STABLE names the
|
|
6
|
+
// deterministic blood/DEXA parsers emit by construction.
|
|
7
|
+
//
|
|
8
|
+
// canonicalImagingMarker() maps a raw extracted name to its canonical form:
|
|
9
|
+
// pure case/whitespace/punctuation variants of a canonical name collapse
|
|
10
|
+
// automatically; genuinely different phrasings are listed in ALIASES. Unknown
|
|
11
|
+
// names pass through unchanged (the alias map is the knob — extend it as new
|
|
12
|
+
// metrics appear).
|
|
13
|
+
|
|
14
|
+
export const CANONICAL_IMAGING_MARKERS: string[] = [
|
|
15
|
+
// Cardiac CT
|
|
16
|
+
"Coronary artery calcium (CAC) score",
|
|
17
|
+
"CAC score — left main",
|
|
18
|
+
"CAC score — left anterior descending",
|
|
19
|
+
"CAC score — circumflex",
|
|
20
|
+
"CAC score — right coronary artery",
|
|
21
|
+
// Echocardiogram
|
|
22
|
+
"Left ventricular ejection fraction (LVEF)",
|
|
23
|
+
"Left ventricular end-diastolic volume",
|
|
24
|
+
"Left ventricular end-systolic volume",
|
|
25
|
+
"TAPSE",
|
|
26
|
+
"Aortic root diameter",
|
|
27
|
+
"Ascending aorta diameter",
|
|
28
|
+
"Left atrial volume (A4C)",
|
|
29
|
+
"Right atrial volume (A4C)",
|
|
30
|
+
"E/A ratio",
|
|
31
|
+
"E/e′ average",
|
|
32
|
+
"e′ average",
|
|
33
|
+
"Left ventricular mass",
|
|
34
|
+
"LVOT stroke-volume index",
|
|
35
|
+
"Left atrial volume index",
|
|
36
|
+
"Right atrial volume index",
|
|
37
|
+
"IVC diameter",
|
|
38
|
+
"Aortic valve area",
|
|
39
|
+
"Aortic valve area index",
|
|
40
|
+
"Aortic valve max velocity",
|
|
41
|
+
"Aortic valve peak gradient",
|
|
42
|
+
"Aortic valve mean gradient",
|
|
43
|
+
// Renal / abdominal ultrasound
|
|
44
|
+
"Right kidney length",
|
|
45
|
+
"Left kidney length",
|
|
46
|
+
"Right renal cortical thickness",
|
|
47
|
+
"Left renal cortical thickness",
|
|
48
|
+
"Pre-void bladder volume",
|
|
49
|
+
"Post-void residual volume",
|
|
50
|
+
"Prostate volume",
|
|
51
|
+
"Common bile duct diameter",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Variant phrasing → canonical. Keys are matched after normalization, so only
|
|
55
|
+
// genuinely different wording needs an entry (not mere case/spacing/dash diffs).
|
|
56
|
+
const ALIASES: Record<string, string> = {
|
|
57
|
+
"lv ejection fraction biplane simpson": "Left ventricular ejection fraction (LVEF)",
|
|
58
|
+
"lv ejection fraction biplane simpson s": "Left ventricular ejection fraction (LVEF)",
|
|
59
|
+
"left ventricular ejection fraction biplane simpson": "Left ventricular ejection fraction (LVEF)",
|
|
60
|
+
"lvef": "Left ventricular ejection fraction (LVEF)",
|
|
61
|
+
"lv end diastolic volume biplane": "Left ventricular end-diastolic volume",
|
|
62
|
+
"lv end systolic volume biplane": "Left ventricular end-systolic volume",
|
|
63
|
+
"lad cac score": "CAC score — left anterior descending",
|
|
64
|
+
"mid ascending aorta diameter": "Ascending aorta diameter",
|
|
65
|
+
"right kidney size": "Right kidney length",
|
|
66
|
+
"left kidney size": "Left kidney length",
|
|
67
|
+
"post void residual": "Post-void residual volume",
|
|
68
|
+
"post void residual bladder volume": "Post-void residual volume",
|
|
69
|
+
"ava": "Aortic valve area",
|
|
70
|
+
"avai": "Aortic valve area index",
|
|
71
|
+
"ava index": "Aortic valve area index",
|
|
72
|
+
"aortic valve area indexed": "Aortic valve area index",
|
|
73
|
+
"peak gradient": "Aortic valve peak gradient",
|
|
74
|
+
"aortic valve peak instantaneous gradient": "Aortic valve peak gradient",
|
|
75
|
+
"e e prime average": "E/e′ average",
|
|
76
|
+
"e e prime": "E/e′ average",
|
|
77
|
+
"e e ratio": "E/e′ average",
|
|
78
|
+
"e prime average": "e′ average",
|
|
79
|
+
"lv mass": "Left ventricular mass",
|
|
80
|
+
"lvot svi": "LVOT stroke-volume index",
|
|
81
|
+
"lvot stroke volume index si": "LVOT stroke-volume index",
|
|
82
|
+
"stroke volume index": "LVOT stroke-volume index",
|
|
83
|
+
"la volume index": "Left atrial volume index",
|
|
84
|
+
"lavi": "Left atrial volume index",
|
|
85
|
+
"left atrial volume index biplane": "Left atrial volume index",
|
|
86
|
+
"ra volume index": "Right atrial volume index",
|
|
87
|
+
"ravi": "Right atrial volume index",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function normalize(s: string): string {
|
|
91
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// normalized canonical form → canonical display name (auto-handles case/space/dash variants)
|
|
95
|
+
const CANON_BY_NORM = new Map<string, string>(CANONICAL_IMAGING_MARKERS.map((c) => [normalize(c), c]));
|
|
96
|
+
|
|
97
|
+
export function canonicalImagingMarker(name: string): string {
|
|
98
|
+
const key = normalize(name);
|
|
99
|
+
return ALIASES[key] ?? CANON_BY_NORM.get(key) ?? name.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Whether `name` is in the catalog (an alias or a canonical form) vs passed
|
|
103
|
+
// through verbatim. Drives the ingest-time "uncataloged marker" report so
|
|
104
|
+
// divergent names surface for cataloging instead of silently splitting a series.
|
|
105
|
+
export function isKnownImagingMarker(name: string): boolean {
|
|
106
|
+
const key = normalize(name);
|
|
107
|
+
return key in ALIASES || CANON_BY_NORM.has(key);
|
|
108
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The isomorphic core: safe to import from a browser bundle, a Cloudflare Pages Function, or a
|
|
2
|
+
// node:crypto-based CLI alike. Runtime-specific pieces (pdfjs-dist) live in ./pdf-node instead —
|
|
3
|
+
// import that directly rather than adding a node-specific dependency here.
|
|
4
|
+
export {};
|
package/ingest-core.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The PURE ingest core: fold + parse + provenance, with no Node/fs/process
|
|
2
|
+
// dependency, so both a CLI and a browser/serverless ingest path reuse the exact same logic. The
|
|
3
|
+
// non-pure edges stay outside, in host-side modules:
|
|
4
|
+
// - reading bytes / copying files / writing artifacts → a host CLI (node:fs)
|
|
5
|
+
// - content hashing (hashSource) → a host module (node:crypto;
|
|
6
|
+
// a browser supplies SubtleCrypto)
|
|
7
|
+
// - the report LLM extraction (proposeFromReport) → a host module (injected)
|
|
8
|
+
// This module takes BYTES + already-extracted data and mutates an in-memory Client.
|
|
9
|
+
|
|
10
|
+
import type { Client, SourceRecord } from "./types";
|
|
11
|
+
|
|
12
|
+
// The lab/dexa/scale byte parser (parseRawFile) lives in ./parse-raw so this module
|
|
13
|
+
// stays parser-free — parseDexa pulls in pdfjs-dist, which a browser fold path must not bundle.
|
|
14
|
+
|
|
15
|
+
// ── Content hash, WebCrypto twin of the Node hash a server-side caller uses. Same sha256 hex +
|
|
16
|
+
// 12-char id, so a Node import and a browser upload dedup identically. ──
|
|
17
|
+
export async function hashSourceWeb(bytes: Uint8Array): Promise<{ sha256: string; id: string }> {
|
|
18
|
+
const buf =
|
|
19
|
+
bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
20
|
+
? (bytes.buffer as ArrayBuffer)
|
|
21
|
+
: (bytes.slice().buffer as ArrayBuffer);
|
|
22
|
+
const digest = new Uint8Array(await (globalThis.crypto as Crypto).subtle.digest("SHA-256", buf));
|
|
23
|
+
let sha256 = "";
|
|
24
|
+
for (const b of digest) sha256 += b.toString(16).padStart(2, "0");
|
|
25
|
+
return { sha256, id: sha256.slice(0, 12) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Fold: merge parsed rows / a report extraction into an in-memory Client. Pure. ──
|
|
29
|
+
export { applySourceReadings, applyReportContribution, pruneOrphanImagingMarkers } from "./report-merge";
|
|
30
|
+
|
|
31
|
+
// ── Removal: cascade-delete one source + its derived data, with corroboration + a tombstone.
|
|
32
|
+
// The provenance check (provenanceIssues) backs vault:verify. Pure. ──
|
|
33
|
+
export { removeSource, provenanceIssues, processedSha8 } from "./report-merge";
|
|
34
|
+
export type { RemoveResult, ProvenanceIssue } from "./report-merge";
|
|
35
|
+
|
|
36
|
+
// ── Provenance: stored-name formatting + the SourceRecord registry. Pure. ──
|
|
37
|
+
|
|
38
|
+
const MONTHS = [
|
|
39
|
+
"January", "February", "March", "April", "May", "June",
|
|
40
|
+
"July", "August", "September", "October", "November", "December",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// "2026-05-19" → "2026May19"
|
|
44
|
+
export function formatDate(d: string): string {
|
|
45
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d.trim());
|
|
46
|
+
if (!m) return d.trim().replace(/[^a-zA-Z0-9]+/g, "") || "undated";
|
|
47
|
+
return `${m[1]}${MONTHS[+m[2] - 1]}${m[3]}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One date → "2026May19"; a span → "2025Sep02-2026May19".
|
|
51
|
+
export function dateSegment(dates: string[]): string {
|
|
52
|
+
const valid = dates.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort();
|
|
53
|
+
if (valid.length === 0) return "undated";
|
|
54
|
+
const lo = formatDate(valid[0]);
|
|
55
|
+
const hi = formatDate(valid[valid.length - 1]);
|
|
56
|
+
return lo === hi ? lo : `${lo}-${hi}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const TYPE_BY_KIND: Record<SourceRecord["kind"], string> = {
|
|
60
|
+
lab: "blood", dexa: "dexa", scale: "scale", imaging: "imaging",
|
|
61
|
+
};
|
|
62
|
+
export function typeForKind(kind: SourceRecord["kind"]): string {
|
|
63
|
+
return TYPE_BY_KIND[kind];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Words that describe the modality, not the region — dropped so the subtype is
|
|
67
|
+
// the body region (the user's example: "Renal Ultrasound" → "renal").
|
|
68
|
+
const MODALITY_WORDS = new Set([
|
|
69
|
+
"ultrasound", "us", "ct", "cta", "mri", "mr", "scan", "xray", "x", "ray",
|
|
70
|
+
"angiogram", "angiography", "limited", "with", "without", "wo", "w", "contrast", "and", "the", "of",
|
|
71
|
+
]);
|
|
72
|
+
const STUDY_SLUG_OVERRIDES: Record<string, string> = {
|
|
73
|
+
"transthoracic echocardiogram": "echo",
|
|
74
|
+
"echocardiogram": "echo",
|
|
75
|
+
};
|
|
76
|
+
export function slugStudyType(studyType: string): string {
|
|
77
|
+
const norm = studyType.toLowerCase().trim();
|
|
78
|
+
if (STUDY_SLUG_OVERRIDES[norm]) return STUDY_SLUG_OVERRIDES[norm];
|
|
79
|
+
const words = norm.replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean);
|
|
80
|
+
return words.find((w) => !MODALITY_WORDS.has(w)) ?? words[0] ?? "study";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function subtypeFor(kind: SourceRecord["kind"], studyType?: string): string {
|
|
84
|
+
if (kind === "imaging") return studyType ? slugStudyType(studyType) : "study";
|
|
85
|
+
return kind === "lab" ? "panel" : kind === "dexa" ? "bodycomp" : "inbody";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// <dateSeg>-<type>-<subtype>-<sha8>.<ext>, e.g. 2021October15-imaging-coronary-13da11c4.pdf
|
|
89
|
+
export function storedName(opts: {
|
|
90
|
+
sha256: string;
|
|
91
|
+
kind: SourceRecord["kind"];
|
|
92
|
+
subtype: string;
|
|
93
|
+
dates: string[];
|
|
94
|
+
ext: string;
|
|
95
|
+
}): string {
|
|
96
|
+
const subtype = opts.subtype.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "x";
|
|
97
|
+
return `${dateSegment(opts.dates)}-${typeForKind(opts.kind)}-${subtype}-${opts.sha256.slice(0, 8)}${opts.ext}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function findSourceBySha(client: Client, sha256: string): SourceRecord | undefined {
|
|
101
|
+
return client.sources?.find((s) => s.sha256 === sha256);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function upsertSourceRecord(client: Client, rec: SourceRecord): void {
|
|
105
|
+
client.sources ??= [];
|
|
106
|
+
const i = client.sources.findIndex((s) => s.id === rec.id);
|
|
107
|
+
if (i >= 0) client.sources[i] = rec;
|
|
108
|
+
else client.sources.push(rec);
|
|
109
|
+
// Re-ingesting a previously-removed sha clears its tombstone: the source is
|
|
110
|
+
// live again, so leaving the tombstone would be a tombstone/live provenance collision.
|
|
111
|
+
if (client.removedSources?.length) {
|
|
112
|
+
client.removedSources = client.removedSources.filter((t) => t.sourceId !== rec.id);
|
|
113
|
+
if (client.removedSources.length === 0) delete client.removedSources;
|
|
114
|
+
}
|
|
115
|
+
}
|
package/item-registry.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Pins for the items a finding GENERATES — questions, glossary terms, exploration items, analysis
|
|
2
|
+
// passages — none of which has a source record to hang a pin on.
|
|
3
|
+
//
|
|
4
|
+
// Every other pinnable thing in a host app is a row the user typed: a note, a study entry, a
|
|
5
|
+
// treatment, an allergy. Those have ids, and a pin is just a boolean on the row. These four do not.
|
|
6
|
+
// The next finding rewrites their lists wholesale, so an index is meaningless and an id would be
|
|
7
|
+
// re-minted on every regeneration — a pin keyed either way would silently move to a different item
|
|
8
|
+
// or vanish. The item's own TEXT is the only thing that survives, so that is the key.
|
|
9
|
+
//
|
|
10
|
+
// Matching is deliberately loose (case- and whitespace-insensitive): a regeneration that reflows
|
|
11
|
+
// "Ferritin" to "ferritin " must keep the pin. A regeneration that says something genuinely
|
|
12
|
+
// different SHOULD lose it — the pin was about that text.
|
|
13
|
+
//
|
|
14
|
+
// Records are minted lazily and deleted on unpin, so an unpinned item costs nothing and the
|
|
15
|
+
// registry never accumulates rows for content that no longer exists.
|
|
16
|
+
import type { Client, ItemRecord, ItemRecordKind } from "./types";
|
|
17
|
+
|
|
18
|
+
const SEP = "::";
|
|
19
|
+
|
|
20
|
+
/** The opaque id the sidebar carries on a row (SidebarLeafRow.itemId) for a generated item. */
|
|
21
|
+
export function itemRecordId(kind: ItemRecordKind, label: string): string {
|
|
22
|
+
return `${kind}${SEP}${label.trim()}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function itemKindOf(id: string): ItemRecordKind {
|
|
26
|
+
return id.slice(0, id.indexOf(SEP)) as ItemRecordKind;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function itemLabelOf(id: string): string {
|
|
30
|
+
return id.slice(id.indexOf(SEP) + SEP.length);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
function norm(s: string): string {
|
|
35
|
+
return s.trim().toLowerCase().replace(/\s+/g, " ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function matches(r: ItemRecord, kind: ItemRecordKind, label: string): boolean {
|
|
39
|
+
return r.kind === kind && norm(r.label) === norm(label);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isItemPinned(client: Client, id: string): boolean {
|
|
43
|
+
const kind = itemKindOf(id);
|
|
44
|
+
const label = itemLabelOf(id);
|
|
45
|
+
return (client.itemRegistry ?? []).some((r) => r.pinned && matches(r, kind, label));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Appends a pinned record on the first pin, drops it again on unpin. */
|
|
49
|
+
export function toggleItemPin(client: Client, id: string): Client {
|
|
50
|
+
const kind = itemKindOf(id);
|
|
51
|
+
const label = itemLabelOf(id);
|
|
52
|
+
const prior = client.itemRegistry ?? [];
|
|
53
|
+
const next = prior.some((r) => matches(r, kind, label))
|
|
54
|
+
? prior.filter((r) => !matches(r, kind, label))
|
|
55
|
+
: [...prior, { kind, label, pinned: true } as ItemRecord];
|
|
56
|
+
// Absent rather than [] when nothing is pinned — an untouched client's vault stays byte-identical
|
|
57
|
+
// to what it was before this feature existed, and findingInputsCanonicalString omits the key.
|
|
58
|
+
return next.length ? { ...client, itemRegistry: next } : (({ itemRegistry: _drop, ...rest }) => rest as Client)(client);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Convenience read for the sidebar builders, which have the text rather than the id. */
|
|
62
|
+
export function isPinnedItem(client: Client, kind: ItemRecordKind, label: string): boolean {
|
|
63
|
+
return isItemPinned(client, itemRecordId(kind, label));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every pinned generated item, in registry order. */
|
|
67
|
+
export function pinnedItems(client: Client): ItemRecord[] {
|
|
68
|
+
return (client.itemRegistry ?? []).filter((r) => r.pinned);
|
|
69
|
+
}
|
package/marker-deltas.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Cross-study deltas (W2). The single source of truth for "what changed" — shared
|
|
2
|
+
// by a host's chart/print surfaces and by the Finding prompt builder here, so the change
|
|
3
|
+
// shown on screen and the change the model reasons over can never disagree.
|
|
4
|
+
//
|
|
5
|
+
// Grouping is by stored marker name, which is canonical post-W1c (imaging-catalog),
|
|
6
|
+
// so a metric is one series. `fromComparison` rows (W1b) are real datapoints here; a
|
|
7
|
+
// directly-measured reading at the same marker|date already won at ingest.
|
|
8
|
+
|
|
9
|
+
import type { Client, MarkerResult } from "./types";
|
|
10
|
+
import { normalizeSeries } from "./unit-systems";
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export type Direction = "up" | "down" | "flat";
|
|
15
|
+
|
|
16
|
+
export interface DeltaChange {
|
|
17
|
+
abs: number; // latest.value - reference.value, in the marker's stored unit
|
|
18
|
+
pct: number | null; // percent change vs the reference; null when reference value is 0
|
|
19
|
+
direction: Direction;
|
|
20
|
+
spanDays: number; // days from the reference reading to the latest
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MarkerDelta {
|
|
24
|
+
marker: string;
|
|
25
|
+
unit: string;
|
|
26
|
+
latest: MarkerResult;
|
|
27
|
+
prior: MarkerResult; // the reading immediately before latest
|
|
28
|
+
vsPrior: DeltaChange;
|
|
29
|
+
// The first reading and the change against it — present only when the baseline is a
|
|
30
|
+
// distinct datapoint from `prior` (≥3 readings or ≥2 distinct dates), so a two-point
|
|
31
|
+
// series doesn't report the same change twice.
|
|
32
|
+
baseline?: MarkerResult;
|
|
33
|
+
vsBaseline?: DeltaChange;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function change(latest: MarkerResult, ref: MarkerResult): DeltaChange {
|
|
37
|
+
const abs = latest.value - ref.value;
|
|
38
|
+
const pct = ref.value !== 0 ? (abs / Math.abs(ref.value)) * 100 : null;
|
|
39
|
+
const direction: Direction = abs > 0 ? "up" : abs < 0 ? "down" : "flat";
|
|
40
|
+
const spanDays = Math.round(
|
|
41
|
+
(new Date(latest.date).getTime() - new Date(ref.date).getTime()) / DAY_MS,
|
|
42
|
+
);
|
|
43
|
+
return { abs, pct, direction, spanDays };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Delta for one marker's readings. `rows` need not be pre-sorted. Returns null when
|
|
47
|
+
// fewer than two readings exist (no change to report).
|
|
48
|
+
export function deltaForSeries(rows: MarkerResult[]): MarkerDelta | null {
|
|
49
|
+
if (rows.length < 2) return null;
|
|
50
|
+
// A mixed-unit series (e.g. an xls with both mg/dL and mmol/L) is reconciled to one
|
|
51
|
+
// canonical unit so the subtraction is like-for-like; a single-unit series is untouched.
|
|
52
|
+
const sorted = [...normalizeSeries(rows)].sort((a, b) => a.date.localeCompare(b.date));
|
|
53
|
+
const latest = sorted[sorted.length - 1];
|
|
54
|
+
const prior = sorted[sorted.length - 2];
|
|
55
|
+
const baseline = sorted[0];
|
|
56
|
+
const out: MarkerDelta = {
|
|
57
|
+
marker: latest.marker,
|
|
58
|
+
unit: latest.unit,
|
|
59
|
+
latest,
|
|
60
|
+
prior,
|
|
61
|
+
vsPrior: change(latest, prior),
|
|
62
|
+
};
|
|
63
|
+
if (baseline.date !== prior.date) {
|
|
64
|
+
out.baseline = baseline;
|
|
65
|
+
out.vsBaseline = change(latest, baseline);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Per-marker deltas across the whole vault. One entry per marker with ≥2 readings,
|
|
71
|
+
// sorted by marker name.
|
|
72
|
+
export function markerDeltas(client: Client): MarkerDelta[] {
|
|
73
|
+
const byMarker = new Map<string, MarkerResult[]>();
|
|
74
|
+
for (const r of client.results) {
|
|
75
|
+
if (!byMarker.has(r.marker)) byMarker.set(r.marker, []);
|
|
76
|
+
byMarker.get(r.marker)!.push(r);
|
|
77
|
+
}
|
|
78
|
+
const out: MarkerDelta[] = [];
|
|
79
|
+
for (const rows of byMarker.values()) {
|
|
80
|
+
const d = deltaForSeries(rows);
|
|
81
|
+
if (d) out.push(d);
|
|
82
|
+
}
|
|
83
|
+
return out.sort((a, b) => a.marker.localeCompare(b.marker));
|
|
84
|
+
}
|