@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,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
+ }
@@ -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
+ };
@@ -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
+ }