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