@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,366 @@
|
|
|
1
|
+
import type { Client, TreatmentItem } from "./types";
|
|
2
|
+
import { formatDay } from "./dates";
|
|
3
|
+
|
|
4
|
+
// This module previously imported UI-framework sidebar helpers for a small tail of rendering logic,
|
|
5
|
+
// which pinned the whole of it — bucketing, dose formatting, assessment lookup, all pure clinical
|
|
6
|
+
// logic — to a host app's UI layer. That tail now lives in a host-side module and this file imports
|
|
7
|
+
// no UI, so it stays importable from any runtime.
|
|
8
|
+
|
|
9
|
+
// A treatment's temporal bucket is DERIVED from its start/optional-end, never stored.
|
|
10
|
+
// `today` is ALWAYS passed in as an ISO `YYYY-MM` (or longer ISO, compared by prefix) so this
|
|
11
|
+
// stays pure and testable — never read the clock inside (feedback_finding_date_awareness).
|
|
12
|
+
|
|
13
|
+
export type Bucket = "past" | "ongoing" | "planned";
|
|
14
|
+
|
|
15
|
+
// M102 Phase 1 — the display label for a bucket badge (Treatment's Ungrouped view, which
|
|
16
|
+
// otherwise concatenates all three buckets with no other visual distinction).
|
|
17
|
+
export const BUCKET_LABEL: Record<Bucket, string> = { ongoing: "Ongoing", planned: "Planned", past: "Past" };
|
|
18
|
+
|
|
19
|
+
// Day-precision ISO date. Callers compute this at the boundary (new Date()) and pass it down.
|
|
20
|
+
export function todayISODate(): string {
|
|
21
|
+
return new Date().toISOString().slice(0, 10);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Compare two partial ISO dates ("2026", "2026-04", "2026-04-01") by their common prefix length,
|
|
25
|
+
// so a year-only value sorts sensibly against a month value. Empty strings sort earliest. Exported
|
|
26
|
+
// for a host's edit model, which needs the same mixed-precision-safe ordering to sort same-name
|
|
27
|
+
// titration rows within Ongoing/Planned/Past newest-first.
|
|
28
|
+
export function cmp(a: string, b: string): number {
|
|
29
|
+
const n = Math.min(a.length, b.length) || Math.max(a.length, b.length);
|
|
30
|
+
const x = a.slice(0, n);
|
|
31
|
+
const y = b.slice(0, n);
|
|
32
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// end in the past → past; start in the future → planned; otherwise (started, or start unknown,
|
|
36
|
+
// and not ended) → ongoing. `today` may be YYYY-MM or YYYY-MM-DD; comparison is prefix-wise, so a
|
|
37
|
+
// day-precision `today` against a legacy month-only start/end degrades gracefully to month-level.
|
|
38
|
+
export function bucketOf(t: Pick<TreatmentItem, "start" | "end">, today: string): Bucket {
|
|
39
|
+
if (t.end && cmp(t.end, today) < 0) return "past";
|
|
40
|
+
if (t.start && cmp(t.start, today) > 0) return "planned";
|
|
41
|
+
return "ongoing";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The right-aligned row meta, phrased by bucket with dates rendered as a clear day (legacy month-only
|
|
45
|
+
// values read as the last day of that month). Past shows a "start – end" range; a hyphen-free en-dash
|
|
46
|
+
// separator with day-bearing dates avoids the old "2026-04–2026-05" blur.
|
|
47
|
+
export function treatmentMeta(t: Pick<TreatmentItem, "start" | "end">, bucket: Bucket): string | undefined {
|
|
48
|
+
if (bucket === "past") {
|
|
49
|
+
return t.start ? `${formatDay(t.start)} – ${formatDay(t.end)}` : `until ${formatDay(t.end)}`;
|
|
50
|
+
}
|
|
51
|
+
if (bucket === "planned") return t.start ? `planned for ${formatDay(t.start)}` : "planned";
|
|
52
|
+
return t.start ? `since ${formatDay(t.start)}` : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Titration is encoded as repeated same-name rows (feedback_treatment_titration_rows). Collapse to
|
|
56
|
+
// one representative per name: earliest start, latest dose, latest end (only past if the latest row
|
|
57
|
+
// ended). Preserves kind from the first row. Sorted by name for a stable order.
|
|
58
|
+
export function collapseByName(items: TreatmentItem[]): TreatmentItem[] {
|
|
59
|
+
const byName = new Map<string, TreatmentItem[]>();
|
|
60
|
+
for (const it of items) {
|
|
61
|
+
const key = it.name.trim().toLowerCase();
|
|
62
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
63
|
+
byName.get(key)!.push(it);
|
|
64
|
+
}
|
|
65
|
+
const out: TreatmentItem[] = [];
|
|
66
|
+
for (const rows of byName.values()) {
|
|
67
|
+
const byStart = [...rows].sort((a, b) => cmp(a.start || "", b.start || ""));
|
|
68
|
+
const latest = byStart[byStart.length - 1];
|
|
69
|
+
const ends = rows.map((r) => r.end).filter((e): e is string => !!e);
|
|
70
|
+
// A collapsed treatment is only "ended" if the most recent dose row has an end; a superseded
|
|
71
|
+
// earlier row's end (the next titration step's start) does not end the treatment.
|
|
72
|
+
const collapsed: TreatmentItem = {
|
|
73
|
+
id: latest.id,
|
|
74
|
+
name: latest.name,
|
|
75
|
+
dose: latest.dose,
|
|
76
|
+
// M104 — carry the structured dose fields through too, same "latest row wins" rule as `dose`
|
|
77
|
+
// itself; formatDose() prefers these over `dose`, so dropping them here would silently blank
|
|
78
|
+
// out any collapsed-view display for a titration step entered through the new Amount/Unit form.
|
|
79
|
+
doseAmount: latest.doseAmount,
|
|
80
|
+
doseUnit: latest.doseUnit,
|
|
81
|
+
doseFrequency: latest.doseFrequency,
|
|
82
|
+
kind: rows[0].kind,
|
|
83
|
+
start: byStart[0].start,
|
|
84
|
+
// Carry the most-recent titration row's attachments through; previously dropped
|
|
85
|
+
// entirely here, so the read-only view (TreatmentRow, which reads off this collapsed shape)
|
|
86
|
+
// never showed a treatment's photos even though the editable view (which reads uncollapsed
|
|
87
|
+
// draft rows directly) did.
|
|
88
|
+
images: latest.images,
|
|
89
|
+
attachments: latest.attachments,
|
|
90
|
+
// The product fields describe the DRUG, so they survive collapsing like name/kind do — and
|
|
91
|
+
// they are taken from rows[0] rather than `latest` because a medicine-scope save fans the
|
|
92
|
+
// same values across every row anyway. This whitelist has needed extending more than once
|
|
93
|
+
// (attachments, structured dose, administration): anything omitted here works in
|
|
94
|
+
// the editor and is invisible in TreatmentRow, chat-context, search-index and
|
|
95
|
+
// reference-resolver.
|
|
96
|
+
description: rows[0].description,
|
|
97
|
+
maker: rows[0].maker,
|
|
98
|
+
ingredients: rows[0].ingredients,
|
|
99
|
+
links: rows[0].links,
|
|
100
|
+
administration: rows[0].administration,
|
|
101
|
+
};
|
|
102
|
+
if (latest.end && ends.length) collapsed.end = ends.sort(cmp)[ends.length - 1];
|
|
103
|
+
out.push(collapsed);
|
|
104
|
+
}
|
|
105
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface NamedTreatmentGroup {
|
|
109
|
+
name: string;
|
|
110
|
+
rows: TreatmentItem[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// M108 — primary group order: Planned drugs first, then Ongoing, then Past — recency of use. A
|
|
114
|
+
// group's bucket is its NEWEST row's (rows[0], already sorted descending below), so a drug
|
|
115
|
+
// currently mid-titration reads as Ongoing even if an older row of the same drug once looked
|
|
116
|
+
// Planned. M109 — within one bucket, groups sort alphabetically (secondary key), not entry order.
|
|
117
|
+
const GROUP_BUCKET_ORDER: Record<Bucket, number> = { planned: 0, ongoing: 1, past: 2 };
|
|
118
|
+
|
|
119
|
+
// M103 — the Medicine audit view's grouping: every raw row for a drug together, unlike
|
|
120
|
+
// collapseByName which discards all but one representative row per name.
|
|
121
|
+
//
|
|
122
|
+
// M105 — rows sorted newest-first (start descending): the table must always read newest-to-oldest,
|
|
123
|
+
// top to bottom. dateGaps() below is written against this same descending order.
|
|
124
|
+
export function groupByName(items: TreatmentItem[], today: string): NamedTreatmentGroup[] {
|
|
125
|
+
const byName = new Map<string, TreatmentItem[]>();
|
|
126
|
+
for (const it of items) {
|
|
127
|
+
const key = it.name.trim().toLowerCase();
|
|
128
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
129
|
+
byName.get(key)!.push(it);
|
|
130
|
+
}
|
|
131
|
+
const groups = [...byName.values()].map((rows) => ({
|
|
132
|
+
name: rows[0].name,
|
|
133
|
+
rows: [...rows].sort((a, b) => cmp(b.start || "", a.start || "")),
|
|
134
|
+
}));
|
|
135
|
+
return groups.sort((a, b) => {
|
|
136
|
+
const bucketDiff = GROUP_BUCKET_ORDER[bucketOf(a.rows[0], today)] - GROUP_BUCKET_ORDER[bucketOf(b.rows[0], today)];
|
|
137
|
+
return bucketDiff !== 0 ? bucketDiff : a.name.localeCompare(b.name);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type DateGapVerdict = "ok" | "gap" | "overlap";
|
|
142
|
+
|
|
143
|
+
// M104 — whole-day distance between two full YYYY-MM-DD dates (nextStart - end), or null if
|
|
144
|
+
// either isn't full day precision (legacy month/year-only can't do calendar-day arithmetic).
|
|
145
|
+
function daysBetween(a: string, b: string): number | null {
|
|
146
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(a) || !/^\d{4}-\d{2}-\d{2}$/.test(b)) return null;
|
|
147
|
+
return Math.round((Date.parse(b) - Date.parse(a)) / 86400000);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// M103 — one verdict per consecutive pair in an already-sorted `rows` (see groupByName), comparing
|
|
151
|
+
// the OLDER row's end against the NEWER row's start: no end (still ongoing) or an unknown start
|
|
152
|
+
// can't be judged, so those read "ok" rather than a false positive. Length is rows.length - 1.
|
|
153
|
+
//
|
|
154
|
+
// M104 — a titration step ending 2026-06-30 with the next starting 2026-07-01 is back-to-back
|
|
155
|
+
// coverage, not a gap: prefer day-precision arithmetic (0 or 1 day apart both read "ok") over the
|
|
156
|
+
// cmp() prefix comparison, which only recognized an exact same-day handoff as contiguous. Falls
|
|
157
|
+
// back to cmp() when either date isn't full day precision.
|
|
158
|
+
//
|
|
159
|
+
// M105 — `rows` is newest-first (groupByName sorts descending), so for the pair at (i, i+1), i+1
|
|
160
|
+
// is the OLDER row and i is the NEWER one — verdict[i] describes the gap/overlap between them.
|
|
161
|
+
//
|
|
162
|
+
// M106 — symmetric with the gap side: a 1-day overlap (the newer row starts the day before the
|
|
163
|
+
// older one's recorded end — a rounding/entry-day wobble, not a real double-dosing period) also
|
|
164
|
+
// reads "ok". Only a 2+ day overlap is worth flagging. This only applies to the day-precision
|
|
165
|
+
// branch — the cmp() fallback (month/year-only dates) has no day-scale magnitude to be lenient about.
|
|
166
|
+
//
|
|
167
|
+
// M110 — an AM row and a PM row covering the same dates aren't double-dosing, they're a twice-daily
|
|
168
|
+
// split — only downgrades a would-be "overlap" (a real gap between an AM and a PM step is still a
|
|
169
|
+
// gap; this isn't a blanket "ignore timing" exemption).
|
|
170
|
+
function splitByTiming(a: TreatmentItem, b: TreatmentItem): boolean {
|
|
171
|
+
return !!a.timingPeriod && !!b.timingPeriod && a.timingPeriod !== b.timingPeriod;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function dateGaps(rows: TreatmentItem[]): DateGapVerdict[] {
|
|
175
|
+
const out: DateGapVerdict[] = [];
|
|
176
|
+
for (let i = 0; i < rows.length - 1; i++) {
|
|
177
|
+
const newer = rows[i];
|
|
178
|
+
const older = rows[i + 1];
|
|
179
|
+
const olderEnd = older.end;
|
|
180
|
+
const newerStart = newer.start;
|
|
181
|
+
if (!olderEnd || !newerStart) {
|
|
182
|
+
out.push("ok");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const days = daysBetween(olderEnd, newerStart);
|
|
186
|
+
let verdict: DateGapVerdict;
|
|
187
|
+
if (days !== null) {
|
|
188
|
+
verdict = days < -1 ? "overlap" : days <= 1 ? "ok" : "gap";
|
|
189
|
+
} else {
|
|
190
|
+
const c = cmp(olderEnd, newerStart);
|
|
191
|
+
verdict = c < 0 ? "gap" : c > 0 ? "overlap" : "ok";
|
|
192
|
+
}
|
|
193
|
+
out.push(verdict === "overlap" && splitByTiming(newer, older) ? "ok" : verdict);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The verbatim label a PLANNED treatment presents to the Finding (Patient Plan Action, treatmentGroups
|
|
199
|
+
// patient ref, planAssessmentRows key) — its name plus dose when present. Must be identical everywhere
|
|
200
|
+
// the ref is matched (finding-generate input block, finding-assemble expected set, treatment-groups resolve).
|
|
201
|
+
// M104 — the display string for a dose: structured amount/unit/frequency when the record has been
|
|
202
|
+
// entered/edited through the new form, reproducing the pre-existing "6mg/week" convention so
|
|
203
|
+
// treatmentLabel()'s output (and everything matched against it) doesn't change shape; falls back to
|
|
204
|
+
// the legacy free-text `dose` string for anything a host has not yet migrated.
|
|
205
|
+
export function formatDose(t: Pick<TreatmentItem, "dose" | "doseAmount" | "doseUnit" | "doseFrequency">): string | undefined {
|
|
206
|
+
if (t.doseAmount != null) {
|
|
207
|
+
const unit = t.doseUnit?.trim() ?? "";
|
|
208
|
+
const freq = t.doseFrequency ? `/${t.doseFrequency}` : "";
|
|
209
|
+
return `${t.doseAmount}${unit}${freq}`;
|
|
210
|
+
}
|
|
211
|
+
return t.dose?.trim() || undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function treatmentLabel(t: Pick<TreatmentItem, "name" | "dose" | "doseAmount" | "doseUnit" | "doseFrequency">): string {
|
|
215
|
+
const name = t.name.trim();
|
|
216
|
+
const dose = formatDose(t);
|
|
217
|
+
return dose ? `${name} ${dose}` : name;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Shared by a host's display and by any backfill over the same records — it must stay a single
|
|
221
|
+
// implementation, so "does this row have an assessment" cannot silently disagree between what a
|
|
222
|
+
// reader is shown and what a backfill decides is missing.
|
|
223
|
+
//
|
|
224
|
+
// `treatmentAssessment`'s targetLabels scope on the model's own dose-annotated "item" label (e.g.
|
|
225
|
+
// "Rosuvastatin 20 mg"), not the raw treatment name — matching a bare name against that requires
|
|
226
|
+
// a substring check,
|
|
227
|
+
// falling back to just the first word so "Magnesium Glycinate" still matches an item recorded as
|
|
228
|
+
// "Magnesium glycinate 1.5g/day elemental".
|
|
229
|
+
export function matchOngoingAssessment<T extends { item: string; assessment: string }>(
|
|
230
|
+
assessments: T[],
|
|
231
|
+
name: string,
|
|
232
|
+
used?: Set<object>,
|
|
233
|
+
): T | undefined {
|
|
234
|
+
return matchByTreatmentName(assessments, name, (t) => t.item, used);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The PLANNED twin, and the reason this was extracted.
|
|
239
|
+
*
|
|
240
|
+
* The planned card used an exact `Map.get(treatmentLabel(row))` — "Tirzepatide 10.5mg/week" — while
|
|
241
|
+
* the model records the action under whatever label it was given, which for a real case was the
|
|
242
|
+
* bare "Tirzepatide". The lookup missed, so the card read "No assessment available" while a
|
|
243
|
+
* perfectly good assessment existed, and every regeneration "failed silently": the regen succeeded,
|
|
244
|
+
* the merge succeeded, and nothing appeared. The ongoing path had tolerated exactly this for a long
|
|
245
|
+
* time; only the planned path was strict, and the asymmetry was invisible because both sides looked
|
|
246
|
+
* reasonable.
|
|
247
|
+
*/
|
|
248
|
+
export function matchPlanAssessment<T extends { action: string; assessment: string }>(
|
|
249
|
+
rows: T[],
|
|
250
|
+
name: string,
|
|
251
|
+
used?: Set<object>,
|
|
252
|
+
): T | undefined {
|
|
253
|
+
return matchByTreatmentName(rows, name, (r) => r.action, used);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A stored label may carry a dose annotation the model wrote ("Rosuvastatin 20 mg") or may be the
|
|
257
|
+
// bare name; the treatment it belongs to may likewise be looked up either way. Match on containment
|
|
258
|
+
// in whichever direction, then fall back to the first word so "Magnesium Glycinate" still finds
|
|
259
|
+
// "Magnesium glycinate 1.5g/day elemental".
|
|
260
|
+
// `used` is an optional index set the caller owns: when a whole list of treatments is matched in one
|
|
261
|
+
// pass (TreatmentRow), one assessment must not be attributed to two different drugs. The card path
|
|
262
|
+
// matches a single name and passes nothing. Merging the two implementations this way is the point —
|
|
263
|
+
// treatment-bucket's had the reverse-containment tier, TreatmentRow's had the exclusion set, and each
|
|
264
|
+
// was missing what the other had.
|
|
265
|
+
export function matchByTreatmentName<T extends object>(
|
|
266
|
+
entries: T[],
|
|
267
|
+
name: string,
|
|
268
|
+
keyOf: (e: T) => string,
|
|
269
|
+
used?: Set<object>,
|
|
270
|
+
): T | undefined {
|
|
271
|
+
const d = name.trim().toLowerCase();
|
|
272
|
+
if (!d) return undefined;
|
|
273
|
+
const w = d.split(/\s+/)[0];
|
|
274
|
+
const free = (e: T) => !used?.has(e);
|
|
275
|
+
const key = (e: T) => keyOf(e).toLowerCase().trim();
|
|
276
|
+
const found =
|
|
277
|
+
entries.find((e) => free(e) && key(e).includes(d))
|
|
278
|
+
?? entries.find((e) => free(e) && d.includes(key(e)))
|
|
279
|
+
?? entries.find((e) => free(e) && key(e).includes(w));
|
|
280
|
+
if (!found) return undefined;
|
|
281
|
+
used?.add(found);
|
|
282
|
+
return found;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface ResolvedAssessment {
|
|
286
|
+
assessment: string;
|
|
287
|
+
group?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* THE assessment lookup — every surface goes through this one, per (drug, phase).
|
|
292
|
+
*
|
|
293
|
+
* A drug can now hold up to three assessments, one per phase, so a Past card no longer renders the
|
|
294
|
+
* text written about the current dose. The resolution order is what keeps that migration free:
|
|
295
|
+
*
|
|
296
|
+
* 1. an entry for this exact phase — the new shape;
|
|
297
|
+
* 2. a PHASE-LESS entry — everything stored before this existed, still shown under every bucket
|
|
298
|
+
* exactly as it was, until that drug is next translated;
|
|
299
|
+
* 3. for planned only, the legacy planAssessmentRows store, which the leaf path no longer writes.
|
|
300
|
+
*
|
|
301
|
+
* Having one function is the other half of the point. The same lookup was written four times with
|
|
302
|
+
* three different behaviours; a bug fixed in the card (a strict Map.get that silently found nothing)
|
|
303
|
+
* stayed live in the read-only path and in the CLI backfill.
|
|
304
|
+
*/
|
|
305
|
+
export function assessmentFor(
|
|
306
|
+
finding: Client["finding"] | undefined,
|
|
307
|
+
name: string,
|
|
308
|
+
phase: Bucket,
|
|
309
|
+
used?: Set<object>,
|
|
310
|
+
treatmentId?: string,
|
|
311
|
+
): ResolvedAssessment | undefined {
|
|
312
|
+
const entries = finding?.treatment ?? [];
|
|
313
|
+
const phased = entries.filter((e) => e.phase === phase);
|
|
314
|
+
|
|
315
|
+
// An exact id match first, and it ends the search. Everything below this line is
|
|
316
|
+
// substring-guessing against a name the MODEL wrote (dose included), which is what the id replaces:
|
|
317
|
+
// three ordered `includes` rules, a `used` set to stop one row being claimed by two drugs, and a
|
|
318
|
+
// rename that splices into the stored string. A row that carries an id needs none of it.
|
|
319
|
+
//
|
|
320
|
+
// The fallback stays because a finding written before ids existed has none, and re-running the leaf
|
|
321
|
+
// for every patient to migrate them would cost a full generation each. A row upgrades itself the
|
|
322
|
+
// next time its drug is reassessed.
|
|
323
|
+
if (treatmentId) {
|
|
324
|
+
const byId = phased.find((e) => e.treatmentId === treatmentId && !used?.has(e));
|
|
325
|
+
if (byId) {
|
|
326
|
+
used?.add(byId);
|
|
327
|
+
return { assessment: byId.assessment, group: byId.group };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Never let a name rule claim a row that is id-keyed for a DIFFERENT treatment: once a row says
|
|
332
|
+
// which drug it is about, a substring coincidence must not override it.
|
|
333
|
+
const nameable = phased.filter((e) => !e.treatmentId || e.treatmentId === treatmentId);
|
|
334
|
+
const hit = matchByTreatmentName(nameable, name, (e) => e.item, used);
|
|
335
|
+
if (hit) return { assessment: hit.assessment, group: hit.group };
|
|
336
|
+
|
|
337
|
+
const legacyEntries = entries.filter((e) => !e.phase && (!e.treatmentId || e.treatmentId === treatmentId));
|
|
338
|
+
const legacy = matchByTreatmentName(legacyEntries, name, (e) => e.item, used);
|
|
339
|
+
if (legacy) return { assessment: legacy.assessment, group: legacy.group };
|
|
340
|
+
|
|
341
|
+
if (phase !== "planned") return undefined;
|
|
342
|
+
const row = matchPlanAssessment(finding?.planAssessmentRows ?? [], name);
|
|
343
|
+
return row ? { assessment: row.assessment } : undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Re-keys stored assessments when a medicine is renamed, in place.
|
|
348
|
+
*
|
|
349
|
+
* finding.treatment[] entries are matched to a medicine BY NAME (matchOngoingAssessment above), so a
|
|
350
|
+
* rename orphans them: the turn is still stored, under a name nothing looks up any more, and the card
|
|
351
|
+
* reads "No assessment available" the instant the rename is saved. Carrying the item forward keeps
|
|
352
|
+
* the previous turn on screen — marked stale by the hash, and replaced as soon as the regen lands —
|
|
353
|
+
* which matters most exactly when that regen fails.
|
|
354
|
+
*/
|
|
355
|
+
export function renameAssessmentItems<T extends { item: string }>(entries: T[], prevName: string, nextName: string): void {
|
|
356
|
+
const prev = prevName.trim();
|
|
357
|
+
const next = nextName.trim();
|
|
358
|
+
if (!prev || !next || prev.toLowerCase() === next.toLowerCase()) return;
|
|
359
|
+
for (const e of entries) {
|
|
360
|
+
const i = e.item.toLowerCase().indexOf(prev.toLowerCase());
|
|
361
|
+
if (i < 0) continue;
|
|
362
|
+
// Splice rather than replace the whole string: the stored item carries a dose annotation the
|
|
363
|
+
// model wrote ("Rosuvastatin 20 mg"), and only the name part is being renamed.
|
|
364
|
+
e.item = e.item.slice(0, i) + next + e.item.slice(i + prev.length);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// M54/4 — treatment add-flow intake: read what the user actually has about a product — photos of a
|
|
2
|
+
// bottle/package/label, or the product sheet as pasted text — and propose a record. Pure of
|
|
3
|
+
// Node/process/env, mirroring report-extract.ts's shape. The Anthropic client is INJECTED and the
|
|
4
|
+
// model is a required arg (no default), so this module never touches process.env or the config.
|
|
5
|
+
//
|
|
6
|
+
// ONE function serves both inputs on purpose. The extraction task is the same task whichever way
|
|
7
|
+
// the label arrives, and the rule that must not drift between them — label amounts are product
|
|
8
|
+
// facts, never the patient's dose — is stated once here rather than twice.
|
|
9
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
10
|
+
import { cleanIngredients, cleanLinks } from "./treatment-product";
|
|
11
|
+
import type { Administration, DoseFrequency, Ingredient, ProductLink } from "./types";
|
|
12
|
+
|
|
13
|
+
export const TREATMENT_INFER_SCHEMA = {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
name: { type: "string" },
|
|
17
|
+
kind: { type: "string", enum: ["drug", "supplement"] },
|
|
18
|
+
description: { type: "string" },
|
|
19
|
+
maker: { type: "string" },
|
|
20
|
+
ingredients: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
name: { type: "string" },
|
|
26
|
+
amount: { type: "number" },
|
|
27
|
+
unit: { type: "string" },
|
|
28
|
+
form: { type: "string" },
|
|
29
|
+
},
|
|
30
|
+
required: ["name"],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
links: {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: { label: { type: "string" }, url: { type: "string" } },
|
|
39
|
+
required: ["label", "url"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
administration: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
unit: { type: "string" },
|
|
47
|
+
unitsPerServing: { type: "number" },
|
|
48
|
+
suggestedUnits: { type: "number" },
|
|
49
|
+
suggestedFrequency: { type: "string", enum: ["day", "week", "month", "as needed"] },
|
|
50
|
+
containerQuantity: { type: "number" },
|
|
51
|
+
},
|
|
52
|
+
required: ["unit", "suggestedUnits", "suggestedFrequency"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ["name", "kind"],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
export interface ProposedTreatment {
|
|
61
|
+
name: string;
|
|
62
|
+
kind: "drug" | "supplement";
|
|
63
|
+
description?: string;
|
|
64
|
+
maker?: string;
|
|
65
|
+
ingredients?: Ingredient[];
|
|
66
|
+
links?: ProductLink[];
|
|
67
|
+
administration?: Administration;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Structural — mirrors report-extract.ts's UsageRecorder so a caller's existing usage accumulator
|
|
71
|
+
// satisfies this, with no cost-accounting module dragged in.
|
|
72
|
+
export interface UsageRecorder {
|
|
73
|
+
record(
|
|
74
|
+
model: string,
|
|
75
|
+
usage:
|
|
76
|
+
| {
|
|
77
|
+
input_tokens?: number | null;
|
|
78
|
+
output_tokens?: number | null;
|
|
79
|
+
cache_creation_input_tokens?: number | null;
|
|
80
|
+
cache_read_input_tokens?: number | null;
|
|
81
|
+
}
|
|
82
|
+
| null
|
|
83
|
+
| undefined,
|
|
84
|
+
): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const SYSTEM_PROMPT = [
|
|
88
|
+
"You read what a user has about ONE medication or supplement — photos of a bottle, blister",
|
|
89
|
+
"pack, box or prescription label, or the product's own written sheet — and return strict JSON",
|
|
90
|
+
"matching the requested schema.",
|
|
91
|
+
"",
|
|
92
|
+
"Extract:",
|
|
93
|
+
"- name: the product's name as printed (brand name, or generic if that is what is printed).",
|
|
94
|
+
" Keep it concise — just the name, no dose and no marketing suffix.",
|
|
95
|
+
"- kind: \"drug\" for a prescription or OTC medication, \"supplement\" for a vitamin, mineral,",
|
|
96
|
+
" herbal, or other dietary supplement.",
|
|
97
|
+
"- description: what the product is and what it is for, in the source's own terms. Fold in any",
|
|
98
|
+
" cost, storage, manufacturing or protocol prose rather than dropping it. Omit when the source",
|
|
99
|
+
" offers nothing beyond a name.",
|
|
100
|
+
"- maker: the manufacturer or brand as printed (e.g. \"Thorne\", \"Pfizer\"), if the source states",
|
|
101
|
+
" one. Omit when the source doesn't say.",
|
|
102
|
+
"- ingredients: one entry per active ingredient listed, with `amount` and `unit` EXACTLY as",
|
|
103
|
+
" printed on the label, and `form` for the chemical form or source in parentheses",
|
|
104
|
+
" (\"L-Selenomethionine\", \"20% Coleus Forskohlii Extract\"). Omit entirely when none is listed.",
|
|
105
|
+
"- links: any URL the source gives, each with the label it is given (\"Third-party testing\").",
|
|
106
|
+
" Only real URLs — never invent one, and never turn a bare reference number into a link.",
|
|
107
|
+
"- administration: the label's OWN suggested serving/administration, if it states one — e.g.",
|
|
108
|
+
" \"Take one capsule daily\", \"1 scoop with 8oz water, 1-2 times per day\", \"Adults chew one",
|
|
109
|
+
" tablet twice daily\". `unit` is the countable unit the label itself uses (capsule, tablet,",
|
|
110
|
+
" softgel, scoop, gummy, mL, spray, patch, drop, packet — whatever word the label uses).",
|
|
111
|
+
" `unitsPerServing` is how many of that unit make up ONE printed serving, ONLY when the label",
|
|
112
|
+
" states a Serving Size distinct from its dosing line (default 1 when it does not).",
|
|
113
|
+
" `suggestedUnits` is the count of that unit the label says to take per administration.",
|
|
114
|
+
" `suggestedFrequency` is how often the label says to take it (\"day\", \"week\", \"month\", or",
|
|
115
|
+
" \"as needed\" for an as-needed/PRN product). `containerQuantity` is the TOTAL count of that",
|
|
116
|
+
" unit the package itself holds, if printed (e.g. a bottle labeled \"60 Capsules\" → 60) — this",
|
|
117
|
+
" is the package's total, NOT the per-administration count above; omit it when the source",
|
|
118
|
+
" doesn't state a total. Omit the whole administration field when the label gives no",
|
|
119
|
+
" administration instruction at all.",
|
|
120
|
+
"",
|
|
121
|
+
"THE DOSE RULE — the one that matters most:",
|
|
122
|
+
"An ingredient amount is a LABEL FACT about the product: what ONE capsule, tablet or serving",
|
|
123
|
+
"contains. It is NOT how much the patient takes. Every amount you read off an ingredient list",
|
|
124
|
+
"belongs in ingredients[], and nowhere else. The label's OWN suggested serving belongs in",
|
|
125
|
+
"administration (above) the same way — \"the label suggests 1 capsule per day\" is also a LABEL",
|
|
126
|
+
"FACT, no different in kind from an ingredient amount, and you should extract it whenever the",
|
|
127
|
+
"label states one. What you must never do, under ANY field, is assert or imply what THIS PATIENT",
|
|
128
|
+
"actually takes: administration is what the LABEL recommends, not a report of the patient's own",
|
|
129
|
+
"behavior, and a \"Protocol\" or \"Directions\" line describes that same label recommendation, not",
|
|
130
|
+
"a patient history. Do NOT infer, guess, or output a dose, dosage, strength, quantity, frequency,",
|
|
131
|
+
"or timing FOR THE PATIENT under any field other than administration — not from the",
|
|
132
|
+
"ingredient list, not from the pack size, not from anything else you read. A product containing",
|
|
133
|
+
"100mcg of selenium, or a label suggesting 1 capsule daily, tells you nothing about what THIS",
|
|
134
|
+
"patient takes — the patient's own quantity, frequency and time of day are entered separately,",
|
|
135
|
+
"after this step, and may differ from what the label suggests. Your job stops at the label.",
|
|
136
|
+
"",
|
|
137
|
+
"If multiple photos are provided, they are the same item from different angles; use all of them",
|
|
138
|
+
"to identify it once.",
|
|
139
|
+
].join("\n");
|
|
140
|
+
|
|
141
|
+
export interface TreatmentInferInput {
|
|
142
|
+
images?: { base64: string; mediaType: "image/jpeg" | "image/png" }[];
|
|
143
|
+
text?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function inferTreatment(
|
|
147
|
+
anthropic: Anthropic,
|
|
148
|
+
input: TreatmentInferInput,
|
|
149
|
+
model: string,
|
|
150
|
+
maxTokens: number,
|
|
151
|
+
usage?: UsageRecorder,
|
|
152
|
+
): Promise<ProposedTreatment> {
|
|
153
|
+
const images = input.images ?? [];
|
|
154
|
+
const text = input.text?.trim() ?? "";
|
|
155
|
+
if (images.length === 0 && !text) throw new Error("treatment inference needs photos or text");
|
|
156
|
+
const content = [
|
|
157
|
+
...images.map((img) => ({
|
|
158
|
+
type: "image" as const,
|
|
159
|
+
source: { type: "base64" as const, media_type: img.mediaType, data: img.base64 },
|
|
160
|
+
})),
|
|
161
|
+
{
|
|
162
|
+
type: "text" as const,
|
|
163
|
+
text: text
|
|
164
|
+
? `Extract the product described by this source text as JSON.\n\n${text}`
|
|
165
|
+
: "Identify the drug or supplement in the photo(s) as JSON.",
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
const response = await anthropic.messages.create({
|
|
169
|
+
model,
|
|
170
|
+
max_tokens: maxTokens,
|
|
171
|
+
system: SYSTEM_PROMPT,
|
|
172
|
+
output_config: {
|
|
173
|
+
format: { type: "json_schema", schema: TREATMENT_INFER_SCHEMA },
|
|
174
|
+
},
|
|
175
|
+
messages: [{ role: "user", content }],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (response.stop_reason === "max_tokens") {
|
|
179
|
+
throw new Error("image inference truncated (hit max_tokens) — raise TREATMENT_INFER_MAX_TOKENS in treatment-infer-config.ts");
|
|
180
|
+
}
|
|
181
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
182
|
+
if (!textBlock || textBlock.type !== "text") {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`no text block in response — stop_reason=${response.stop_reason}, types=${response.content.map((b) => b.type).join(",")}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
let parsed: ProposedTreatment;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(textBlock.text);
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(`invalid JSON for image inference: ${textBlock.text.slice(0, 200)}`);
|
|
192
|
+
}
|
|
193
|
+
validate(parsed);
|
|
194
|
+
usage?.record(model, response.usage);
|
|
195
|
+
return parsed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Validates name/kind strictly (a bad one is a failed inference) and NORMALIZES the product fields
|
|
199
|
+
// leniently through the shared cleaners: a malformed ingredient or an unsafe URL is dropped rather
|
|
200
|
+
// than failing the whole extraction, since the name/kind half is still worth having. cleanLinks is
|
|
201
|
+
// also the boundary that rejects a javascript:/data: URL the model may have echoed out of the source.
|
|
202
|
+
export function validate(r: ProposedTreatment): void {
|
|
203
|
+
if (typeof r.name !== "string" || r.name.trim() === "") {
|
|
204
|
+
throw new Error("image inference result missing name");
|
|
205
|
+
}
|
|
206
|
+
if (r.kind !== "drug" && r.kind !== "supplement") {
|
|
207
|
+
throw new Error(`image inference result kind must be "drug" or "supplement", got ${JSON.stringify(r.kind)}`);
|
|
208
|
+
}
|
|
209
|
+
const description = typeof r.description === "string" ? r.description.trim() : "";
|
|
210
|
+
if (description) r.description = description;
|
|
211
|
+
else delete r.description;
|
|
212
|
+
const maker = typeof r.maker === "string" ? r.maker.trim() : "";
|
|
213
|
+
if (maker) r.maker = maker;
|
|
214
|
+
else delete r.maker;
|
|
215
|
+
const ingredients = cleanIngredients(r.ingredients);
|
|
216
|
+
if (ingredients.length) r.ingredients = ingredients;
|
|
217
|
+
else delete r.ingredients;
|
|
218
|
+
const links = cleanLinks(r.links);
|
|
219
|
+
if (links.length) r.links = links;
|
|
220
|
+
else delete r.links;
|
|
221
|
+
const admin = r.administration as Partial<Administration> | undefined;
|
|
222
|
+
const validFrequency = (["day", "week", "month", "as needed"] as DoseFrequency[]).includes(
|
|
223
|
+
admin?.suggestedFrequency as DoseFrequency,
|
|
224
|
+
);
|
|
225
|
+
if (admin && typeof admin.unit === "string" && admin.unit.trim() && typeof admin.suggestedUnits === "number" && Number.isFinite(admin.suggestedUnits) && validFrequency) {
|
|
226
|
+
const containerQuantity = typeof admin.containerQuantity === "number" && Number.isFinite(admin.containerQuantity) && admin.containerQuantity > 0
|
|
227
|
+
? admin.containerQuantity
|
|
228
|
+
: undefined;
|
|
229
|
+
r.administration = {
|
|
230
|
+
unit: admin.unit.trim(),
|
|
231
|
+
unitsPerServing: typeof admin.unitsPerServing === "number" && Number.isFinite(admin.unitsPerServing) && admin.unitsPerServing > 0 ? admin.unitsPerServing : 1,
|
|
232
|
+
suggestedUnits: admin.suggestedUnits,
|
|
233
|
+
suggestedFrequency: admin.suggestedFrequency as DoseFrequency,
|
|
234
|
+
...(containerQuantity != null ? { containerQuantity } : {}),
|
|
235
|
+
};
|
|
236
|
+
} else delete r.administration;
|
|
237
|
+
}
|