@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/report-merge.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import type { Client, DiseaseEntry, MarkerResult, SourceRecord } from "./types";
|
|
2
|
+
import { addDisease, removeDiseasesBySourceId } from "./factors-edit";
|
|
3
|
+
|
|
4
|
+
export interface ProposedDisease {
|
|
5
|
+
date: string;
|
|
6
|
+
diagnostic: string;
|
|
7
|
+
summary?: string;
|
|
8
|
+
icdCodes?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normDate(s: string): string {
|
|
12
|
+
return s.trim().toLowerCase().replace(/[/.]/g, "-").replace(/\s+/g, " ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normDiag(s: string): string {
|
|
16
|
+
return s.trim().toLowerCase().replace(/\s+/g, " ").replace(/[.;,]+$/, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function diseaseKey(d: ProposedDisease): string {
|
|
20
|
+
return `${normDate(d.date)}|${normDiag(d.diagnostic)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function diagTokens(s: string): string[] {
|
|
24
|
+
return normDiag(s).split(" ").filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A coarse coded comorbidity (e.g. "Bicuspid aortic valve" [Q23.81]) is covered
|
|
28
|
+
// by a more-detailed finding on the same date when every token of its label
|
|
29
|
+
// appears in the finding's diagnostic ("Bicuspid aortic valve with mild
|
|
30
|
+
// stenosis; AVA …"). Same-date + strict-superset keeps it conservative: it only
|
|
31
|
+
// ever folds a short label into a longer one, never merges two findings.
|
|
32
|
+
function findSubsumingDisease(diseases: DiseaseEntry[], d: ProposedDisease): DiseaseEntry | undefined {
|
|
33
|
+
const dDate = normDate(d.date);
|
|
34
|
+
const labelTokens = diagTokens(d.diagnostic);
|
|
35
|
+
if (labelTokens.length === 0) return undefined;
|
|
36
|
+
return diseases.find((h) => {
|
|
37
|
+
if (normDate(h.date) !== dDate) return false;
|
|
38
|
+
const hostTokens = diagTokens(h.diagnostic);
|
|
39
|
+
if (hostTokens.length <= labelTokens.length) return false;
|
|
40
|
+
const hostSet = new Set(hostTokens);
|
|
41
|
+
return labelTokens.every((t) => hostSet.has(t));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mergeCodes(existing: string[] | undefined, incoming: string[]): string[] {
|
|
46
|
+
const out = [...(existing ?? [])];
|
|
47
|
+
for (const c of incoming) {
|
|
48
|
+
const v = c.trim();
|
|
49
|
+
if (v && !out.includes(v)) out.push(v);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ApplyResult {
|
|
55
|
+
diseasesAdded: number;
|
|
56
|
+
diseasesAdopted: number;
|
|
57
|
+
comorbiditiesMerged: number;
|
|
58
|
+
markersAdded: number;
|
|
59
|
+
markersAdopted: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Apply ONE source's extracted contribution, scoped by its sourceId. Idempotent:
|
|
63
|
+
// re-running replaces only this source's entries, never touching hand-entered
|
|
64
|
+
// diagnoses or other sources' entries. A provenance-less entry that matches an
|
|
65
|
+
// extracted one is *adopted* (stamped with this sourceId) rather than duplicated.
|
|
66
|
+
//
|
|
67
|
+
// `markers` must already carry source "Imaging" and sourceId === sourceId.
|
|
68
|
+
export function applyReportContribution(
|
|
69
|
+
client: Client,
|
|
70
|
+
sourceId: string,
|
|
71
|
+
diseases: ProposedDisease[],
|
|
72
|
+
markers: MarkerResult[],
|
|
73
|
+
): ApplyResult {
|
|
74
|
+
client.factors ??= {};
|
|
75
|
+
client.factors.diseases ??= [];
|
|
76
|
+
|
|
77
|
+
// 1. Drop this source's prior contribution so a re-run can't accumulate.
|
|
78
|
+
removeDiseasesBySourceId(client, sourceId);
|
|
79
|
+
client.results = client.results.filter(
|
|
80
|
+
(r) => !(r.source === "Imaging" && r.sourceId === sourceId),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// 2. Diseases — within-source dedup; a code-bearing comorbidity already
|
|
84
|
+
// covered by a more-detailed finding folds its code(s) into that row; else
|
|
85
|
+
// adopt a provenance-less match; else add. Findings are listed before
|
|
86
|
+
// comorbidities by the caller, so a comorbidity sees this source's findings.
|
|
87
|
+
let diseasesAdded = 0;
|
|
88
|
+
let diseasesAdopted = 0;
|
|
89
|
+
let comorbiditiesMerged = 0;
|
|
90
|
+
const seenD = new Set<string>();
|
|
91
|
+
for (const d of diseases) {
|
|
92
|
+
const k = diseaseKey(d);
|
|
93
|
+
if (seenD.has(k)) continue;
|
|
94
|
+
seenD.add(k);
|
|
95
|
+
|
|
96
|
+
if (d.icdCodes?.length) {
|
|
97
|
+
const host = findSubsumingDisease(client.factors.diseases, d);
|
|
98
|
+
if (host) {
|
|
99
|
+
host.icdCodes = mergeCodes(host.icdCodes, d.icdCodes);
|
|
100
|
+
comorbiditiesMerged++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const match = client.factors.diseases.find((x) => diseaseKey(x) === k && !x.sourceId);
|
|
106
|
+
if (match) {
|
|
107
|
+
match.sourceId = sourceId;
|
|
108
|
+
if (d.summary) match.summary = d.summary;
|
|
109
|
+
if (d.icdCodes?.length) match.icdCodes = mergeCodes(match.icdCodes, d.icdCodes);
|
|
110
|
+
diseasesAdopted++;
|
|
111
|
+
} else {
|
|
112
|
+
addDisease(client, { date: d.date, diagnostic: d.diagnostic, summary: d.summary, sourceId, icdCodes: d.icdCodes });
|
|
113
|
+
diseasesAdded++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Markers — adopt a provenance-less Imaging row by marker|date, else add.
|
|
118
|
+
// A `fromComparison` row (a prior value backfilled from this report's narrative)
|
|
119
|
+
// is a low-priority placeholder: it yields to any existing row, and a real
|
|
120
|
+
// reading replaces it (see precedence rules below).
|
|
121
|
+
let markersAdded = 0;
|
|
122
|
+
let markersAdopted = 0;
|
|
123
|
+
const seenM = new Set<string>();
|
|
124
|
+
for (const m of markers) {
|
|
125
|
+
const k = `${m.marker}|${m.date}`;
|
|
126
|
+
if (seenM.has(k)) continue;
|
|
127
|
+
seenM.add(k);
|
|
128
|
+
const idx = client.results.findIndex((r) => r.marker === m.marker && r.date === m.date);
|
|
129
|
+
const existing = idx >= 0 ? client.results[idx] : undefined;
|
|
130
|
+
|
|
131
|
+
if (m.fromComparison) {
|
|
132
|
+
// Rule 1: a placeholder is added only when nothing occupies this marker|date.
|
|
133
|
+
if (!existing) { client.results.push(m); markersAdded++; }
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (existing) {
|
|
137
|
+
if (existing.fromComparison) {
|
|
138
|
+
// Rule 2: a real reading ousts the placeholder.
|
|
139
|
+
client.results[idx] = m;
|
|
140
|
+
markersAdded++;
|
|
141
|
+
} else if (existing.source === "Imaging" && !existing.sourceId) {
|
|
142
|
+
existing.sourceId = sourceId;
|
|
143
|
+
markersAdopted++;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
client.results.push(m);
|
|
147
|
+
markersAdded++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
client.results.sort((a, b) =>
|
|
151
|
+
a.marker === b.marker ? a.date.localeCompare(b.date) : a.marker.localeCompare(b.marker),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return { diseasesAdded, diseasesAdopted, comorbiditiesMerged, markersAdded, markersAdopted };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Merge readings parsed from a positional source file (lab/DEXA/scale), tagging
|
|
158
|
+
// provenance like imaging does: a parsed row that matches an existing
|
|
159
|
+
// provenance-less reading by marker|date *adopts* it (stamps this sourceId); a
|
|
160
|
+
// genuinely new reading is added with the sourceId; an already-sourced match is
|
|
161
|
+
// left alone (the first file to introduce a reading owns it). Idempotent.
|
|
162
|
+
export function applySourceReadings(
|
|
163
|
+
client: Client,
|
|
164
|
+
sourceId: string,
|
|
165
|
+
rows: MarkerResult[],
|
|
166
|
+
// On a --force re-ingest the file is authoritative for the (marker,date)s it
|
|
167
|
+
// carries, so refresh the stored value/unit/ref/valueText (e.g. a parser fix
|
|
168
|
+
// that re-reads a titer correctly). Provenance (sourceId) is left as-is.
|
|
169
|
+
refresh = false,
|
|
170
|
+
): { added: number; adopted: number; updated: number } {
|
|
171
|
+
let added = 0;
|
|
172
|
+
let adopted = 0;
|
|
173
|
+
let updated = 0;
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const idx = client.results.findIndex((r) => r.marker === row.marker && r.date === row.date);
|
|
176
|
+
const existing = idx >= 0 ? client.results[idx] : undefined;
|
|
177
|
+
if (existing?.fromComparison) {
|
|
178
|
+
// A directly-measured reading replaces a backfilled placeholder.
|
|
179
|
+
client.results[idx] = { ...row, sourceId };
|
|
180
|
+
added++;
|
|
181
|
+
} else if (existing) {
|
|
182
|
+
if (!existing.sourceId) { existing.sourceId = sourceId; adopted++; }
|
|
183
|
+
if (refresh && (existing.value !== row.value || existing.unit !== row.unit || existing.valueText !== row.valueText)) {
|
|
184
|
+
existing.value = row.value;
|
|
185
|
+
existing.unit = row.unit;
|
|
186
|
+
existing.ref = row.ref;
|
|
187
|
+
if (row.valueText !== undefined) existing.valueText = row.valueText;
|
|
188
|
+
else delete existing.valueText;
|
|
189
|
+
updated++;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
client.results.push({ ...row, sourceId });
|
|
193
|
+
added++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
client.results.sort((a, b) =>
|
|
197
|
+
a.marker === b.marker ? a.date.localeCompare(b.date) : a.marker.localeCompare(b.marker),
|
|
198
|
+
);
|
|
199
|
+
return { added, adopted, updated };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// An imaging marker is an orphan if it has no sourceId, or its sourceId no longer
|
|
203
|
+
// points to a live source record. Prune them so re-extraction drift can never
|
|
204
|
+
// leave stale duplicate series behind.
|
|
205
|
+
export function pruneOrphanImagingMarkers(client: Client): number {
|
|
206
|
+
const live = new Set((client.sources ?? []).map((s) => s.id));
|
|
207
|
+
const before = client.results.length;
|
|
208
|
+
client.results = client.results.filter(
|
|
209
|
+
(r) => !(r.source === "Imaging" && (!r.sourceId || !live.has(r.sourceId))),
|
|
210
|
+
);
|
|
211
|
+
return before - client.results.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface RemoveResult {
|
|
215
|
+
removed: boolean; // false when the source isn't on file (no-op)
|
|
216
|
+
sourceId: string;
|
|
217
|
+
sha8: string;
|
|
218
|
+
kind: string;
|
|
219
|
+
markersDropped: number; // readings whose sole attribution was this source
|
|
220
|
+
diseasesDropped: number;
|
|
221
|
+
markersReattributed: number; // dropped readings re-added because a surviving source also supplies them
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Remove ONE source and everything it produced — the all-kinds generalization of
|
|
225
|
+
// pruneOrphanImagingMarkers. Drops the SourceRecord and every datum tagged with its
|
|
226
|
+
// sourceId (results incl. fromComparison placeholders; diseases incl. comorbidity codes
|
|
227
|
+
// folded into them), then re-applies the surviving sources' readings so a reading that a
|
|
228
|
+
// surviving source *also* measured is kept (corroboration — the W1b "a real reading wins"
|
|
229
|
+
// rule, re-attributed to a survivor). Appends a PHI-free tombstone. Idempotent: removing an
|
|
230
|
+
// absent source is a no-op and a tombstone is never duplicated. Pure — the CLI and a future
|
|
231
|
+
// web delete share it; file/R2 deletion is the caller's job.
|
|
232
|
+
export function removeSource(
|
|
233
|
+
client: Client,
|
|
234
|
+
sourceId: string,
|
|
235
|
+
// Per surviving source: the readings it independently supplies (its pre-fold processed
|
|
236
|
+
// rows). Used only to re-attribute a corroborated reading the removed source had owned.
|
|
237
|
+
surviving: { sourceId: string; rows: MarkerResult[] }[] = [],
|
|
238
|
+
removedAt = "",
|
|
239
|
+
): RemoveResult {
|
|
240
|
+
const rec = client.sources?.find((s) => s.id === sourceId);
|
|
241
|
+
if (!rec) {
|
|
242
|
+
return { removed: false, sourceId, sha8: "", kind: "", markersDropped: 0, diseasesDropped: 0, markersReattributed: 0 };
|
|
243
|
+
}
|
|
244
|
+
const sha8 = rec.sha256.slice(0, 8);
|
|
245
|
+
const kind = rec.kind;
|
|
246
|
+
|
|
247
|
+
// 1. Drop the SourceRecord.
|
|
248
|
+
client.sources = (client.sources ?? []).filter((s) => s.id !== sourceId);
|
|
249
|
+
|
|
250
|
+
// 2. Drop derived data tagged with this sourceId.
|
|
251
|
+
const resultsBefore = client.results.length;
|
|
252
|
+
client.results = client.results.filter((r) => r.sourceId !== sourceId);
|
|
253
|
+
const markersDropped = resultsBefore - client.results.length;
|
|
254
|
+
|
|
255
|
+
const diseasesBefore = client.factors?.diseases?.length ?? 0;
|
|
256
|
+
// Capture the ids BEFORE the rows go, so the finding's read of them can be pruned too.
|
|
257
|
+
const removedDiseaseIds = new Set(
|
|
258
|
+
(client.factors?.diseases ?? []).filter((d) => d.sourceId === sourceId).map((d) => d.id),
|
|
259
|
+
);
|
|
260
|
+
removeDiseasesBySourceId(client, sourceId);
|
|
261
|
+
const diseasesDropped = diseasesBefore - (client.factors?.diseases?.length ?? 0);
|
|
262
|
+
|
|
263
|
+
// 2b. And take the AI's turn ABOUT those diagnoses with them.
|
|
264
|
+
//
|
|
265
|
+
// A host-side `removeFrom` cascade closes this orphan class for notes, allergies, family
|
|
266
|
+
// history and treatments, but report deletion never goes through that function — it comes
|
|
267
|
+
// here — so `diseaseResults` was the one id-keyed section nothing ever pruned. The entries
|
|
268
|
+
// stayed keyed to ids that no longer existed: invisible in the UI, travelling in the client
|
|
269
|
+
// record, and reappearing if an id were reused. finding-invariants.ts flags exactly this as
|
|
270
|
+
// "matches no row on file"; until now it was reporting a state no code could reach.
|
|
271
|
+
if (removedDiseaseIds.size > 0 && client.finding?.diseaseResults) {
|
|
272
|
+
client.finding.diseaseResults = client.finding.diseaseResults.filter((r) => !removedDiseaseIds.has(r.diseaseId));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 3. Corroboration: re-apply each surviving source's readings. A reading the removed
|
|
276
|
+
// source had owned but a survivor also measured was dropped in step 2; re-applying
|
|
277
|
+
// re-adds it, now attributed to that survivor. Readings that still exist are adopted
|
|
278
|
+
// (no-op) or left alone, so this never duplicates.
|
|
279
|
+
let markersReattributed = 0;
|
|
280
|
+
for (const s of surviving) {
|
|
281
|
+
if (s.sourceId === sourceId) continue;
|
|
282
|
+
markersReattributed += applySourceReadings(client, s.sourceId, s.rows).added;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 4. PHI-free tombstone (deduped by sourceId).
|
|
286
|
+
client.removedSources ??= [];
|
|
287
|
+
if (!client.removedSources.some((t) => t.sourceId === sourceId)) {
|
|
288
|
+
client.removedSources.push({ sourceId, sha8, kind, removedAt });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { removed: true, sourceId, sha8, kind, markersDropped, diseasesDropped, markersReattributed };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface SourceEditPatch {
|
|
295
|
+
studyType?: string; // imaging only
|
|
296
|
+
dateKey: "studyDate" | "dateEnd" | "dateStart";
|
|
297
|
+
date: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// M66 P3 — patch ONE source and its linked DiseaseEntry rows (by position, in sourceId order) in
|
|
301
|
+
// one mutation, keyed by sourceId — the patch counterpart to removeSource's drop. `diseasePatches`
|
|
302
|
+
// must line up 1:1 with diseasesForSource(sourceId)'s current order; a short/mismatched array is
|
|
303
|
+
// ignored past its own length (no add/remove of diagnoses here). Raw values in — normalizeClientDraft
|
|
304
|
+
// (factors-edit.ts) is still the caller's job for trimming/capFirst/date normalization.
|
|
305
|
+
export function updateSource(
|
|
306
|
+
client: Client,
|
|
307
|
+
sourceId: string,
|
|
308
|
+
patch: SourceEditPatch,
|
|
309
|
+
diseasePatches: { diagnostic: string; date: string; summary?: string; icdCodes?: string[] }[],
|
|
310
|
+
): void {
|
|
311
|
+
const rec = client.sources?.find((s) => s.id === sourceId);
|
|
312
|
+
if (rec) {
|
|
313
|
+
if (patch.studyType !== undefined) rec.studyType = patch.studyType;
|
|
314
|
+
rec[patch.dateKey] = patch.date;
|
|
315
|
+
}
|
|
316
|
+
const diseases = (client.factors?.diseases ?? []).filter((d) => d.sourceId === sourceId);
|
|
317
|
+
diseases.forEach((d, i) => {
|
|
318
|
+
const p = diseasePatches[i];
|
|
319
|
+
if (!p) return;
|
|
320
|
+
d.diagnostic = p.diagnostic;
|
|
321
|
+
d.date = p.date;
|
|
322
|
+
d.summary = p.summary;
|
|
323
|
+
d.icdCodes = p.icdCodes;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface ProvenanceIssue {
|
|
328
|
+
kind: "dangling-result" | "dangling-disease" | "tombstone-live" | "treatment-missing-raw-capture";
|
|
329
|
+
detail: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The provenance invariant: no derived datum may carry a sourceId that no live
|
|
333
|
+
// SourceRecord answers for, and a tombstoned source must not also be live. (A
|
|
334
|
+
// provenance-LESS datum — hand-entered, no sourceId — is allowed.) A host's verify step runs
|
|
335
|
+
// this so a bad delete can't pass its pre-push gate.
|
|
336
|
+
export function provenanceIssues(client: Client): ProvenanceIssue[] {
|
|
337
|
+
const live = new Set((client.sources ?? []).map((s) => s.id));
|
|
338
|
+
const issues: ProvenanceIssue[] = [];
|
|
339
|
+
for (const r of client.results) {
|
|
340
|
+
if (r.sourceId && !live.has(r.sourceId)) {
|
|
341
|
+
issues.push({ kind: "dangling-result", detail: `${r.marker}|${r.date} → missing source ${r.sourceId}` });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const d of client.factors?.diseases ?? []) {
|
|
345
|
+
if (d.sourceId && !live.has(d.sourceId)) {
|
|
346
|
+
issues.push({ kind: "dangling-disease", detail: `${d.date}|${d.diagnostic} → missing source ${d.sourceId}` });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const t of client.removedSources ?? []) {
|
|
350
|
+
if (live.has(t.sourceId)) {
|
|
351
|
+
issues.push({ kind: "tombstone-live", detail: `tombstone ${t.sourceId} (${t.sha8}) is also a live source` });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// A treatment that claims to have been read off a photo or pasted text (extracted) must still
|
|
355
|
+
// have that raw capture on file — otherwise "extracted" is an unverifiable claim, not provenance.
|
|
356
|
+
for (const t of client.factors?.treatments ?? []) {
|
|
357
|
+
if (t.extracted?.via === "photo" && !t.attachments?.some((a) => t.rawCaptureAttachmentKeys?.includes(a.key))) {
|
|
358
|
+
issues.push({ kind: "treatment-missing-raw-capture", detail: `${t.name} (${t.id}) → extracted via photo, no raw capture attachment on file` });
|
|
359
|
+
}
|
|
360
|
+
if (t.extracted?.via === "text" && !t.rawCaptureText?.trim()) {
|
|
361
|
+
issues.push({ kind: "treatment-missing-raw-capture", detail: `${t.name} (${t.id}) → extracted via text, no rawCaptureText on file` });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return issues;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// A SourceRecord references its raw bytes (rec.file) and a processed artifact (by sha8).
|
|
368
|
+
// Existence of those files is a caller's check, Node-side — this module stays fs-free — but the
|
|
369
|
+
// expected processed sha8 is derived here for a single source.
|
|
370
|
+
export function processedSha8(rec: SourceRecord): string {
|
|
371
|
+
return rec.sha256.slice(0, 8);
|
|
372
|
+
}
|
package/report-title.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// How a report is DISPLAYED — title, kind label, date — in one place.
|
|
2
|
+
//
|
|
3
|
+
// This logic used to be copy-pasted across five call sites (a sidebar helper, a reference resolver,
|
|
4
|
+
// a search index, and two UI components). `reportTitleOf` had already been extracted here and
|
|
5
|
+
// the copies simply never deleted; three of the five carried comments blessing the duplication that
|
|
6
|
+
// cited EACH OTHER as precedent. A plain module is importable from a UI component, a CLI and a
|
|
7
|
+
// server-side handler alike, so there was never a reason for a second copy.
|
|
8
|
+
import type { SourceRecord } from "./types";
|
|
9
|
+
|
|
10
|
+
// Keyed on SourceRecord["kind"], not Record<string, string>: reference-resolver's loose copy plus a
|
|
11
|
+
// `?? s.kind` fallback meant a NEW report kind compiled clean there and rendered its raw enum value
|
|
12
|
+
// to the user. Typed this way, adding a kind is a compile error until every label is supplied.
|
|
13
|
+
export const REPORT_KIND_LABEL: Record<SourceRecord["kind"], string> = {
|
|
14
|
+
lab: "Lab",
|
|
15
|
+
dexa: "DEXA",
|
|
16
|
+
scale: "Scale",
|
|
17
|
+
imaging: "Imaging",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The date a report is filed under: its study date, else the range it covers, else when it landed. */
|
|
21
|
+
export const reportDateOf = (s: SourceRecord): string =>
|
|
22
|
+
s.studyDate ?? s.dateEnd ?? s.dateStart ?? s.importedAt.slice(0, 10);
|
|
23
|
+
|
|
24
|
+
export function reportTitleOf(s: SourceRecord): string {
|
|
25
|
+
if (s.kind === "imaging") return s.studyType ?? s.extraction?.studyType ?? "Imaging study";
|
|
26
|
+
if (s.kind === "dexa") return "DEXA body composition";
|
|
27
|
+
if (s.kind === "scale") return "Body composition (scale)";
|
|
28
|
+
return "Blood panel";
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The one table of section display names. Kept separate from any host's presentation metadata
|
|
2
|
+
// (icon, kind, blurb) so this package can name a section without depending on host branding, and
|
|
3
|
+
// so there is exactly one spelling per key. The label lived in three places before this file
|
|
4
|
+
// existed, and the three had drifted.
|
|
5
|
+
export const SECTION_LABEL: Record<string, string> = {
|
|
6
|
+
analysis: "Analysis",
|
|
7
|
+
study: "Study",
|
|
8
|
+
futureTreatment: "Hypothesis",
|
|
9
|
+
exploration: "Exploration",
|
|
10
|
+
treatment: "Treatment",
|
|
11
|
+
personalization: "Profile",
|
|
12
|
+
allergies: "Allergies",
|
|
13
|
+
familyHistory: "Family",
|
|
14
|
+
markers: "Markers",
|
|
15
|
+
clinicalReports: "Reports",
|
|
16
|
+
notes: "Notes",
|
|
17
|
+
docInference: "Questions",
|
|
18
|
+
healthMarkers: "Recommended Markers",
|
|
19
|
+
definitions: "Glossary",
|
|
20
|
+
};
|
package/system-groups.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The one place body-system grouping lives. The System Analysis (finding.disease) is what
|
|
2
|
+
// establishes a patient's body-system risks ("Cardiovascular Risk", "Metabolic Health", …) and the
|
|
3
|
+
// order they rank in (severity, highest first). Every section that groups by body system — Current
|
|
4
|
+
// Treatment, Study Result, Treatment Plan, Hypothesis, Questions for Dr — orders by this
|
|
5
|
+
// and defers to it for whether grouping is possible at all. Until the Finding runs there are no
|
|
6
|
+
// systems to group under, so those sections fall back to a flat, ungrouped list (see PendingGrouping).
|
|
7
|
+
|
|
8
|
+
import type { Client } from "./types";
|
|
9
|
+
|
|
10
|
+
// The trailing bucket for items that exist but carry no (or an unknown) system — e.g. a treatment
|
|
11
|
+
// added after the Finding, or one the model didn't tag. Signals a Finding refresh would place it.
|
|
12
|
+
export const UNCATEGORIZED = "Not yet categorized";
|
|
13
|
+
|
|
14
|
+
// The canonical body-system order: disease groups in the AI's severity order.
|
|
15
|
+
export function systemOrder(client: Client): string[] {
|
|
16
|
+
return (client.finding?.disease ?? []).map((d) => d.group);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Have the AI's per-system risks been established yet? Grouping is only possible once they have.
|
|
20
|
+
export function systemAnalysisEstablished(client: Client): boolean {
|
|
21
|
+
return (client.finding?.disease?.length ?? 0) > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Bucket a flat list under body-system headings, in systemOrder, with any item whose group is
|
|
25
|
+
// missing or unknown swept into a trailing UNCATEGORIZED bucket so coverage is exactly-once and
|
|
26
|
+
// complete. Returns null when the System Analysis isn't established — the caller's cue to render its
|
|
27
|
+
// flat fallback plus a PendingGrouping note. Order within a bucket is preserved from `items`.
|
|
28
|
+
export function groupBySystem<T>(
|
|
29
|
+
client: Client,
|
|
30
|
+
items: T[],
|
|
31
|
+
getGroup: (item: T) => string | undefined,
|
|
32
|
+
): { system: string; rows: T[] }[] | null {
|
|
33
|
+
const order = systemOrder(client);
|
|
34
|
+
if (order.length === 0) return null;
|
|
35
|
+
const buckets = new Map<string, T[]>();
|
|
36
|
+
for (const it of items) {
|
|
37
|
+
const g = getGroup(it);
|
|
38
|
+
const key = g && order.includes(g) ? g : UNCATEGORIZED;
|
|
39
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
40
|
+
buckets.get(key)!.push(it);
|
|
41
|
+
}
|
|
42
|
+
return [...order, UNCATEGORIZED].filter((s) => buckets.has(s)).map((system) => ({ system, rows: buckets.get(system)! }));
|
|
43
|
+
}
|