@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,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Client,
|
|
3
|
+
ClientFactors,
|
|
4
|
+
LegacyFactors,
|
|
5
|
+
LegacyTreatmentItem,
|
|
6
|
+
TreatmentItem,
|
|
7
|
+
TreatmentKind,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
// Normalize free-text treatment dates to ISO `YYYY-MM` and fold pre-unification vaults
|
|
11
|
+
// (medications / supplements / plan) into the single `treatments` array. Pure, and shared by a
|
|
12
|
+
// one-time migration and the read-shim (treatmentsOf) alike, so both fold identically.
|
|
13
|
+
|
|
14
|
+
const MONTHS: Record<string, string> = {
|
|
15
|
+
jan: "01", january: "01", feb: "02", february: "02", mar: "03", march: "03",
|
|
16
|
+
apr: "04", april: "04", may: "05", jun: "06", june: "06", jul: "07", july: "07",
|
|
17
|
+
aug: "08", august: "08", sep: "09", sept: "09", september: "09", oct: "10", october: "10",
|
|
18
|
+
nov: "11", november: "11", dec: "12", december: "12",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A single date-ish token → YYYY-MM (or YYYY, or "" when there's no year to anchor it).
|
|
22
|
+
function parseOne(raw: string, fallbackYear?: string): string {
|
|
23
|
+
const cleaned = raw.trim().toLowerCase().replace(/^since\s+/, "");
|
|
24
|
+
if (!cleaned) return "";
|
|
25
|
+
const iso = cleaned.match(/(\d{4})-(\d{1,2})(?:-\d{1,2})?/);
|
|
26
|
+
if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}`;
|
|
27
|
+
let month: string | undefined;
|
|
28
|
+
let year: string | undefined;
|
|
29
|
+
for (const tok of cleaned.split(/[\s,]+/).filter(Boolean)) {
|
|
30
|
+
if (MONTHS[tok]) month = MONTHS[tok];
|
|
31
|
+
else if (/^\d{4}$/.test(tok)) year = tok;
|
|
32
|
+
}
|
|
33
|
+
year ??= fallbackYear;
|
|
34
|
+
if (!year) return "";
|
|
35
|
+
return month ? `${year}-${month}` : year;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Free-text date → { start, end? }. A closed window like "April–May 2026" (en/em dash — a hyphen
|
|
39
|
+
// is left to ISO) becomes start+end; a bare month/year becomes start only; prose ("TBD", "when HRV
|
|
40
|
+
// rises") yields an empty start.
|
|
41
|
+
export function parseSince(raw: string): { start: string; end?: string } {
|
|
42
|
+
const s = (raw ?? "").trim();
|
|
43
|
+
if (!s) return { start: "" };
|
|
44
|
+
if (/^\d{4}(-\d{1,2}){0,2}$/.test(s)) return { start: parseOne(s) };
|
|
45
|
+
const range = s.match(/^(.+?)\s*[–—]\s*(.+)$/);
|
|
46
|
+
if (range) {
|
|
47
|
+
const end = parseOne(range[2]);
|
|
48
|
+
const start = parseOne(range[1], end.slice(0, 4) || undefined);
|
|
49
|
+
return end ? { start, end } : { start };
|
|
50
|
+
}
|
|
51
|
+
return { start: parseOne(s) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeDate(raw: string): string {
|
|
55
|
+
return parseSince(raw).start;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fold a (possibly legacy) factors object into the unified treatments array. Idempotent: if
|
|
59
|
+
// `treatments` already exists it is returned untouched, so a migrated vault is a no-op.
|
|
60
|
+
export function normalizeTreatments(factors: (ClientFactors & LegacyFactors) | undefined): TreatmentItem[] {
|
|
61
|
+
if (!factors) return [];
|
|
62
|
+
if (factors.treatments) return factors.treatments;
|
|
63
|
+
const out: TreatmentItem[] = [];
|
|
64
|
+
const fold = (items: LegacyTreatmentItem[] | undefined, kind: TreatmentKind) => {
|
|
65
|
+
for (const it of items ?? []) {
|
|
66
|
+
const name = (it.drug ?? "").trim();
|
|
67
|
+
if (!name) continue;
|
|
68
|
+
const { start, end } = parseSince(it.since ?? "");
|
|
69
|
+
const t: TreatmentItem = { id: crypto.randomUUID(), name, kind, start };
|
|
70
|
+
const dose = (it.dose ?? "").trim();
|
|
71
|
+
if (dose) t.dose = dose;
|
|
72
|
+
if (end) t.end = end;
|
|
73
|
+
out.push(t);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
fold(factors.medications, "drug");
|
|
77
|
+
fold(factors.supplements, "supplement");
|
|
78
|
+
for (const p of factors.plan ?? []) {
|
|
79
|
+
const name = (p.action ?? "").trim();
|
|
80
|
+
if (!name) continue;
|
|
81
|
+
// Plan actions are free prose — keep the whole string as a behavior name (no lossy name/dose
|
|
82
|
+
// split); their prose date often won't parse, leaving start "". The migration CLI re-stamps
|
|
83
|
+
// such rows to a future month so they read as PLANNED.
|
|
84
|
+
out.push({ id: crypto.randomUUID(), name, kind: "behavior", start: normalizeDate(p.date ?? "") });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function treatmentsOf(client: Client): TreatmentItem[] {
|
|
90
|
+
return normalizeTreatments(client.factors as (ClientFactors & LegacyFactors) | undefined);
|
|
91
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The product side of a treatment — description, ingredients, links — as pure functions shared by
|
|
2
|
+
// the browser form, the inference endpoint's validator, the staleness hash and the search index.
|
|
3
|
+
//
|
|
4
|
+
// Kept out of treatment-bucket.ts on purpose: that module owns dose and TIMING, and the one rule
|
|
5
|
+
// this feature must never break is that a product's label amounts stay away from formatDose() /
|
|
6
|
+
// treatmentLabel(). Different file, different concern, no import back the other way.
|
|
7
|
+
|
|
8
|
+
import type { Administration, Ingredient, ProductLink, TreatmentItem } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A URL safe to store and render. http/https only — a `javascript:` or `data:` href reaching an
|
|
12
|
+
* anchor is a script-injection vector, and these URLs arrive from model output over pasted text,
|
|
13
|
+
* which is exactly the untrusted boundary worth validating at.
|
|
14
|
+
*/
|
|
15
|
+
export function safeProductUrl(raw: unknown): string | null {
|
|
16
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
17
|
+
let parsed: URL;
|
|
18
|
+
try {
|
|
19
|
+
parsed = new URL(raw.trim());
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
24
|
+
return parsed.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drops anything unnamed or malformed rather than storing a half-record. */
|
|
28
|
+
export function cleanIngredients(raw: unknown): Ingredient[] {
|
|
29
|
+
if (!Array.isArray(raw)) return [];
|
|
30
|
+
const out: Ingredient[] = [];
|
|
31
|
+
for (const item of raw) {
|
|
32
|
+
const i = item as Partial<Ingredient>;
|
|
33
|
+
const name = typeof i?.name === "string" ? i.name.trim() : "";
|
|
34
|
+
if (!name) continue;
|
|
35
|
+
const amount = typeof i.amount === "number" && Number.isFinite(i.amount) ? i.amount : undefined;
|
|
36
|
+
const unit = typeof i.unit === "string" && i.unit.trim() ? i.unit.trim() : undefined;
|
|
37
|
+
const form = typeof i.form === "string" && i.form.trim() ? i.form.trim() : undefined;
|
|
38
|
+
out.push({ name, ...(amount != null ? { amount } : {}), ...(unit ? { unit } : {}), ...(form ? { form } : {}) });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A link with no usable URL is dropped; a link with no label falls back to its own host. */
|
|
44
|
+
export function cleanLinks(raw: unknown): ProductLink[] {
|
|
45
|
+
if (!Array.isArray(raw)) return [];
|
|
46
|
+
const out: ProductLink[] = [];
|
|
47
|
+
for (const item of raw) {
|
|
48
|
+
const l = item as Partial<ProductLink>;
|
|
49
|
+
const url = safeProductUrl(l?.url);
|
|
50
|
+
if (!url) continue;
|
|
51
|
+
const label = typeof l.label === "string" && l.label.trim() ? l.label.trim() : new URL(url).hostname;
|
|
52
|
+
out.push({ label, url });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "100mcg Selenium (L-Selenomethionine)" — display and prompt use the same rendering. */
|
|
58
|
+
export function formatIngredient(i: Ingredient): string {
|
|
59
|
+
const amount = i.amount != null ? `${i.amount}${i.unit ?? ""} ` : "";
|
|
60
|
+
return `${amount}${i.name}${i.form ? ` (${i.form})` : ""}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hasProductData(
|
|
64
|
+
t: Pick<TreatmentItem, "description" | "maker" | "ingredients" | "links" | "administration">,
|
|
65
|
+
): boolean {
|
|
66
|
+
return !!t.description?.trim() || !!t.maker?.trim() || !!t.ingredients?.length || !!t.links?.length || !!t.administration;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a medicine-scope save is newly attaching `administration` for the first time, or
|
|
71
|
+
* changing its `unit` vs. what the medicine's rows already carry — the trigger for relabeling
|
|
72
|
+
* every sibling dose row's `doseUnit` to match. Same trimmed/lowercased comparison
|
|
73
|
+
* computeConclusion uses for its own unit-mismatch check, so "unchanged" here means it already
|
|
74
|
+
* agreed the units matched.
|
|
75
|
+
*/
|
|
76
|
+
export function administrationUnitChanged(
|
|
77
|
+
prevAdministration: Administration | undefined,
|
|
78
|
+
nextAdministration: Administration | undefined,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (!nextAdministration) return false;
|
|
81
|
+
if (!prevAdministration) return true;
|
|
82
|
+
return prevAdministration.unit.trim().toLowerCase() !== nextAdministration.unit.trim().toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The product half of a treatment's staleness signature, or "" when there is none.
|
|
87
|
+
*
|
|
88
|
+
* Returning "" — rather than a run of empty pipes — is what keeps this rollout free. treatmentCanonical
|
|
89
|
+
* builds one pipe-joined string per treatment, so appending unconditionally would change EVERY
|
|
90
|
+
* existing treatment's signature, mark all of them stale, and fire a full regeneration for every user
|
|
91
|
+
* on next load. Same reasoning as the allergy/family summaries in factors-hash.ts, which omit their
|
|
92
|
+
* key outright when empty instead of emitting [].
|
|
93
|
+
*/
|
|
94
|
+
export function productCanonical(
|
|
95
|
+
t: Pick<TreatmentItem, "description" | "maker" | "ingredients" | "links" | "administration">,
|
|
96
|
+
): string {
|
|
97
|
+
if (!hasProductData(t)) return "";
|
|
98
|
+
const ingredients = (t.ingredients ?? []).map(formatIngredient).join(";");
|
|
99
|
+
const links = (t.links ?? []).map((l) => `${l.label}=${l.url}`).join(";");
|
|
100
|
+
// `maker` and `administration` are both appended ONLY when present — not into the middle of the
|
|
101
|
+
// original {description, ingredients, links} slots — so a record that predates either field
|
|
102
|
+
// (every treatment that already had product data before this milestone) produces the EXACT same
|
|
103
|
+
// string as before. Inserting a new fixed slot there, even an empty one, would still reshape
|
|
104
|
+
// every existing record's signature and fire the same unwanted mass regen this file exists to
|
|
105
|
+
// avoid — see the doc comment above.
|
|
106
|
+
const maker = t.maker?.trim() ? `|${t.maker.trim()}` : "";
|
|
107
|
+
const admin = t.administration
|
|
108
|
+
? `|${t.administration.unit}|${t.administration.unitsPerServing}|${t.administration.suggestedUnits}|${t.administration.suggestedFrequency}|${t.administration.containerQuantity ?? ""}`
|
|
109
|
+
: "";
|
|
110
|
+
return `|${t.description?.trim() ?? ""}|${ingredients}|${links}${maker}${admin}`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The two timing rules every treatment assessment must obey, in ONE place.
|
|
2
|
+
//
|
|
3
|
+
// They were previously written out twice — once in leaf-regen-registry.ts's per-node prompts (the
|
|
4
|
+
// Translate path) and once in finding-generate.ts's monolith prompt (the whole-Finding "↻ Translate"
|
|
5
|
+
// path) — in different formats, so a correction to one silently left the other wrong. Both had shipped
|
|
6
|
+
// the same two defects:
|
|
7
|
+
//
|
|
8
|
+
// 1. "the current dose is the most-recently-dated row" — which names a SCHEDULED FUTURE titration
|
|
9
|
+
// step as current the moment one exists, reporting a drug at 6mg while 9mg is actually active.
|
|
10
|
+
// 2. Nothing required a named co-treatment to be real or concurrent, so an assessment could call a
|
|
11
|
+
// stack "concurrent" by reading its constituents out of ANOTHER entry's name, months after it
|
|
12
|
+
// had ended.
|
|
13
|
+
//
|
|
14
|
+
// Both prompt systems now interpolate these constants, so there is one wording to correct.
|
|
15
|
+
|
|
16
|
+
// Which row of a titration is the dose in force right now.
|
|
17
|
+
export const CURRENT_DOSE_RULE =
|
|
18
|
+
"The CURRENT dose is the row whose window CONTAINS Today: its start is on or before Today AND it " +
|
|
19
|
+
"either has no end or its end is on or after Today. This is NOT simply the latest-dated row. A row " +
|
|
20
|
+
"that starts AFTER Today is a scheduled future step and is NOT the current dose, even though it is " +
|
|
21
|
+
"the newest and even though it has no end date; a CLOSED range that contains Today IS the current " +
|
|
22
|
+
"dose. If several rows contain Today, take the one with the latest start.";
|
|
23
|
+
|
|
24
|
+
// What must be true before another treatment may be named in an assessment.
|
|
25
|
+
export const CO_MENTION_RULE =
|
|
26
|
+
"CO-MENTION DISCIPLINE — before naming any OTHER treatment, verify BOTH of the following against " +
|
|
27
|
+
"the treatment list and drop the mention if either fails. (1) IT EXISTS AS ITS OWN ROW. Never infer " +
|
|
28
|
+
"a treatment from words inside another entry's name: an entry called \"Glutathione stack (Glycine " +
|
|
29
|
+
"20g/day and NAC 2g/day)\" is ONE row, and is NOT evidence that Glycine or NAC is separately on " +
|
|
30
|
+
"record. Only a row that appears in its own right counts. (2) ITS WINDOW OVERLAPS the window you " +
|
|
31
|
+
"are discussing. Read that row's own dates: a [Since X] row is active from X onward; a closed range " +
|
|
32
|
+
"[X–Y] is active only between X and Y. Two treatments are concurrent ONLY if both are active at the " +
|
|
33
|
+
"same time. If the other one ended before the item you are assessing started, or starts after it " +
|
|
34
|
+
"ended, they never overlapped — say what you actually mean in the right tense (\"ran until May 2026, " +
|
|
35
|
+
"so it no longer overlaps this dose\") or leave it out. NEVER write \"concurrent\", \"alongside\", " +
|
|
36
|
+
"\"together with\", \"on top of\", or \"while also taking\" about a treatment whose window does not " +
|
|
37
|
+
"overlap the one under discussion. An interaction you cannot date is an interaction you do not assert.";
|
|
38
|
+
|
|
39
|
+
// finding-generate.ts builds its prompt as an array of pre-wrapped lines rather than one long string,
|
|
40
|
+
// so the shared text is wrapped to a column and optionally indented to sit inside its section.
|
|
41
|
+
export function asPromptLines(rule: string, indent = "", width = 68): string[] {
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
let line = "";
|
|
44
|
+
for (const word of rule.split(" ")) {
|
|
45
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
46
|
+
if (candidate.length > width && line) {
|
|
47
|
+
out.push(indent + line);
|
|
48
|
+
line = word;
|
|
49
|
+
} else {
|
|
50
|
+
line = candidate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (line) out.push(indent + line);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// How to speak about dose in each temporal bucket, and against what yardstick.
|
|
58
|
+
//
|
|
59
|
+
// Two failures this exists to stop, both seen live on the same drug. A PAST card was described as
|
|
60
|
+
// "ongoing since August 2025" with "the current 6 mg/week dose" — present tense and a "current" dose
|
|
61
|
+
// for a regimen that has ended, which is simply false. And a bucket is not one dose: Tirzepatide's
|
|
62
|
+
// past alone holds nine closed windows, so naming any single row "the dose" throws away the
|
|
63
|
+
// trajectory that is the actual clinical content.
|
|
64
|
+
export const BUCKET_DOSE_RULE =
|
|
65
|
+
"DOSE IN CONTEXT — a treatment's rows are grouped by whether they are PAST, ONGOING or PLANNED, " +
|
|
66
|
+
"and each group can hold SEVERAL rows, because a titration is one drug across many dose periods. " +
|
|
67
|
+
"Never pick one row and call it \"the dose\"; read the group as a trajectory and say where it " +
|
|
68
|
+
"started, where it ended, and how it moved. Then match your tense and your language to the group " +
|
|
69
|
+
"you are discussing. PAST: every row has ended. Write entirely in the past tense and NEVER use " +
|
|
70
|
+
"\"current\", \"currently\", \"is taking\", \"remains on\", or \"ongoing\" about it. The facts worth " +
|
|
71
|
+
"stating are the range the dose covered, the highest dose reached, the final dose before it " +
|
|
72
|
+
"stopped, how long it ran in total, and what the markers did across that span — not what the " +
|
|
73
|
+
"patient is on now, which this group cannot tell you. ONGOING: the current dose is the row whose " +
|
|
74
|
+
"window contains Today (see the rule above); earlier rows in the group are the path taken to reach " +
|
|
75
|
+
"it, and a row starting after Today is a scheduled step, not the present. PLANNED: nothing is " +
|
|
76
|
+
"being taken yet. Write in the future tense — \"is scheduled to start at\", \"will escalate to\" — " +
|
|
77
|
+
"and never describe a planned dose as one the patient is on.";
|
|
78
|
+
|
|
79
|
+
// The yardstick the patient's own numbers should be read against.
|
|
80
|
+
export const STANDARD_DOSING_RULE =
|
|
81
|
+
"STANDARD DOSING — when you discuss a dose, say where it sits against that drug's usual range, " +
|
|
82
|
+
"when you know that range with confidence: its usual starting dose, its usual titration steps, and " +
|
|
83
|
+
"its maximum or usual maintenance dose. Tirzepatide, for example, conventionally starts at 2.5 " +
|
|
84
|
+
"mg/week and is titrated in 2.5 mg steps to a maximum of 15 mg/week; a patient at 7.5 mg/week is " +
|
|
85
|
+
"mid-titration with room above, and one at 15 mg/week is at ceiling with no escalation left. That " +
|
|
86
|
+
"placement is what makes a dose mean something: say plainly when a dose is sub-therapeutic, " +
|
|
87
|
+
"mid-range, at the usual maximum, or above label, and when an escalation is available say what the " +
|
|
88
|
+
"next conventional step is rather than inventing a target. If you do not know a drug's standard " +
|
|
89
|
+
"range with confidence, say nothing about it rather than guessing — a wrong ceiling is worse than " +
|
|
90
|
+
"no ceiling.";
|