@pablotech/akesi 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/benchmarks/retry-corrections.ts +231 -0
- package/dates.ts +36 -0
- package/document-model.ts +96 -0
- package/document-read.ts +171 -0
- package/factors-edit.ts +161 -0
- package/finding-assemble.ts +803 -0
- package/finding-generate.ts +1439 -0
- package/finding-regroup.ts +167 -0
- package/imaging-catalog.ts +108 -0
- package/index.ts +4 -0
- package/ingest-core.ts +115 -0
- package/item-registry.ts +69 -0
- package/marker-deltas.ts +84 -0
- package/marker-groups-prompt.ts +198 -0
- package/package.json +69 -0
- package/parsers-report.ts +21 -0
- package/pinned-queries.ts +113 -0
- package/ranges-prompt.ts +228 -0
- package/ranges.ts +94 -0
- package/report-extract.ts +337 -0
- package/report-merge.ts +372 -0
- package/report-title.ts +29 -0
- package/section-labels.ts +20 -0
- package/system-groups.ts +43 -0
- package/treatment-bucket.ts +366 -0
- package/treatment-infer.ts +237 -0
- package/treatment-normalize.ts +91 -0
- package/treatment-product.ts +111 -0
- package/treatment-timing-rules.ts +90 -0
- package/types.ts +647 -0
- package/unit-systems.ts +214 -0
- package/vitest.config.ts +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pablo Rodriguez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# `@pablotech/akesi`
|
|
2
|
+
|
|
3
|
+
*Ἀκεσώ — Akeso, daughter of Asclepius, goddess of the **process** of curing rather than the cure
|
|
4
|
+
itself; that was her sister Panacea's. The name is the disclaimer: this package builds the reasoning
|
|
5
|
+
and checks what comes back. It does not diagnose.*
|
|
6
|
+
|
|
7
|
+
Clinical reasoning over lab and marker data — unit normalization, reference-range lookup, treatment
|
|
8
|
+
bucketing, finding assembly — where **every constraint stated to the model in prose is re-enforced in
|
|
9
|
+
code on the response.**
|
|
10
|
+
|
|
11
|
+
The constraints worth enforcing are the ones a schema cannot state. Shape is largely the provider's
|
|
12
|
+
job now. What is left is the response that is valid JSON, every field the right type, and still files
|
|
13
|
+
clinical content under a body-system heading this patient does not have — where it is invisible in
|
|
14
|
+
every view organized by group. Assembling one finding raises 96 distinct refusals on four grounds —
|
|
15
|
+
shape, name, count and domain bounds — and the taxonomy is the substance of the package:
|
|
16
|
+
[`ARCHITECTURE.md` § *The response contract*](ARCHITECTURE.md#4-the-response-contract).
|
|
17
|
+
|
|
18
|
+
It is [`neuro-pil`](../neuro-pil)'s sibling, not its demonstration. This package does not import that
|
|
19
|
+
one and knows nothing about graphs; it is the workload the engine was extracted alongside, kept
|
|
20
|
+
separate to prove the engine is separable. A host is what joins them, hanging a derived node off each
|
|
21
|
+
finding so that changing a source — a marker's raw value, a reference range, a prompt template —
|
|
22
|
+
marks exactly the findings downstream of it as stale
|
|
23
|
+
([`../ARCHITECTURE.md`](../ARCHITECTURE.md#two-packages-no-edge-between-them)).
|
|
24
|
+
|
|
25
|
+
**Not medical advice.** Nothing in this package diagnoses, treats, or gives clinical guidance. It
|
|
26
|
+
assembles prompts and normalizes structured data for an LLM call an adopter supplies themselves;
|
|
27
|
+
what that call returns, and any decision made from it, is the adopter's responsibility to review
|
|
28
|
+
with a qualified clinician before acting on it.
|
|
29
|
+
|
|
30
|
+
## One patient, end to end
|
|
31
|
+
|
|
32
|
+
Everything below is quoted from this package's own checked-in fixtures — a synthetic patient named
|
|
33
|
+
`fullyPopulated` in [`tests/fixtures/canonical-variants.ts`](tests/fixtures/canonical-variants.ts),
|
|
34
|
+
and the prompt text it produces in
|
|
35
|
+
[`tests/fixtures/prompt-golden/`](tests/fixtures/prompt-golden/). The golden files are asserted
|
|
36
|
+
byte-for-byte by [`tests/prompt-golden.test.ts`](tests/prompt-golden.test.ts), so the rendered
|
|
37
|
+
output shown here cannot quietly drift away from what the code actually emits: if it changed, the
|
|
38
|
+
suite would be red. One exception, flagged where it appears: the grouping prompt in step 5 is
|
|
39
|
+
rendered from the same fixture but has no golden file, so it carries no such guarantee.
|
|
40
|
+
|
|
41
|
+
### 1. What arrives
|
|
42
|
+
|
|
43
|
+
Structured rows. Two lab results and three treatments, entered at different times by different
|
|
44
|
+
people, with no shared convention beyond the field names:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
results: [
|
|
48
|
+
{ marker: "ApoB", group: "Lipids", source: "lab", date: "2026-01-01", value: 80, unit: "mg/dL" },
|
|
49
|
+
{ marker: "LDL", group: "Lipids", source: "lab", date: "2026-01-01", value: 120, unit: "mg/dL" },
|
|
50
|
+
],
|
|
51
|
+
treatments: [
|
|
52
|
+
{ id: "t1", name: "Ezetimibe", dose: "10 mg", kind: "drug", start: "2025-01" },
|
|
53
|
+
{ id: "t2", name: "Statin", kind: "drug", start: "2024-01", end: "2024-12" },
|
|
54
|
+
{ id: "t3", name: "Start rosuvastatin", kind: "drug", start: "2099-02" },
|
|
55
|
+
],
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Nothing here says which of those three the patient is currently taking, or what `80` means next to
|
|
59
|
+
`120`. That is the work.
|
|
60
|
+
|
|
61
|
+
### 2. Normalize the units, where a bug is a clinical error
|
|
62
|
+
|
|
63
|
+
`unit-systems.ts` converts a reading from its stored unit into the other measurement system on
|
|
64
|
+
read — never on write, because a stored value stays faithful to its source. Conversion is
|
|
65
|
+
**analyte-specific**, so the table is keyed by marker name:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
"Apolipoprotein B": { us: "mg/dL", si: "g/L", k: 0.01 },
|
|
69
|
+
"LDL-C": { us: "mg/dL", si: "mmol/L", k: 1 / 38.67 },
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
An adopter reading this table is entitled to expect our patient's ApoB to render as `0.8 g/L` under
|
|
73
|
+
a metric selector. It does not, and the reason is the rule that gives this package its character
|
|
74
|
+
([`unit-systems.ts:12-14`](unit-systems.ts)):
|
|
75
|
+
|
|
76
|
+
> **CLINICAL SAFETY:** only analytes with a VERIFIED factor are converted; anything else passes
|
|
77
|
+
> through in its stored unit, untouched, and is surfaced by `unmappedConvertible` + the coverage-gate
|
|
78
|
+
> test. **A missing conversion is safe; a wrong one is a clinical error.**
|
|
79
|
+
|
|
80
|
+
The patient's rows say `ApoB` and `LDL`. The table's keys are `Apolipoprotein B` and `LDL-C`. Those
|
|
81
|
+
are not the same string, so no factor is verified for these two readings and nothing is converted:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
unmappedConvertible([
|
|
85
|
+
{ marker: "ApoB", unit: "mg/dL" },
|
|
86
|
+
{ marker: "LDL", unit: "mg/dL" },
|
|
87
|
+
])
|
|
88
|
+
// → [ { marker: 'ApoB', unit: 'mg/dL' }, { marker: 'LDL', unit: 'mg/dL' } ]
|
|
89
|
+
|
|
90
|
+
unmappedConvertible([
|
|
91
|
+
{ marker: "Apolipoprotein B", unit: "mg/dL" },
|
|
92
|
+
{ marker: "LDL-C", unit: "mg/dL" },
|
|
93
|
+
])
|
|
94
|
+
// → []
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This is the design working, not failing. `unmappedConvertible` returns every `(marker, unit)` pair
|
|
98
|
+
whose *unit* is of a convertible class but whose *marker* has no rule — the gap made countable, so a
|
|
99
|
+
host can gate on it and a developer sees a console warning, rather than the gap being invisible until
|
|
100
|
+
someone reads a number in the wrong scale. A synonym table mapping `ApoB` → `Apolipoprotein B` is a
|
|
101
|
+
reasonable thing for a host to add; guessing that they mean the same analyte, inside the converter,
|
|
102
|
+
is not.
|
|
103
|
+
|
|
104
|
+
Downstream, the reading is rendered in the unit it was stored in, and says so:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
ApoB (mg/dL)
|
|
108
|
+
Last year (1 reading):
|
|
109
|
+
2026-01-01: 80.0 mg/dL
|
|
110
|
+
Prior: (no earlier data)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The one place conversion is *not* per-reading is `normalizeSeries`: a single marker's history may
|
|
114
|
+
mix units across dates, and a chart or an aggregate over mixed units is meaningless, so a series is
|
|
115
|
+
reconciled to a canonical SI unit before it is charted.
|
|
116
|
+
|
|
117
|
+
### 3. Bucket by time
|
|
118
|
+
|
|
119
|
+
`bucketOf` (`treatment-bucket.ts:38-42`) is nine lines and settles what the three treatment rows
|
|
120
|
+
mean, relative to a `today` the caller passes in rather than reads from the clock:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
export function bucketOf(t: Pick<TreatmentItem, "start" | "end">, today: string): Bucket {
|
|
124
|
+
if (t.end && cmp(t.end, today) < 0) return "past";
|
|
125
|
+
if (t.start && cmp(t.start, today) > 0) return "planned";
|
|
126
|
+
return "ongoing";
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Against `today = "2026-06-28"`:
|
|
131
|
+
|
|
132
|
+
| Row | `start` | `end` | Bucket |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| Ezetimibe 10 mg | `2025-01` | — | `ongoing` |
|
|
135
|
+
| Statin | `2024-01` | `2024-12` | `past` |
|
|
136
|
+
| Start rosuvastatin | `2099-02` | — | `planned` |
|
|
137
|
+
|
|
138
|
+
`today` is a parameter because a prompt built from the same patient data must be reproducible: a
|
|
139
|
+
function that reads `Date.now()` produces a different prompt tomorrow for reasons that have nothing
|
|
140
|
+
to do with the patient, and a golden test over it could only ever be flaky.
|
|
141
|
+
|
|
142
|
+
### 4. What the model actually sees
|
|
143
|
+
|
|
144
|
+
`buildUserMessage` (`finding-generate.ts:259`) renders the bucketed rows into the prompt. This block
|
|
145
|
+
is verbatim from
|
|
146
|
+
[`tests/fixtures/prompt-golden/user--finding--fullyPopulated.txt`](tests/fixtures/prompt-golden/user--finding--fullyPopulated.txt):
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Treatment History:
|
|
150
|
+
Ongoing regimen (currently being taken — assess each in `treatment`):
|
|
151
|
+
- Ezetimibe 10 mg [since 2025-01]
|
|
152
|
+
Past / discontinued treatments (historical context only — do NOT assess these in `treatment`):
|
|
153
|
+
- Statin [2024-01–2024-12]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The planned one is not in that section at all. It appears under its own `Patient Plan` heading,
|
|
157
|
+
which instructs the model to *"Assess the plan AS A WHOLE in `planAssessment`; do NOT let it
|
|
158
|
+
influence progression / disease / treatment analysis"*:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
- Action: "Start rosuvastatin" (timing: 2099-02)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Three undifferentiated rows went in. What comes out is three *different kinds of fact*, each fenced
|
|
165
|
+
off from the others with a stated reason. That fencing is the package's actual output: not a
|
|
166
|
+
cleverer query, but one in which the model cannot mistake a drug the patient stopped eighteen
|
|
167
|
+
months ago for one they are on.
|
|
168
|
+
|
|
169
|
+
### 5. The round trip that has to converge
|
|
170
|
+
|
|
171
|
+
The finding above is one prompt and one answer. The marker-grouping surface is the other shape this
|
|
172
|
+
package uses, and it is the more instructive one: a bounded loop that keeps re-asking until every
|
|
173
|
+
marker has a home.
|
|
174
|
+
|
|
175
|
+
`runMarkerGroupingPasses` (`marker-groups-prompt.ts:174`) asks the model to place every marker under
|
|
176
|
+
one of the patient's body systems, listed in the prompt under the heading *"BODY SYSTEMS (assign
|
|
177
|
+
every marker to one of these, **verbatim**)"*. For our patient, `contextBlock` renders:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
MARKERS TO ASSIGN (place every one of these exactly once):
|
|
181
|
+
- ApoB [watchlist] (latest 80 mg/dL)
|
|
182
|
+
- LDL [watchlist] (latest 120 mg/dL)
|
|
183
|
+
|
|
184
|
+
PURSUED STUDIES (assignment hints):
|
|
185
|
+
- Suspicion: CVD
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The answer is not trusted. `reconcileGroups` drops any group name the model did not take verbatim —
|
|
189
|
+
dropped, not corrected, because an invented heading is a mis-filing rather than a typo — keeps the
|
|
190
|
+
first placement of each marker, and sweeps anything still unplaced into an explicit
|
|
191
|
+
`"Not yet categorized"` bucket. Coverage is exactly-once by construction: no marker can be
|
|
192
|
+
duplicated across two systems, and none can silently vanish.
|
|
193
|
+
|
|
194
|
+
Then the loop, and the part worth stealing. Markers left over are re-asked — at most three more
|
|
195
|
+
times, over **only** the still-unplaced residue — and on those passes the escape hatch is taken away:
|
|
196
|
+
|
|
197
|
+
> These markers were NOT assigned on the first pass. EVERY marker below belongs to one of the
|
|
198
|
+
> systems above […] Do NOT output a `"Not yet categorized"` group — **it is not permitted in this
|
|
199
|
+
> response**; if a marker seems to fit no system, choose the one it relates to most.
|
|
200
|
+
|
|
201
|
+
The bucket still exists as a safety net in code — but it is no longer offered to the model as an
|
|
202
|
+
option, because an escape hatch left available becomes the answer. And the loop stops on *two*
|
|
203
|
+
conditions, not one: the residue reaching zero, or a pass placing nothing new. The second is the one
|
|
204
|
+
people forget. A model that has failed to place a marker twice will keep failing, and the third
|
|
205
|
+
attempt costs exactly as much as the first.
|
|
206
|
+
|
|
207
|
+
### 6. What comes back, and what is checked
|
|
208
|
+
|
|
209
|
+
`assembleFinding` (`finding-assemble.ts:669`) takes the model's parsed response and builds the
|
|
210
|
+
stored `ClientFinding`. Before it does, `validate` refuses a response that is internally
|
|
211
|
+
inconsistent with the prompt that produced it — not merely malformed JSON, but well-formed JSON
|
|
212
|
+
that does not line up with what was asked.
|
|
213
|
+
|
|
214
|
+
Our patient has two note entries, one of which is blank; `populatedNoteEntries` drops the blank, so
|
|
215
|
+
the prompt presents exactly one note. A note has no short label the model could echo back, so
|
|
216
|
+
results are zipped back **positionally**, which only works if the count matches:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
noteResults must have exactly one entry per populated Note, in order (expected 1, got 2)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The same discipline runs through the rest: a `studyResults` entry whose `group` is not one of the
|
|
223
|
+
disease groups is rejected by name, a `disease` array with fewer than four areas is rejected, a
|
|
224
|
+
`treatment` array over thirty entries is rejected. A structurally inconsistent answer throws
|
|
225
|
+
instead of being stored — because a wrong association between a note and its result is not a
|
|
226
|
+
display bug, it is a clinical record that says something nobody said.
|
|
227
|
+
|
|
228
|
+
*Demonstrated by:* [`tests/finding-validate.test.ts`](tests/finding-validate.test.ts) §
|
|
229
|
+
*noteResults pairs to notes by position, so the count is the contract* — one case per direction
|
|
230
|
+
(a result too many, a result too few), each red when the count check is removed. The count of
|
|
231
|
+
refusals this section describes is not maintained by hand either:
|
|
232
|
+
[`tests/refusal-count.test.ts`](tests/refusal-count.test.ts) derives it from `finding-assemble.ts`
|
|
233
|
+
and fails every document that states a different one.
|
|
234
|
+
|
|
235
|
+
## Seven prompts, one discipline
|
|
236
|
+
|
|
237
|
+
The walk above follows one of them. The package has **seven** schema-constrained prompt surfaces, and
|
|
238
|
+
they all work the same way: the prompt states a constraint in prose, and code re-enforces it on the
|
|
239
|
+
response without consulting the prompt.
|
|
240
|
+
|
|
241
|
+
| Surface | What it asks for | What enforces the answer |
|
|
242
|
+
|---|---|---|
|
|
243
|
+
| Finding | The whole clinical reasoning unit — disease areas, treatments, studies, notes | `finding-assemble.ts` — 96 distinct refusals |
|
|
244
|
+
| Marker grouping | Every marker filed under one body system | `GROUPS_SCHEMA` + `reconcileGroups`, then the loop in step 5 |
|
|
245
|
+
| Reference ranges | Age/sex-appropriate ranges for the patient's markers | `RANGE_SCHEMA` |
|
|
246
|
+
| Report extraction | Structured markers out of a lab report | `REPORT_SCHEMA` |
|
|
247
|
+
| Document read | Structured JSON out of an arbitrary document | `DOCUMENT_READ_SCHEMA` |
|
|
248
|
+
| Treatment inference | A product label read as a label, not as a dose | `TREATMENT_INFER_SCHEMA` + the dose rule below |
|
|
249
|
+
| Treatment regrouping | Existing treatments re-filed under changed groups | `TREATMENT_GROUPS_TOOL` + `validateRegroup` |
|
|
250
|
+
|
|
251
|
+
The first row is the one that says the most. It is the largest surface and the only one with **no**
|
|
252
|
+
schema: it asks for strict JSON in prose. Structured output can guarantee that a field is a string —
|
|
253
|
+
it cannot guarantee that the string is a group name the model was given rather than one it made up,
|
|
254
|
+
that answers came back in the order the queries were put, or that a dose was read off a label
|
|
255
|
+
instead of inferred. Where the constraint is semantic, the only place to enforce it is code. That is
|
|
256
|
+
why the surface with no schema is also the surface with ninety-six refusals.
|
|
257
|
+
|
|
258
|
+
Four of the seven have golden fixtures. The gap and the reasoning are in
|
|
259
|
+
[`ARCHITECTURE.md`](./ARCHITECTURE.md) §3.
|
|
260
|
+
|
|
261
|
+
## Measured
|
|
262
|
+
|
|
263
|
+
One benchmark in this package has been run on real model calls: two retry-correction strategies
|
|
264
|
+
over twelve synthetic cases, scored by the package's own `validate()`. Every attempt passed on the
|
|
265
|
+
first try, which says the Ranges prompt is strong and says nothing about the strategies.
|
|
266
|
+
[`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil) has the numbers — the only place this repo
|
|
267
|
+
keeps one.
|
|
268
|
+
|
|
269
|
+
## When the model gets it wrong
|
|
270
|
+
|
|
271
|
+
`generateFindingResponse` (`finding-generate.ts:1363`) retries a rejected finding with a correction
|
|
272
|
+
appended. Three details of how it does that were each paid for by a real run.
|
|
273
|
+
|
|
274
|
+
**Every prior rejection is accumulated, not just the latest.** One run burned all six attempts it was
|
|
275
|
+
then allowed: attempt 4 failed on a duplicate marker group, 5 on a bad data-requisition group, 6 on a
|
|
276
|
+
`doctorConversation` label. Each correction named exactly one problem; the model fixed that problem
|
|
277
|
+
and broke a different one, and never once saw the accumulated list. A correction that replaces its
|
|
278
|
+
predecessor teaches the model to oscillate between two failures forever.
|
|
279
|
+
|
|
280
|
+
*Measured:* not this claim. Both strategies were run against twelve synthetic cases on two models
|
|
281
|
+
and **not one of the 72 attempts was rejected** — good news about the Ranges prompt, and no evidence
|
|
282
|
+
either way about accumulating corrections, because with nothing to correct both strategies sent
|
|
283
|
+
identical prompts. The paragraph above still rests on the single run that produced it
|
|
284
|
+
([`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil)).
|
|
285
|
+
|
|
286
|
+
**The ceiling is three attempts, and the number is cost-derived.** Each attempt is a whole Opus
|
|
287
|
+
generation — roughly $5 and several minutes. Six of them bought nothing on the run above, so the
|
|
288
|
+
bound came down rather than the corrections getting cleverer. A retry budget that is not priced is
|
|
289
|
+
not a budget.
|
|
290
|
+
|
|
291
|
+
**`onAttemptFailed` reports each rejection to the caller**, for two reasons that belong together. A
|
|
292
|
+
silent retry is indistinguishable from a hang — a live check once ran 32 minutes with nothing on
|
|
293
|
+
screen to say whether it was working or wedged. And the rejection message can name a treatment or a
|
|
294
|
+
study, which makes it PHI-adjacent: the package hands it to the caller rather than logging it, and
|
|
295
|
+
takes no view on where it may go. Observability and disclosure are the same decision here, and it is
|
|
296
|
+
not this package's to make.
|
|
297
|
+
|
|
298
|
+
## The dose rule
|
|
299
|
+
|
|
300
|
+
The most important logic in this package is not code. `treatment-infer.ts:121-135` is a prompt
|
|
301
|
+
paragraph, and it exists because a model reading a supplement label will otherwise report the
|
|
302
|
+
label's contents as the patient's intake:
|
|
303
|
+
|
|
304
|
+
> **THE DOSE RULE — the one that matters most:**
|
|
305
|
+
> An ingredient amount is a LABEL FACT about the product: what ONE capsule, tablet or serving
|
|
306
|
+
> contains. It is NOT how much the patient takes. […] A product containing 100mcg of selenium, or a
|
|
307
|
+
> label suggesting 1 capsule daily, tells you nothing about what THIS patient takes — the patient's
|
|
308
|
+
> own quantity, frequency and time of day are entered separately, after this step, and may differ
|
|
309
|
+
> from what the label suggests. **Your job stops at the label.**
|
|
310
|
+
|
|
311
|
+
An extraction step that quietly promotes "100mcg per capsule" into "patient takes 100mcg" produces
|
|
312
|
+
a record that reads as a clinical history and is a guess. The rule is stated at length, in the
|
|
313
|
+
prompt, rather than left to be inferred — the domain knowledge is the deliverable, and here it
|
|
314
|
+
happens to be English rather than TypeScript.
|
|
315
|
+
|
|
316
|
+
## Where `neuro-pil` comes in
|
|
317
|
+
|
|
318
|
+
None of this package's own code decides *when* a finding regenerates — that policy (which source
|
|
319
|
+
change invalidates which derived output, and what triggers a rerun) belongs to the host's
|
|
320
|
+
`neuro-pil` `Dag`. Declare each finding as a `derived` node whose `inputs` are its source markers,
|
|
321
|
+
ranges and prompt template, and `driftedKeys` tells you exactly which findings a given source edit
|
|
322
|
+
stales. Correcting our patient's ApoB from `80` to `85` stales every finding that read it, and
|
|
323
|
+
nothing else.
|
|
324
|
+
|
|
325
|
+
Between the two packages the patient's finding acquires two independent stamps, and the pair is the
|
|
326
|
+
point. `neuro-pil` stamps what the finding was derived *from*; the [brain](../README.md#what-this-is-for)
|
|
327
|
+
that produced it — its prompt, its schema, and the model that answered — is stamped by its own version
|
|
328
|
+
key. A stored finding is still trustworthy only when **both** hold. It can be fresh on one and stale on
|
|
329
|
+
the other: nobody has touched the patient's markers, but the reasoning that read them was replaced last
|
|
330
|
+
Tuesday.
|
|
331
|
+
|
|
332
|
+
See [`ARCHITECTURE.md`](./ARCHITECTURE.md#8-the-hosts-job-a-brain-registry) for how a host layers a
|
|
333
|
+
brain registry and an event log on top of that staleness signal, and
|
|
334
|
+
[`../neuro-pil/compare.ts`](../neuro-pil/compare.ts)'s `compareBrains` for scoring competing prompt
|
|
335
|
+
versions against a fixed case set once you have more than one.
|
|
336
|
+
|
|
337
|
+
## Module map
|
|
338
|
+
|
|
339
|
+
- **Normalization** — `unit-systems.ts`, `dates.ts`, `ranges.ts` / `ranges-prompt.ts`: converting
|
|
340
|
+
raw lab/marker values and dates into a comparable, unit-consistent shape, and rendering reference
|
|
341
|
+
ranges into prompt-ready text.
|
|
342
|
+
- **Domain registries** — `item-registry.ts`, `system-groups.ts`, `imaging-catalog.ts`,
|
|
343
|
+
`section-labels.ts`: static tables mapping raw report vocabulary (marker names, body systems,
|
|
344
|
+
imaging modalities) onto the labels a prompt or a UI needs.
|
|
345
|
+
- **Marker grouping** — `marker-groups-prompt.ts`: the prompt and JSON schema for assigning every
|
|
346
|
+
marker to a body system, plus `reconcileGroups`, which drops any group name the model did not take
|
|
347
|
+
verbatim from the allowed systems and guarantees exactly-once coverage, and
|
|
348
|
+
`runMarkerGroupingPasses`, which re-prompts only the still-unplaced markers (at most three times,
|
|
349
|
+
stopping early when a pass places nothing new). The model call itself is injected by the caller.
|
|
350
|
+
- **Treatment logic** — `treatment-normalize.ts`, `treatment-bucket.ts`, `treatment-product.ts`,
|
|
351
|
+
`treatment-infer.ts`, `treatment-timing-rules.ts`: turning a free-text treatment/medication entry
|
|
352
|
+
into a normalized, bucketed, timing-aware structure.
|
|
353
|
+
- **Document ingestion** — `document-model.ts`, `document-read.ts`, `report-extract.ts`,
|
|
354
|
+
`report-merge.ts`, `report-title.ts`, `ingest-core.ts`, `parsers-report.ts` (Node-only, `./pdf-node`):
|
|
355
|
+
turning a source document (a lab report PDF, in the reference implementation) into the structured
|
|
356
|
+
form the rest of the package operates on.
|
|
357
|
+
- **Finding construction** — `finding-generate.ts`, `finding-assemble.ts`, `finding-regroup.ts`,
|
|
358
|
+
`marker-deltas.ts`, `pinned-queries.ts`, `factors-edit.ts`: building the actual LLM prompt for one
|
|
359
|
+
reasoning unit ("finding") from its source data, and assembling/regrouping the model's structured
|
|
360
|
+
response.
|
|
361
|
+
- **Benchmarks** — `benchmarks/retry-corrections.ts`: a synthetic case set, two correction
|
|
362
|
+
strategies and the retry loop that runs them, scored by this package's own shipped `validate()`
|
|
363
|
+
rather than by a rubric written to be passed. Not exported from the package root, and never part
|
|
364
|
+
of `npm test` — running it costs real model calls. It constructs no client either: the cases and
|
|
365
|
+
the scorer are data, and a host supplies both the SDK instance and the comparison loop. See
|
|
366
|
+
[`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil).
|
|
367
|
+
|
|
368
|
+
## Importing
|
|
369
|
+
|
|
370
|
+
**The package root exports nothing.** `index.ts` is a comment and `export {};` — there is no barrel
|
|
371
|
+
file, deliberately: a barrel over twenty-nine modules would make every consumer's bundle depend on
|
|
372
|
+
all of them. Every import names its module directly, and the subpath is the module's own filename:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { bucketOf, groupByName } from "@pablotech/akesi/treatment-bucket";
|
|
376
|
+
import { unmappedConvertible, normalizeSeries } from "@pablotech/akesi/unit-systems";
|
|
377
|
+
import { buildUserMessage, SYSTEM_PROMPT } from "@pablotech/akesi/finding-generate";
|
|
378
|
+
import { assembleFinding } from "@pablotech/akesi/finding-assemble";
|
|
379
|
+
import type { Client, TreatmentItem } from "@pablotech/akesi/types";
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Every subpath in [`package.json`](package.json)'s `exports` is isomorphic — safe in a browser
|
|
383
|
+
bundle, a Cloudflare Pages Function or a Node CLI alike — with **one** exception: `./pdf-node`
|
|
384
|
+
(`parsers-report.ts`) pulls in `pdfjs-dist`. It is the only Node-only entry point, and it is named
|
|
385
|
+
that way so importing it is a deliberate act rather than something a bundler discovers for you.
|
|
386
|
+
|
|
387
|
+
The package has **no runtime dependencies**. Its two external couplings — `@anthropic-ai/sdk` and
|
|
388
|
+
`pdfjs-dist` — are declared as *optional* peer dependencies, so installing `akesi-pil` pulls in
|
|
389
|
+
neither. Bring the SDK if you call one of the three model-issuing functions, and `pdfjs-dist` if you
|
|
390
|
+
import `./pdf-node`; importing `unit-systems` or `treatment-bucket` should not cost you a PDF parser
|
|
391
|
+
and an HTTP client, and it doesn't.
|
|
392
|
+
|
|
393
|
+
## Tests
|
|
394
|
+
|
|
395
|
+
`npm test` runs standalone — no credentials, no network, no vault access, no model call. The suite
|
|
396
|
+
asserts eighteen golden prompt fixtures byte-for-byte, so any change to prompt construction shows up
|
|
397
|
+
as a fixture diff rather than a silent pass. Regenerate them with `npm run prompt:golden` and review
|
|
398
|
+
the diff before committing.
|
|
399
|
+
|
|
400
|
+
`benchmarks/` is the one directory `npm test` does not run, because a benchmark issues real model
|
|
401
|
+
calls. Everything in it except the calls is still covered offline against scripted responses — the
|
|
402
|
+
retry loop, both strategies, the scorer and the statistics — because an instrument nobody has tested
|
|
403
|
+
is not a measurement. Those tests assert the accumulation itself, by recording every user message
|
|
404
|
+
the loop sends and requiring the third attempt to carry both earlier rejections under one strategy
|
|
405
|
+
and only the latest under the other; one asserts the module cannot reach a provider on its own.
|
|
406
|
+
|
|
407
|
+
One test in that file is not about prompts at all:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
it("is generated from a synthetic roster and nothing else", () => {
|
|
411
|
+
const NAMES = ["Bare", "Full", "Empty", "CLI"];
|
|
412
|
+
const clients = [...Object.values(CANONICAL_VARIANTS).map((f) => f()), /* … */];
|
|
413
|
+
expect(clients.length).toBeGreaterThan(NAMES.length);
|
|
414
|
+
for (const c of clients) expect(NAMES).toContain(c.displayName);
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Every fixture in this package is synthetic, and this package is published; a real person reaching a
|
|
419
|
+
golden file would be a PHI leak into an open-source artefact. The guard makes that a red build
|
|
420
|
+
rather than a matter of remembering.
|
|
421
|
+
|
|
422
|
+
It checks the generator's *input*, and that is the interesting part. The obvious guard is a denylist
|
|
423
|
+
— scan each fixture for the names that must never appear — and it was written that way first. But a
|
|
424
|
+
denylist has to spell out the names it is keeping out, which in a public repo discloses exactly what
|
|
425
|
+
it exists to protect, and it still passes for a person nobody thought to list. Checking the roster
|
|
426
|
+
instead names only synthetic values and catches every real one, because the two ends are already
|
|
427
|
+
tied: the goldens are pinned byte-for-byte to what this roster produces, so nothing reaches a fixture
|
|
428
|
+
without entering here first. An allowlist over a controlled input beats a denylist over its output.
|
|
429
|
+
|
|
430
|
+
## License
|
|
431
|
+
|
|
432
|
+
MIT — see [`LICENSE`](./LICENSE).
|