@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,803 @@
|
|
|
1
|
+
// The pure, browser-safe half of finding generation: the response schema
|
|
2
|
+
// (FindingAIResponse), the structural + coverage validator, and the assembly of a validated
|
|
3
|
+
// response into a ClientFinding (hashes injected by the caller). No prompt, no Anthropic SDK, no
|
|
4
|
+
// Node — a browser imports this to validate + assemble a streamed finding; a host CLI and a
|
|
5
|
+
// serverless refresh endpoint reuse the exact same validate/assemble.
|
|
6
|
+
import type { ClientFinding } from "./types";
|
|
7
|
+
|
|
8
|
+
interface FindingDecisionAIResponse {
|
|
9
|
+
intervention: string;
|
|
10
|
+
purpose: string;
|
|
11
|
+
pros: string[];
|
|
12
|
+
cons: string[];
|
|
13
|
+
alternatives: string[];
|
|
14
|
+
recommendation: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FindingAIResponse {
|
|
18
|
+
progression: {
|
|
19
|
+
latest: string;
|
|
20
|
+
recent: string;
|
|
21
|
+
overall: string;
|
|
22
|
+
};
|
|
23
|
+
/** Leaf-owned: absent from a core response, present on an older stored finding. */
|
|
24
|
+
studyResults?: { study: string; result: string; group: string }[];
|
|
25
|
+
// M92 — no `noteId`/label field here: a note has no short verbatim-echoable label like Study's
|
|
26
|
+
// `focus`, so entries are paired back to `factors.noteEntries` by array position, not content.
|
|
27
|
+
// See assembleFinding's `meta.noteIds` (the caller supplies the parallel id array).
|
|
28
|
+
/** Leaf-owned: absent from a core response, present on an older stored finding. */
|
|
29
|
+
noteResults?: { result: string; group: string }[];
|
|
30
|
+
disease: { group: string; finding: string }[];
|
|
31
|
+
/** Leaf-owned: absent from a core response, present on an older stored finding. */
|
|
32
|
+
treatment?: { item: string; assessment: string; group: string }[];
|
|
33
|
+
patternAntipattern: { pattern: string; antipattern: string };
|
|
34
|
+
clinicalSynthesis: { adverse: string; favorable: string; conditioning?: string };
|
|
35
|
+
criticalRatios: {
|
|
36
|
+
name: string;
|
|
37
|
+
numerator: string;
|
|
38
|
+
denominator: string;
|
|
39
|
+
unit: string;
|
|
40
|
+
meaning: string;
|
|
41
|
+
generalLow?: number;
|
|
42
|
+
generalHigh?: number;
|
|
43
|
+
generalExplanation: string;
|
|
44
|
+
personalizedLow?: number;
|
|
45
|
+
personalizedHigh?: number;
|
|
46
|
+
explanation: string;
|
|
47
|
+
}[];
|
|
48
|
+
decisions: {
|
|
49
|
+
patient: FindingDecisionAIResponse[];
|
|
50
|
+
ai: FindingDecisionAIResponse[];
|
|
51
|
+
};
|
|
52
|
+
doctorConversation: { group: string; questions: string[] }[];
|
|
53
|
+
definitions: { term: string; definition: string; group: string }[];
|
|
54
|
+
healthMarkers: {
|
|
55
|
+
recommended: { group: string; markers: { name: string; rationale: string }[] }[];
|
|
56
|
+
};
|
|
57
|
+
dataRequisition: { type: string; group: string; items: string[] }[];
|
|
58
|
+
/** Leaf-owned: absent from a core response, present on an older stored finding. */
|
|
59
|
+
treatmentGroups?: { system: string; topic: string; patient: string[]; ai: string[] }[];
|
|
60
|
+
planAssessment: string;
|
|
61
|
+
/** Leaf-owned (aiOnPlan): absent from a core response, present on an older stored one. */
|
|
62
|
+
planAssessmentRows?: { action: string; assessment: string }[];
|
|
63
|
+
finalThoughts: string;
|
|
64
|
+
// The union the validator has always accepted (see validate()'s "accept either shape"), and
|
|
65
|
+
// the object form is the one production actually stores; the array form is what a model may emit,
|
|
66
|
+
// which the parser then collapses. This was declared as the array alone, so every test fixture
|
|
67
|
+
// built the real (object) shape and was silently wrong against its own type — invisible until
|
|
68
|
+
// tests/ came under the type checker.
|
|
69
|
+
basis: { key: string; text: string }[] | Record<string, string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The streamed finding's top-level portions, in emission order. Typed as
|
|
73
|
+
// `keyof FindingAIResponse` so a renamed schema key breaks the build here; a host refresh client scans the
|
|
74
|
+
// stream buffer for these to drive portion-based progress (N of M sections), so progress and the
|
|
75
|
+
// validated shape can't drift apart.
|
|
76
|
+
export const FINDING_PORTION_KEYS: readonly (keyof FindingAIResponse)[] = [
|
|
77
|
+
"progression",
|
|
78
|
+
// studyResults, noteResults, treatment, treatmentGroups and planAssessmentRows are gone
|
|
79
|
+
// from this list because
|
|
80
|
+
// they are gone from the core prompt: each is now written by its own leaf. Leaving them here would
|
|
81
|
+
// peg the progress bar four portions short of full on every successful run.
|
|
82
|
+
"disease",
|
|
83
|
+
"patternAntipattern",
|
|
84
|
+
"clinicalSynthesis",
|
|
85
|
+
"criticalRatios",
|
|
86
|
+
"decisions",
|
|
87
|
+
"doctorConversation",
|
|
88
|
+
"definitions",
|
|
89
|
+
"healthMarkers",
|
|
90
|
+
"dataRequisition",
|
|
91
|
+
"planAssessment",
|
|
92
|
+
"finalThoughts",
|
|
93
|
+
"basis",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// The six keys the monolith no longer narrates: each is a leaf whose turn is generated by its own
|
|
97
|
+
// LEAF_REGEN_SPECS entry, and whose basis sentence is stamped from that node's own DagNode.basis at
|
|
98
|
+
// merge time (stampLeafBasis in leaf-regen-registry.ts). Keeping them in the required set would make
|
|
99
|
+
// the core response fail validation for narrating sections it does not write.
|
|
100
|
+
export const LEAF_OWNED_BASIS_KEYS = [
|
|
101
|
+
"studyResults",
|
|
102
|
+
"noteResults",
|
|
103
|
+
"treatmentAssessment",
|
|
104
|
+
"hypothesisEvaluation",
|
|
105
|
+
"treatmentGroups",
|
|
106
|
+
"aiOnPlan",
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
export const BASIS_KEYS = [
|
|
110
|
+
"patientAssessment",
|
|
111
|
+
"patientProfile",
|
|
112
|
+
"statedObjective",
|
|
113
|
+
"pursuedStudy",
|
|
114
|
+
"pursuedNotes",
|
|
115
|
+
"diagnosedDisease",
|
|
116
|
+
"treatmentHistory",
|
|
117
|
+
"correlationHistory",
|
|
118
|
+
"patientHypothesis",
|
|
119
|
+
"markerLevels",
|
|
120
|
+
"aiFindings",
|
|
121
|
+
"healthProgression",
|
|
122
|
+
"studyResults",
|
|
123
|
+
"noteResults",
|
|
124
|
+
"possibleFindings",
|
|
125
|
+
"treatmentAssessment",
|
|
126
|
+
"dataRequisition",
|
|
127
|
+
"aiHypothesis",
|
|
128
|
+
"hypothesisEvaluation",
|
|
129
|
+
"doctorConversation",
|
|
130
|
+
"healthMarkers",
|
|
131
|
+
"patientPlan",
|
|
132
|
+
"treatmentGroups",
|
|
133
|
+
"patternAntipattern",
|
|
134
|
+
"clinicalSynthesis",
|
|
135
|
+
"criticalRatios",
|
|
136
|
+
"aiOnPlan",
|
|
137
|
+
"finalThoughts",
|
|
138
|
+
"abbreviations",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
type BasisKey = typeof BASIS_KEYS[number];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Collapse a response's `basis` into the object form the vault stores.
|
|
145
|
+
*
|
|
146
|
+
* Accepts BOTH shapes, matching what validate() has always accepted. It took the array alone,
|
|
147
|
+
* so a model emitting the object form (which validate explicitly permits — see its "accept either
|
|
148
|
+
* shape" branch) passed validation and then threw here, iterating a non-iterable. Latent until the
|
|
149
|
+
* declared type was widened to the union the validator actually allows.
|
|
150
|
+
*/
|
|
151
|
+
function basisFromResponse(basis: FindingAIResponse["basis"]) {
|
|
152
|
+
const out = {} as Record<BasisKey, string>;
|
|
153
|
+
const entries = Array.isArray(basis)
|
|
154
|
+
? basis.map((e) => [e.key, e.text] as const)
|
|
155
|
+
: Object.entries(basis ?? {});
|
|
156
|
+
for (const [key, text] of entries) {
|
|
157
|
+
out[key.trim() as BasisKey] = String(text).trim();
|
|
158
|
+
}
|
|
159
|
+
return out as ClientFinding["basis"];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseFindingDecision(d: FindingDecisionAIResponse) {
|
|
163
|
+
return {
|
|
164
|
+
intervention: d.intervention.trim(),
|
|
165
|
+
purpose: d.purpose.trim(),
|
|
166
|
+
pros: d.pros.map((s) => s.trim()).filter((s) => s.length > 0),
|
|
167
|
+
cons: d.cons.map((s) => s.trim()).filter((s) => s.length > 0),
|
|
168
|
+
alternatives: d.alternatives.map((s) => s.trim()).filter((s) => s.length > 0),
|
|
169
|
+
recommendation: d.recommendation.trim(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function validateFindingResponse(r: FindingAIResponse): void {
|
|
174
|
+
return validate(r);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Full validation including patient-side coverage/verbatim matching — the exact path the pipeline
|
|
178
|
+
// runs (generateFinding passes the same `expected`). Exposed so a contract test can assert the
|
|
179
|
+
// prompt presentation and the verbatim-match logic agree (e.g. a date-prefixed plan-action ref is
|
|
180
|
+
// rejected, so the retry-feedback can fix it).
|
|
181
|
+
export function validateFindingWithInputs(
|
|
182
|
+
r: FindingAIResponse,
|
|
183
|
+
expected: { patient: string[]; planActions?: string[]; noteIds?: string[] },
|
|
184
|
+
): void {
|
|
185
|
+
return validate(r, expected);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `expected.patient` is the set of patient-item refs (factors.decisions interventions + factors.plan
|
|
189
|
+
// actions) that treatmentGroups must cover. `expected.noteIds` is the ordered id list noteResults
|
|
190
|
+
// must be pairable against (one result per populated Note entry, in order — see populatedNoteEntries).
|
|
191
|
+
// Omitted by the structural-only wrapper above (existing unit fixtures), in which case patient-side
|
|
192
|
+
// coverage/membership and the noteResults count are skipped; AI-side coverage (from r.decisions.ai)
|
|
193
|
+
// is always checked.
|
|
194
|
+
export function validate(r: FindingAIResponse, expected?: { patient: string[]; planActions?: string[]; noteIds?: string[] }): void {
|
|
195
|
+
const sections: [string, string][] = [
|
|
196
|
+
["progression.latest", r.progression?.latest],
|
|
197
|
+
["progression.recent", r.progression?.recent],
|
|
198
|
+
["progression.overall", r.progression?.overall],
|
|
199
|
+
];
|
|
200
|
+
for (const [key, value] of sections) {
|
|
201
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
202
|
+
throw new Error(`finding section "${key}" missing`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Body-system tags on studyResults / treatment (used by the UI to group them under System Analysis
|
|
206
|
+
// headings) must each match one of the disease[].group names — same discipline as treatmentGroups.
|
|
207
|
+
const diseaseGroupSet = new Set((r.disease ?? []).map((d) => d?.group?.trim()).filter((g): g is string => !!g));
|
|
208
|
+
if (r.studyResults !== undefined && !Array.isArray(r.studyResults)) {
|
|
209
|
+
throw new Error(`studyResults present but not an array`);
|
|
210
|
+
}
|
|
211
|
+
for (const [i, s] of (r.studyResults ?? []).entries()) {
|
|
212
|
+
if (!s || typeof s.study !== "string" || s.study.trim().length === 0) {
|
|
213
|
+
throw new Error(`studyResults[${i}].study missing`);
|
|
214
|
+
}
|
|
215
|
+
if (typeof s.result !== "string" || s.result.trim().length === 0) {
|
|
216
|
+
throw new Error(`studyResults[${i}] (${s.study}) result missing`);
|
|
217
|
+
}
|
|
218
|
+
if (typeof s.group !== "string" || s.group.trim().length === 0) {
|
|
219
|
+
throw new Error(`studyResults[${i}] (${s.study}) group missing`);
|
|
220
|
+
}
|
|
221
|
+
if (!diseaseGroupSet.has(s.group.trim())) {
|
|
222
|
+
throw new Error(`studyResults[${i}] (${s.study}) group "${s.group}" is not one of the disease groups`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// noteResults — same shape/group discipline as studyResults, minus a label field (a note has no
|
|
226
|
+
// short verbatim label; entries pair back to factors.noteEntries by position — see expected.noteIds).
|
|
227
|
+
if (r.noteResults !== undefined && !Array.isArray(r.noteResults)) {
|
|
228
|
+
throw new Error(`noteResults missing or not an array`);
|
|
229
|
+
}
|
|
230
|
+
if (expected?.noteIds && r.noteResults !== undefined && r.noteResults.length !== expected.noteIds.length) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`noteResults must have exactly one entry per populated Note, in order (expected ${expected.noteIds.length}, got ${r.noteResults!.length})`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
for (const [i, n] of (r.noteResults ?? []).entries()) {
|
|
236
|
+
if (!n || typeof n.result !== "string" || n.result.trim().length === 0) {
|
|
237
|
+
throw new Error(`noteResults[${i}].result missing`);
|
|
238
|
+
}
|
|
239
|
+
if (typeof n.group !== "string" || n.group.trim().length === 0) {
|
|
240
|
+
throw new Error(`noteResults[${i}].group missing`);
|
|
241
|
+
}
|
|
242
|
+
if (!diseaseGroupSet.has(n.group.trim())) {
|
|
243
|
+
throw new Error(`noteResults[${i}] group "${n.group}" is not one of the disease groups`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!Array.isArray(r.disease) || r.disease.length < 4) {
|
|
247
|
+
throw new Error(`disease missing or has fewer than 4 area entries`);
|
|
248
|
+
}
|
|
249
|
+
if (r.disease.length > 10) {
|
|
250
|
+
throw new Error(`disease has more than 10 area entries (${r.disease.length})`);
|
|
251
|
+
}
|
|
252
|
+
for (const [i, d] of r.disease.entries()) {
|
|
253
|
+
if (!d || typeof d.group !== "string" || d.group.trim().length === 0) {
|
|
254
|
+
throw new Error(`disease[${i}].group missing`);
|
|
255
|
+
}
|
|
256
|
+
if (typeof d.finding !== "string" || d.finding.trim().length === 0) {
|
|
257
|
+
throw new Error(`disease[${i}] (${d.group}) finding missing`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (r.treatment !== undefined && (!Array.isArray(r.treatment) || r.treatment.length < 1)) {
|
|
261
|
+
throw new Error(`treatment missing or empty`);
|
|
262
|
+
}
|
|
263
|
+
if ((r.treatment?.length ?? 0) > 30) {
|
|
264
|
+
throw new Error(`treatment has more than 30 entries (${r.treatment!.length})`);
|
|
265
|
+
}
|
|
266
|
+
const seenItems = new Set<string>();
|
|
267
|
+
for (const [i, t] of (r.treatment ?? []).entries()) {
|
|
268
|
+
if (!t || typeof t.item !== "string" || t.item.trim().length === 0) {
|
|
269
|
+
throw new Error(`treatment[${i}].item missing`);
|
|
270
|
+
}
|
|
271
|
+
if (typeof t.assessment !== "string" || t.assessment.trim().length === 0) {
|
|
272
|
+
throw new Error(`treatment[${i}] (${t.item}) assessment missing`);
|
|
273
|
+
}
|
|
274
|
+
if (typeof t.group !== "string" || t.group.trim().length === 0) {
|
|
275
|
+
throw new Error(`treatment[${i}] (${t.item}) group missing`);
|
|
276
|
+
}
|
|
277
|
+
if (!diseaseGroupSet.has(t.group.trim())) {
|
|
278
|
+
throw new Error(`treatment[${i}] (${t.item}) group "${t.group}" is not one of the disease groups`);
|
|
279
|
+
}
|
|
280
|
+
const baseName = t.item.trim().toLowerCase().replace(/\s+\d.*$/, "");
|
|
281
|
+
if (seenItems.has(baseName)) {
|
|
282
|
+
throw new Error(`treatment[${i}] (${t.item}) duplicates a prior entry by drug name — titration must collapse into one entry`);
|
|
283
|
+
}
|
|
284
|
+
seenItems.add(baseName);
|
|
285
|
+
}
|
|
286
|
+
if (!r.decisions || typeof r.decisions !== "object" || Array.isArray(r.decisions)) {
|
|
287
|
+
throw new Error(`decisions missing or not an object with { patient, ai }`);
|
|
288
|
+
}
|
|
289
|
+
if (!Array.isArray(r.decisions.patient)) {
|
|
290
|
+
throw new Error(`decisions.patient missing or not an array`);
|
|
291
|
+
}
|
|
292
|
+
if (!Array.isArray(r.decisions.ai)) {
|
|
293
|
+
throw new Error(`decisions.ai missing or not an array`);
|
|
294
|
+
}
|
|
295
|
+
if (r.decisions.ai.length > 12) {
|
|
296
|
+
throw new Error(`decisions.ai has more than 12 entries (${r.decisions.ai.length})`);
|
|
297
|
+
}
|
|
298
|
+
const validateOneDecision = (label: string, i: number, d: FindingDecisionAIResponse) => {
|
|
299
|
+
if (!d || typeof d.intervention !== "string" || d.intervention.trim().length === 0) {
|
|
300
|
+
throw new Error(`${label}[${i}].intervention missing`);
|
|
301
|
+
}
|
|
302
|
+
if (typeof d.purpose !== "string" || d.purpose.trim().length === 0) {
|
|
303
|
+
throw new Error(`${label}[${i}] (${d.intervention}) purpose missing`);
|
|
304
|
+
}
|
|
305
|
+
for (const field of ["pros", "cons", "alternatives"] as const) {
|
|
306
|
+
if (!Array.isArray(d[field]) || d[field].length < 2) {
|
|
307
|
+
throw new Error(`${label}[${i}] (${d.intervention}) ${field} must be an array of 2+ bullets`);
|
|
308
|
+
}
|
|
309
|
+
if (d[field].length > 8) {
|
|
310
|
+
throw new Error(`${label}[${i}] (${d.intervention}) ${field} has more than 8 bullets`);
|
|
311
|
+
}
|
|
312
|
+
for (const [j, s] of d[field].entries()) {
|
|
313
|
+
if (typeof s !== "string" || s.trim().length === 0) {
|
|
314
|
+
throw new Error(`${label}[${i}].${field}[${j}] missing`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (typeof d.recommendation !== "string" || d.recommendation.trim().length === 0) {
|
|
319
|
+
throw new Error(`${label}[${i}] (${d.intervention}) recommendation missing`);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
for (const [i, d] of r.decisions.patient.entries()) validateOneDecision("decisions.patient", i, d);
|
|
323
|
+
for (const [i, d] of r.decisions.ai.entries()) validateOneDecision("decisions.ai", i, d);
|
|
324
|
+
// AI Hypothesis is now the collective intervention set, so it MAY restate
|
|
325
|
+
// items the patient already lists — no dedup against decisions.patient.
|
|
326
|
+
|
|
327
|
+
const expectedDc = r.disease.length + r.decisions.patient.length + r.decisions.ai.length;
|
|
328
|
+
if (!Array.isArray(r.doctorConversation) || r.doctorConversation.length !== expectedDc) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`doctorConversation must have one entry per disease group + one per patient decision + one per AI consideration (expected ${expectedDc}, got ${
|
|
331
|
+
Array.isArray(r.doctorConversation) ? r.doctorConversation.length : "non-array"
|
|
332
|
+
})`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
for (const [i, g] of r.doctorConversation.entries()) {
|
|
336
|
+
if (!g || typeof g.group !== "string" || g.group.trim().length === 0) {
|
|
337
|
+
throw new Error(`doctorConversation[${i}].group missing`);
|
|
338
|
+
}
|
|
339
|
+
let expectedGroup: string | undefined;
|
|
340
|
+
if (i < r.disease.length) {
|
|
341
|
+
expectedGroup = r.disease[i]?.group?.trim();
|
|
342
|
+
} else if (i < r.disease.length + r.decisions.patient.length) {
|
|
343
|
+
expectedGroup = r.decisions.patient[i - r.disease.length]?.intervention?.trim();
|
|
344
|
+
} else {
|
|
345
|
+
expectedGroup = r.decisions.ai[i - r.disease.length - r.decisions.patient.length]?.intervention?.trim();
|
|
346
|
+
}
|
|
347
|
+
if (expectedGroup && g.group.trim() !== expectedGroup) {
|
|
348
|
+
// A real run died here on attempt 6 with "Methylation stack (Methyl-B12 + L-methylfolate)"
|
|
349
|
+
// against its OWN earlier "…(Methyl-B12 + L-methylfolate ± cofactors)": the model truncated a
|
|
350
|
+
// long label it was asked to reproduce verbatim. That is a transcription slip, not a different
|
|
351
|
+
// intervention, and a $5 regeneration is an absurd price for it.
|
|
352
|
+
//
|
|
353
|
+
// A prefix relationship is the signature of a truncation and is strong evidence of the same
|
|
354
|
+
// subject; anything else still throws, so naming an unrelated intervention (the real
|
|
355
|
+
// misattribution risk — wrong questions filed under the wrong decision) is untouched. On a
|
|
356
|
+
// tolerated near-match the canonical label is STAMPED, so downstream readers still get one
|
|
357
|
+
// spelling.
|
|
358
|
+
const got = g.group.trim();
|
|
359
|
+
const near = got.startsWith(expectedGroup) || expectedGroup.startsWith(got);
|
|
360
|
+
if (!near) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`doctorConversation[${i}] group "${g.group}" does not match expected "${expectedGroup}" — order is disease groups, then patient decisions, then AI considerations`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
g.group = expectedGroup;
|
|
366
|
+
}
|
|
367
|
+
if (!Array.isArray(g.questions) || g.questions.length < 1) {
|
|
368
|
+
throw new Error(`doctorConversation[${i}] (${g.group}) has no questions`);
|
|
369
|
+
}
|
|
370
|
+
if (g.questions.length > 6) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`doctorConversation[${i}] (${g.group}) has more than 6 questions (${g.questions.length})`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
for (const [j, q] of g.questions.entries()) {
|
|
376
|
+
if (typeof q !== "string" || q.trim().length === 0) {
|
|
377
|
+
throw new Error(`doctorConversation[${i}].questions[${j}] missing`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// treatmentGroups — partition of every proposed treatment. AI-side coverage is always
|
|
382
|
+
// enforced (refs live in r.decisions.ai); patient-side only when `expected` is supplied.
|
|
383
|
+
if (r.treatmentGroups !== undefined && !Array.isArray(r.treatmentGroups)) {
|
|
384
|
+
throw new Error(`treatmentGroups missing or not an array`);
|
|
385
|
+
}
|
|
386
|
+
const aiInterventions = r.decisions.ai.map((d) => d.intervention.trim());
|
|
387
|
+
// Every group is pinned to a disease[].group (its System Analysis heading); groups are
|
|
388
|
+
// ordered by that system in disease order, and all groups of one system are contiguous.
|
|
389
|
+
const diseaseGroups = r.disease.map((d) => d.group.trim());
|
|
390
|
+
const seenAi = new Set<string>();
|
|
391
|
+
const seenPatient = new Set<string>();
|
|
392
|
+
let prevSystemIdx = -1;
|
|
393
|
+
const systemsSeen = new Set<string>();
|
|
394
|
+
for (const [i, g] of (r.treatmentGroups ?? []).entries()) {
|
|
395
|
+
if (!g || typeof g.topic !== "string" || g.topic.trim().length === 0) {
|
|
396
|
+
throw new Error(`treatmentGroups[${i}].topic missing`);
|
|
397
|
+
}
|
|
398
|
+
if (typeof g.system !== "string" || g.system.trim().length === 0) {
|
|
399
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) system missing`);
|
|
400
|
+
}
|
|
401
|
+
const sysIdx = diseaseGroups.indexOf(g.system.trim());
|
|
402
|
+
if (sysIdx < 0) {
|
|
403
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) system "${g.system}" is not one of the disease groups`);
|
|
404
|
+
}
|
|
405
|
+
if (sysIdx < prevSystemIdx) {
|
|
406
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) system "${g.system}" is out of disease order`);
|
|
407
|
+
}
|
|
408
|
+
if (sysIdx !== prevSystemIdx && systemsSeen.has(g.system.trim())) {
|
|
409
|
+
throw new Error(`treatmentGroups system "${g.system}" is not contiguous — all groups of a system must be adjacent`);
|
|
410
|
+
}
|
|
411
|
+
prevSystemIdx = sysIdx;
|
|
412
|
+
systemsSeen.add(g.system.trim());
|
|
413
|
+
if (!Array.isArray(g.patient) || !Array.isArray(g.ai)) {
|
|
414
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) patient and ai must be arrays`);
|
|
415
|
+
}
|
|
416
|
+
if (g.patient.length === 0 && g.ai.length === 0) {
|
|
417
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) has neither patient nor ai items`);
|
|
418
|
+
}
|
|
419
|
+
for (const ref of g.ai) {
|
|
420
|
+
const t = (ref ?? "").trim();
|
|
421
|
+
if (!aiInterventions.includes(t)) {
|
|
422
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) ai ref "${ref}" matches no decisions.ai intervention`);
|
|
423
|
+
}
|
|
424
|
+
if (seenAi.has(t)) throw new Error(`treatmentGroups ai ref "${t}" appears in more than one group`);
|
|
425
|
+
seenAi.add(t);
|
|
426
|
+
}
|
|
427
|
+
for (const ref of g.patient) {
|
|
428
|
+
const t = (ref ?? "").trim();
|
|
429
|
+
if (t.length === 0) throw new Error(`treatmentGroups[${i}] (${g.topic}) has an empty patient ref`);
|
|
430
|
+
if (expected && !expected.patient.includes(t)) {
|
|
431
|
+
throw new Error(`treatmentGroups[${i}] (${g.topic}) patient ref "${ref}" matches no patient hypothesis or plan action`);
|
|
432
|
+
}
|
|
433
|
+
if (seenPatient.has(t)) throw new Error(`treatmentGroups patient ref "${t}" appears in more than one group`);
|
|
434
|
+
seenPatient.add(t);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Only when the response actually CARRIES treatmentGroups. The core writes decisions.ai but
|
|
438
|
+
// no longer writes treatmentGroups (the leaf does), which made this unsatisfiable: it demanded
|
|
439
|
+
// placement into a section that is legitimately absent. Placement is still enforced for the leaf's
|
|
440
|
+
// own output by validateRegroup (finding-regroup.ts), which checks the same thing against the ids
|
|
441
|
+
// it was given — so the guarantee moved rather than disappeared.
|
|
442
|
+
if (r.treatmentGroups !== undefined) {
|
|
443
|
+
for (const a of aiInterventions) {
|
|
444
|
+
if (!seenAi.has(a)) throw new Error(`decisions.ai "${a}" is not placed in any treatmentGroups group`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Same guard, patient side. This one fires only when `expected` is supplied — which the live
|
|
448
|
+
// refresh always does and the structural-only wrapper never does, which is exactly why the
|
|
449
|
+
// core-only unit test below had to start passing `expected` too.
|
|
450
|
+
if (expected && r.treatmentGroups !== undefined) {
|
|
451
|
+
for (const p of expected.patient) {
|
|
452
|
+
if (!seenPatient.has(p)) throw new Error(`patient item "${p}" is not placed in any treatmentGroups group`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!Array.isArray(r.definitions) || r.definitions.length < 3) {
|
|
457
|
+
throw new Error(`definitions missing or has fewer than 3 entries`);
|
|
458
|
+
}
|
|
459
|
+
if (r.definitions.length > 80) {
|
|
460
|
+
throw new Error(`definitions has more than 80 entries (${r.definitions.length})`);
|
|
461
|
+
}
|
|
462
|
+
for (const [i, d] of r.definitions.entries()) {
|
|
463
|
+
if (!d || typeof d.term !== "string" || d.term.trim().length === 0) {
|
|
464
|
+
throw new Error(`definitions[${i}].term missing`);
|
|
465
|
+
}
|
|
466
|
+
if (typeof d.definition !== "string" || d.definition.trim().length === 0) {
|
|
467
|
+
throw new Error(`definitions[${i}] (${d.term}) definition missing`);
|
|
468
|
+
}
|
|
469
|
+
if (typeof d.group !== "string" || d.group.trim().length === 0) {
|
|
470
|
+
throw new Error(`definitions[${i}] (${d.term}) group missing`);
|
|
471
|
+
}
|
|
472
|
+
if (!diseaseGroupSet.has(d.group.trim())) {
|
|
473
|
+
throw new Error(`definitions[${i}] (${d.term}) group "${d.group}" is not one of the disease groups`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (!r.healthMarkers || !Array.isArray(r.healthMarkers.recommended) || r.healthMarkers.recommended.length < 1) {
|
|
477
|
+
throw new Error(`healthMarkers.recommended missing or empty`);
|
|
478
|
+
}
|
|
479
|
+
// A duplicated group used to reject the whole response, costing a full regeneration to fix
|
|
480
|
+
// something with exactly one correct reading: the group's markers are the union of its entries. The
|
|
481
|
+
// group name is the key, nothing is ambiguous, and nothing is lost, so merge in place instead. This
|
|
482
|
+
// is the only rule in this validator that repairs rather than rejects, and it earns it by being
|
|
483
|
+
// information-preserving — contrast the group-not-in-disease-groups rule two blocks down, where
|
|
484
|
+
// picking a replacement would be a clinical guess.
|
|
485
|
+
const byGroup = new Map<string, { group: string; markers: unknown[] }>();
|
|
486
|
+
for (const g of r.healthMarkers.recommended) {
|
|
487
|
+
const groupKey = g?.group?.trim();
|
|
488
|
+
if (!groupKey) continue;
|
|
489
|
+
const prior = byGroup.get(groupKey);
|
|
490
|
+
if (prior) prior.markers.push(...(Array.isArray(g.markers) ? g.markers : []));
|
|
491
|
+
else byGroup.set(groupKey, g as { group: string; markers: unknown[] });
|
|
492
|
+
}
|
|
493
|
+
if (byGroup.size !== r.healthMarkers.recommended.filter((g) => g?.group?.trim()).length) {
|
|
494
|
+
r.healthMarkers.recommended = [...byGroup.values()] as typeof r.healthMarkers.recommended;
|
|
495
|
+
}
|
|
496
|
+
for (const [i, g] of r.healthMarkers.recommended.entries()) {
|
|
497
|
+
if (!g || typeof g.group !== "string" || g.group.trim().length === 0) {
|
|
498
|
+
throw new Error(`healthMarkers.recommended[${i}].group missing`);
|
|
499
|
+
}
|
|
500
|
+
if (!Array.isArray(g.markers)) {
|
|
501
|
+
throw new Error(`healthMarkers.recommended[${i}] (${g.group}) markers not an array`);
|
|
502
|
+
}
|
|
503
|
+
for (const [j, m] of g.markers.entries()) {
|
|
504
|
+
if (!m || typeof m.name !== "string" || m.name.trim().length === 0) {
|
|
505
|
+
throw new Error(`healthMarkers.recommended[${i}][${j}].name missing`);
|
|
506
|
+
}
|
|
507
|
+
if (typeof m.rationale !== "string" || m.rationale.trim().length === 0) {
|
|
508
|
+
throw new Error(`healthMarkers.recommended[${i}][${j}] (${m.name}) rationale missing`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!Array.isArray(r.dataRequisition)) {
|
|
513
|
+
throw new Error(`dataRequisition missing or not an array`);
|
|
514
|
+
}
|
|
515
|
+
for (const [i, g] of r.dataRequisition.entries()) {
|
|
516
|
+
if (!g || typeof g.type !== "string" || g.type.trim().length === 0) {
|
|
517
|
+
throw new Error(`dataRequisition[${i}].type missing`);
|
|
518
|
+
}
|
|
519
|
+
if (typeof g.group !== "string" || g.group.trim().length === 0) {
|
|
520
|
+
throw new Error(`dataRequisition[${i}] (${g.type}) group missing`);
|
|
521
|
+
}
|
|
522
|
+
if (!diseaseGroupSet.has(g.group.trim())) {
|
|
523
|
+
throw new Error(`dataRequisition[${i}] (${g.type}) group "${g.group}" is not one of the disease groups`);
|
|
524
|
+
}
|
|
525
|
+
if (!Array.isArray(g.items)) {
|
|
526
|
+
throw new Error(`dataRequisition[${i}] (${g.type}) items not an array`);
|
|
527
|
+
}
|
|
528
|
+
// assembleFinding silently .filter()s an items-empty group out (see the dataRequisition
|
|
529
|
+
// build below), so the model's mistake vanished instead of being corrected. Checked HERE, inside
|
|
530
|
+
// validate(), so the core's correction loop can actually fix it.
|
|
531
|
+
if (g.items.length === 0) {
|
|
532
|
+
throw new Error(`dataRequisition[${i}] (${g.type}) has no items — omit the group entirely instead`);
|
|
533
|
+
}
|
|
534
|
+
for (const [j, s] of g.items.entries()) {
|
|
535
|
+
if (typeof s !== "string" || s.trim().length === 0) {
|
|
536
|
+
throw new Error(`dataRequisition[${i}][${j}] missing`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// planAssessment (AI on Plan) is empty when the patient gave no Patient
|
|
541
|
+
// Plan to reason about; otherwise it's a substantive paragraph.
|
|
542
|
+
if (typeof r.planAssessment !== "string") {
|
|
543
|
+
throw new Error(`planAssessment missing or not a string`);
|
|
544
|
+
}
|
|
545
|
+
// planAssessmentRows — one AI assessment per Patient Plan Action. Coverage/membership are
|
|
546
|
+
// enforced only when the plan actions are supplied (skipped by the structural-only wrapper).
|
|
547
|
+
if (r.planAssessmentRows !== undefined && !Array.isArray(r.planAssessmentRows)) {
|
|
548
|
+
throw new Error(`planAssessmentRows missing or not an array`);
|
|
549
|
+
}
|
|
550
|
+
for (const [i, row] of (r.planAssessmentRows ?? []).entries()) {
|
|
551
|
+
if (!row || typeof row.action !== "string" || row.action.trim().length === 0) {
|
|
552
|
+
throw new Error(`planAssessmentRows[${i}].action missing`);
|
|
553
|
+
}
|
|
554
|
+
if (typeof row.assessment !== "string" || row.assessment.trim().length === 0) {
|
|
555
|
+
throw new Error(`planAssessmentRows[${i}] (${row.action}) assessment missing`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (expected?.planActions) {
|
|
559
|
+
const seen = new Set<string>();
|
|
560
|
+
for (const [i, row] of (r.planAssessmentRows ?? []).entries()) {
|
|
561
|
+
const a = row.action.trim();
|
|
562
|
+
if (!expected.planActions.includes(a)) {
|
|
563
|
+
throw new Error(`planAssessmentRows[${i}] action "${row.action}" matches no Patient Plan action`);
|
|
564
|
+
}
|
|
565
|
+
if (seen.has(a)) throw new Error(`planAssessmentRows action "${a}" appears more than once`);
|
|
566
|
+
seen.add(a);
|
|
567
|
+
}
|
|
568
|
+
// Coverage only when the core actually wrote the rows. aiOnPlan owns them now, so an absent
|
|
569
|
+
// planAssessmentRows means "the leaf will fill it", not "every plan action went unassessed".
|
|
570
|
+
if (r.planAssessmentRows !== undefined) {
|
|
571
|
+
for (const a of expected.planActions) {
|
|
572
|
+
if (!seen.has(a)) throw new Error(`Patient Plan action "${a}" is not assessed in planAssessmentRows`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// finalThoughts (the whole-report reflection) is always expected — it does
|
|
577
|
+
// not depend on a Patient Plan existing.
|
|
578
|
+
if (typeof r.finalThoughts !== "string" || r.finalThoughts.trim().length === 0) {
|
|
579
|
+
throw new Error(`finalThoughts missing`);
|
|
580
|
+
}
|
|
581
|
+
// patternAntipattern (the patient-specific Pattern and Antipattern section)
|
|
582
|
+
// is always expected — both prose halves required.
|
|
583
|
+
if (!r.patternAntipattern || typeof r.patternAntipattern !== "object" || Array.isArray(r.patternAntipattern)) {
|
|
584
|
+
throw new Error(`patternAntipattern missing or not an object with { pattern, antipattern }`);
|
|
585
|
+
}
|
|
586
|
+
if (typeof r.patternAntipattern.pattern !== "string" || r.patternAntipattern.pattern.trim().length === 0) {
|
|
587
|
+
throw new Error(`patternAntipattern.pattern missing`);
|
|
588
|
+
}
|
|
589
|
+
if (typeof r.patternAntipattern.antipattern !== "string" || r.patternAntipattern.antipattern.trim().length === 0) {
|
|
590
|
+
throw new Error(`patternAntipattern.antipattern missing`);
|
|
591
|
+
}
|
|
592
|
+
// clinicalSynthesis (the two-track trajectory section): adverse + favorable
|
|
593
|
+
// always required; conditioning is optional (may be "" when the data does
|
|
594
|
+
// not support a biological-age read).
|
|
595
|
+
if (!r.clinicalSynthesis || typeof r.clinicalSynthesis !== "object" || Array.isArray(r.clinicalSynthesis)) {
|
|
596
|
+
throw new Error(`clinicalSynthesis missing or not an object with { adverse, favorable }`);
|
|
597
|
+
}
|
|
598
|
+
if (typeof r.clinicalSynthesis.adverse !== "string" || r.clinicalSynthesis.adverse.trim().length === 0) {
|
|
599
|
+
throw new Error(`clinicalSynthesis.adverse missing`);
|
|
600
|
+
}
|
|
601
|
+
if (typeof r.clinicalSynthesis.favorable !== "string" || r.clinicalSynthesis.favorable.trim().length === 0) {
|
|
602
|
+
throw new Error(`clinicalSynthesis.favorable missing`);
|
|
603
|
+
}
|
|
604
|
+
if (r.clinicalSynthesis.conditioning != null && typeof r.clinicalSynthesis.conditioning !== "string") {
|
|
605
|
+
throw new Error(`clinicalSynthesis.conditioning must be a string when present`);
|
|
606
|
+
}
|
|
607
|
+
// criticalRatios: 2–8 marker ratios, each with non-empty name / components /
|
|
608
|
+
// meaning / explanations; range bounds are optional numbers.
|
|
609
|
+
if (!Array.isArray(r.criticalRatios) || r.criticalRatios.length < 2) {
|
|
610
|
+
throw new Error(`criticalRatios must be an array of at least 2 entries`);
|
|
611
|
+
}
|
|
612
|
+
if (r.criticalRatios.length > 8) {
|
|
613
|
+
throw new Error(`criticalRatios has more than 8 entries`);
|
|
614
|
+
}
|
|
615
|
+
for (const [i, cr] of r.criticalRatios.entries()) {
|
|
616
|
+
for (const k of ["name", "numerator", "denominator", "meaning", "generalExplanation", "explanation"] as const) {
|
|
617
|
+
if (typeof cr[k] !== "string" || cr[k].trim().length === 0) {
|
|
618
|
+
throw new Error(`criticalRatios[${i}].${k} missing`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (typeof cr.unit !== "string") {
|
|
622
|
+
throw new Error(`criticalRatios[${i}].unit must be a string ("" for dimensionless)`);
|
|
623
|
+
}
|
|
624
|
+
for (const k of ["generalLow", "generalHigh", "personalizedLow", "personalizedHigh"] as const) {
|
|
625
|
+
if (cr[k] != null && typeof cr[k] !== "number") {
|
|
626
|
+
throw new Error(`criticalRatios[${i}].${k} must be a number when present`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// The LLM emits basis as an array of {key, text} (the strict json-schema
|
|
631
|
+
// grammar limit on the object form was rejecting the 14-key shape). We
|
|
632
|
+
// accept either shape — the typed parser collapses the array form into
|
|
633
|
+
// an object before persisting, but validate runs against the raw response.
|
|
634
|
+
if (!r.basis) {
|
|
635
|
+
throw new Error("basis missing");
|
|
636
|
+
}
|
|
637
|
+
const basisMap = new Map<string, string>();
|
|
638
|
+
if (Array.isArray(r.basis)) {
|
|
639
|
+
for (const entry of r.basis as { key: string; text: string }[]) {
|
|
640
|
+
if (!entry || typeof entry.key !== "string" || typeof entry.text !== "string") {
|
|
641
|
+
throw new Error(`basis entries must be { key, text } pairs`);
|
|
642
|
+
}
|
|
643
|
+
basisMap.set(entry.key.trim(), entry.text);
|
|
644
|
+
}
|
|
645
|
+
} else if (typeof r.basis === "object") {
|
|
646
|
+
for (const [key, value] of Object.entries(r.basis as Record<string, unknown>)) {
|
|
647
|
+
if (typeof value !== "string") {
|
|
648
|
+
throw new Error(`basis.${key} must be a string`);
|
|
649
|
+
}
|
|
650
|
+
basisMap.set(key, value);
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
throw new Error("basis must be an array of {key, text} or an object");
|
|
654
|
+
}
|
|
655
|
+
const leafOwned = new Set<string>(LEAF_OWNED_BASIS_KEYS);
|
|
656
|
+
for (const key of BASIS_KEYS) {
|
|
657
|
+
if (leafOwned.has(key)) continue; // stamped by the leaf that writes the section, not by the core
|
|
658
|
+
const value = basisMap.get(key);
|
|
659
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
660
|
+
throw new Error(`basis.${key} missing`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Assemble a validated FindingAIResponse into a ClientFinding. The mapping is the exact
|
|
666
|
+
// tail of the old generateFinding (trim/filter/sort); the hashes + timestamp + generatedBy are
|
|
667
|
+
// INJECTED so a host CLI (node:crypto findingInputsHashOf/nodeHashesOf) and a browser (SubtleCrypto
|
|
668
|
+
// twins in staleness.ts) produce an identical finding.
|
|
669
|
+
export function assembleFinding(
|
|
670
|
+
parsed: FindingAIResponse,
|
|
671
|
+
meta: {
|
|
672
|
+
generatedAt: string;
|
|
673
|
+
inputsHash: string;
|
|
674
|
+
nodeHashes: Record<string, string>;
|
|
675
|
+
generatedBy: ClientFinding["generatedBy"];
|
|
676
|
+
// M92 — the ordered ids of the Note entries the prompt presented (populatedNoteEntries), zipped
|
|
677
|
+
// positionally against parsed.noteResults since a note has no label the LLM could echo back.
|
|
678
|
+
noteIds: string[];
|
|
679
|
+
// 06/Gap A — injected rather than looked up here, so this module carries no dependency on
|
|
680
|
+
// finding-config/brain-versions (both span brains outside the reasoning core's own scope).
|
|
681
|
+
promptVersions: ClientFinding["promptVersions"];
|
|
682
|
+
},
|
|
683
|
+
): ClientFinding {
|
|
684
|
+
const definitions = parsed.definitions
|
|
685
|
+
.map((d) => ({ term: d.term.trim(), definition: d.definition.trim(), group: d.group?.trim() ?? "" }))
|
|
686
|
+
.filter((d) => d.term.length > 0 && d.definition.length > 0)
|
|
687
|
+
.sort((a, b) => a.term.localeCompare(b.term));
|
|
688
|
+
const healthMarkers = {
|
|
689
|
+
recommended: parsed.healthMarkers.recommended.map((g) => ({
|
|
690
|
+
group: g.group.trim(),
|
|
691
|
+
markers: g.markers
|
|
692
|
+
.map((m) => ({ name: m.name.trim(), rationale: m.rationale.trim() }))
|
|
693
|
+
.filter((m) => m.name.length > 0 && m.rationale.length > 0),
|
|
694
|
+
})),
|
|
695
|
+
};
|
|
696
|
+
const dataRequisition = parsed.dataRequisition
|
|
697
|
+
.map((g) => ({
|
|
698
|
+
type: g.type.trim(),
|
|
699
|
+
group: g.group?.trim() ?? "",
|
|
700
|
+
items: g.items.map((s) => s.trim()).filter((s) => s.length > 0),
|
|
701
|
+
}))
|
|
702
|
+
.filter((g) => g.type.length > 0 && g.items.length > 0);
|
|
703
|
+
return {
|
|
704
|
+
progression: {
|
|
705
|
+
latest: parsed.progression.latest.trim(),
|
|
706
|
+
recent: parsed.progression.recent.trim(),
|
|
707
|
+
overall: parsed.progression.overall.trim(),
|
|
708
|
+
},
|
|
709
|
+
// `?? []` on all four: the core no longer narrates them, and the leaf that owns each one
|
|
710
|
+
// merges its own entries in afterwards. An older stored response still carries them.
|
|
711
|
+
studyResults: (parsed.studyResults ?? [])
|
|
712
|
+
.map((s) => ({ study: s.study.trim(), result: s.result.trim(), group: s.group?.trim() ?? "" }))
|
|
713
|
+
.filter((s) => s.study.length > 0 && s.result.length > 0),
|
|
714
|
+
noteResults: (parsed.noteResults ?? [])
|
|
715
|
+
.map((n, i) => ({ noteId: meta.noteIds[i] ?? "", result: n.result.trim(), group: n.group?.trim() ?? "" }))
|
|
716
|
+
.filter((n) => n.noteId.length > 0 && n.result.length > 0),
|
|
717
|
+
disease: parsed.disease.map((d) => ({ group: d.group.trim(), finding: d.finding.trim() })),
|
|
718
|
+
treatment: (parsed.treatment ?? []).map((t) => ({ item: t.item.trim(), assessment: t.assessment.trim(), group: t.group?.trim() ?? "" })),
|
|
719
|
+
patternAntipattern: {
|
|
720
|
+
pattern: parsed.patternAntipattern.pattern.trim(),
|
|
721
|
+
antipattern: parsed.patternAntipattern.antipattern.trim(),
|
|
722
|
+
},
|
|
723
|
+
clinicalSynthesis: {
|
|
724
|
+
adverse: parsed.clinicalSynthesis.adverse.trim(),
|
|
725
|
+
favorable: parsed.clinicalSynthesis.favorable.trim(),
|
|
726
|
+
conditioning: parsed.clinicalSynthesis.conditioning?.trim() || undefined,
|
|
727
|
+
},
|
|
728
|
+
criticalRatios: parsed.criticalRatios.map((r) => ({
|
|
729
|
+
name: r.name.trim(),
|
|
730
|
+
numerator: r.numerator.trim(),
|
|
731
|
+
denominator: r.denominator.trim(),
|
|
732
|
+
unit: r.unit.trim(),
|
|
733
|
+
meaning: r.meaning.trim(),
|
|
734
|
+
generalLow: r.generalLow,
|
|
735
|
+
generalHigh: r.generalHigh,
|
|
736
|
+
generalExplanation: r.generalExplanation.trim(),
|
|
737
|
+
personalizedLow: r.personalizedLow,
|
|
738
|
+
personalizedHigh: r.personalizedHigh,
|
|
739
|
+
explanation: r.explanation.trim(),
|
|
740
|
+
})),
|
|
741
|
+
decisions: {
|
|
742
|
+
patient: parsed.decisions.patient.map(parseFindingDecision),
|
|
743
|
+
ai: parsed.decisions.ai.map(parseFindingDecision),
|
|
744
|
+
},
|
|
745
|
+
doctorConversation: parsed.doctorConversation.map((g) => ({
|
|
746
|
+
group: g.group.trim(),
|
|
747
|
+
questions: g.questions.map((q) => q.trim()).filter((q) => q.length > 0),
|
|
748
|
+
})),
|
|
749
|
+
definitions,
|
|
750
|
+
healthMarkers,
|
|
751
|
+
dataRequisition,
|
|
752
|
+
treatmentGroups: (parsed.treatmentGroups ?? []).map((g) => ({
|
|
753
|
+
system: g.system.trim(),
|
|
754
|
+
topic: g.topic.trim(),
|
|
755
|
+
patient: g.patient.map((s) => s.trim()).filter((s) => s.length > 0),
|
|
756
|
+
ai: g.ai.map((s) => s.trim()).filter((s) => s.length > 0),
|
|
757
|
+
})),
|
|
758
|
+
planAssessment: parsed.planAssessment.trim(),
|
|
759
|
+
planAssessmentRows: (parsed.planAssessmentRows ?? [])
|
|
760
|
+
.map((r) => ({ action: r.action.trim(), assessment: r.assessment.trim() }))
|
|
761
|
+
.filter((r) => r.action.length > 0 && r.assessment.length > 0),
|
|
762
|
+
finalThoughts: parsed.finalThoughts.trim(),
|
|
763
|
+
basis: basisFromResponse(parsed.basis),
|
|
764
|
+
generatedAt: meta.generatedAt,
|
|
765
|
+
inputsHash: meta.inputsHash,
|
|
766
|
+
nodeHashes: meta.nodeHashes,
|
|
767
|
+
// Only `core`, and only the core. The leaf-owned sections this object leaves empty are
|
|
768
|
+
// written later by mergeLeafResult, which stamps its own key; claiming them here would attribute
|
|
769
|
+
// nine sections to a brain that never saw them.
|
|
770
|
+
promptVersions: meta.promptVersions,
|
|
771
|
+
generatedBy: meta.generatedBy,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// The finding is no longer grammar-constrained, so tolerate a stray code
|
|
776
|
+
// fence or surrounding prose. Extract the FIRST complete (brace-balanced) JSON
|
|
777
|
+
// object — slicing to the last "}" over-grabs when the model appends extra
|
|
778
|
+
// content after the object (observed: "Unexpected non-whitespace after JSON"),
|
|
779
|
+
// which then fails to parse. Braces inside string literals are ignored.
|
|
780
|
+
export function extractJson(text: string): string {
|
|
781
|
+
let t = text.trim();
|
|
782
|
+
const fence = t.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
|
|
783
|
+
if (fence) t = fence[1].trim();
|
|
784
|
+
const first = t.indexOf("{");
|
|
785
|
+
if (first < 0) return t;
|
|
786
|
+
let depth = 0;
|
|
787
|
+
let inStr = false;
|
|
788
|
+
let esc = false;
|
|
789
|
+
for (let i = first; i < t.length; i++) {
|
|
790
|
+
const ch = t[i];
|
|
791
|
+
if (inStr) {
|
|
792
|
+
if (esc) esc = false;
|
|
793
|
+
else if (ch === "\\") esc = true;
|
|
794
|
+
else if (ch === '"') inStr = false;
|
|
795
|
+
} else if (ch === '"') inStr = true;
|
|
796
|
+
else if (ch === "{") depth++;
|
|
797
|
+
else if (ch === "}") {
|
|
798
|
+
depth--;
|
|
799
|
+
if (depth === 0) return t.slice(first, i + 1);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return t.slice(first); // unbalanced — let JSON.parse surface the error
|
|
803
|
+
}
|