justlabs-mcp-server 1.0.0 → 1.1.0
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/dist/__tests__/analysis.test.d.ts +5 -0
- package/dist/__tests__/analysis.test.js +203 -0
- package/dist/analysis/analyzePanelResults.d.ts +45 -0
- package/dist/analysis/analyzePanelResults.js +137 -0
- package/dist/analysis/generatePatientSummary.d.ts +25 -0
- package/dist/analysis/generatePatientSummary.js +94 -0
- package/dist/analysis/suggestNextSteps.d.ts +37 -0
- package/dist/analysis/suggestNextSteps.js +136 -0
- package/dist/index.js +164 -0
- package/dist/ranges/panels.d.ts +28 -0
- package/dist/ranges/panels.js +115 -0
- package/dist/ranges/questRanges.d.ts +53 -0
- package/dist/ranges/questRanges.js +335 -0
- package/package.json +2 -2
- package/src/__tests__/analysis.test.ts +235 -0
- package/src/analysis/analyzePanelResults.ts +205 -0
- package/src/analysis/generatePatientSummary.ts +131 -0
- package/src/analysis/suggestNextSteps.ts +183 -0
- package/src/index.ts +180 -0
- package/src/ranges/panels.ts +145 -0
- package/src/ranges/questRanges.ts +403 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic tests for analysis modules
|
|
3
|
+
* Run with: npx ts-node src/__tests__/analysis.test.ts
|
|
4
|
+
*/
|
|
5
|
+
import { classifyValue, checkCriticalValue, getReferenceRange, getApplicableRange } from "../ranges/questRanges.js";
|
|
6
|
+
import { analyzePanelResults } from "../analysis/analyzePanelResults.js";
|
|
7
|
+
import { suggestNextSteps } from "../analysis/suggestNextSteps.js";
|
|
8
|
+
import { generatePatientSummary } from "../analysis/generatePatientSummary.js";
|
|
9
|
+
let passed = 0;
|
|
10
|
+
let failed = 0;
|
|
11
|
+
function test(name, fn) {
|
|
12
|
+
try {
|
|
13
|
+
fn();
|
|
14
|
+
console.log(`✓ ${name}`);
|
|
15
|
+
passed++;
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
console.log(`✗ ${name}`);
|
|
19
|
+
console.log(` Error: ${e.message}`);
|
|
20
|
+
failed++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function expect(actual) {
|
|
24
|
+
return {
|
|
25
|
+
toBe(expected) {
|
|
26
|
+
if (actual !== expected) {
|
|
27
|
+
throw new Error(`Expected ${expected}, got ${actual}`);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
toBeTruthy() {
|
|
31
|
+
if (!actual) {
|
|
32
|
+
throw new Error(`Expected truthy value, got ${actual}`);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
toBeFalsy() {
|
|
36
|
+
if (actual) {
|
|
37
|
+
throw new Error(`Expected falsy value, got ${actual}`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
toContain(expected) {
|
|
41
|
+
if (typeof actual !== "string" || !actual.includes(expected)) {
|
|
42
|
+
throw new Error(`Expected "${actual}" to contain "${expected}"`);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
console.log("\n=== Range Classification Tests ===\n");
|
|
48
|
+
test("classifies value below range as low", () => {
|
|
49
|
+
const result = classifyValue(0.3, { low: 0.45, high: 4.5, unit: "mIU/L" });
|
|
50
|
+
expect(result).toBe("low");
|
|
51
|
+
});
|
|
52
|
+
test("classifies value above range as high", () => {
|
|
53
|
+
const result = classifyValue(5.0, { low: 0.45, high: 4.5, unit: "mIU/L" });
|
|
54
|
+
expect(result).toBe("high");
|
|
55
|
+
});
|
|
56
|
+
test("classifies value within range as normal", () => {
|
|
57
|
+
const result = classifyValue(2.5, { low: 0.45, high: 4.5, unit: "mIU/L" });
|
|
58
|
+
expect(result).toBe("normal");
|
|
59
|
+
});
|
|
60
|
+
test("classifies value with only upper bound", () => {
|
|
61
|
+
const result = classifyValue(50, { high: 34, unit: "IU/mL" });
|
|
62
|
+
expect(result).toBe("high");
|
|
63
|
+
});
|
|
64
|
+
console.log("\n=== Critical Value Detection Tests ===\n");
|
|
65
|
+
test("detects critical high glucose", () => {
|
|
66
|
+
const result = checkCriticalValue("glucose", 450);
|
|
67
|
+
expect(result.isCritical).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
test("does not flag normal glucose", () => {
|
|
70
|
+
const result = checkCriticalValue("glucose", 95);
|
|
71
|
+
expect(result.isCritical).toBeFalsy();
|
|
72
|
+
});
|
|
73
|
+
test("detects critical low potassium", () => {
|
|
74
|
+
const result = checkCriticalValue("potassium", 2.3);
|
|
75
|
+
expect(result.isCritical).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
test("detects critical high TSH", () => {
|
|
78
|
+
const result = checkCriticalValue("tsh", 60);
|
|
79
|
+
expect(result.isCritical).toBeTruthy();
|
|
80
|
+
});
|
|
81
|
+
console.log("\n=== Sex-Specific Range Tests ===\n");
|
|
82
|
+
test("returns male-specific ferritin range", () => {
|
|
83
|
+
const biomarker = getReferenceRange("ferritin");
|
|
84
|
+
expect(biomarker).toBeTruthy();
|
|
85
|
+
const range = getApplicableRange(biomarker, "male");
|
|
86
|
+
expect(range.high).toBe(400);
|
|
87
|
+
});
|
|
88
|
+
test("returns female-specific ferritin range", () => {
|
|
89
|
+
const biomarker = getReferenceRange("ferritin");
|
|
90
|
+
const range = getApplicableRange(biomarker, "female");
|
|
91
|
+
expect(range.high).toBe(200);
|
|
92
|
+
});
|
|
93
|
+
console.log("\n=== Panel Analysis Tests ===\n");
|
|
94
|
+
test("analyzes fatigue panel with normal results", () => {
|
|
95
|
+
const analysis = analyzePanelResults({
|
|
96
|
+
panel_id: "fatigue_panel",
|
|
97
|
+
results: [
|
|
98
|
+
{ biomarker_key: "tsh", value: 2.0, unit: "mIU/L" },
|
|
99
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
100
|
+
{ biomarker_key: "vitamin-b12", value: 500, unit: "pg/mL" },
|
|
101
|
+
{ biomarker_key: "vitamin-d", value: 45, unit: "ng/mL" },
|
|
102
|
+
{ biomarker_key: "hemoglobin", value: 14, unit: "g/dL" },
|
|
103
|
+
{ biomarker_key: "wbc", value: 7.0, unit: "K/uL" },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
expect(analysis.summary.overall_status).toBe("all_normal");
|
|
107
|
+
expect(analysis.red_flags_detected).toBeFalsy();
|
|
108
|
+
});
|
|
109
|
+
test("detects abnormal TSH in fatigue panel", () => {
|
|
110
|
+
const analysis = analyzePanelResults({
|
|
111
|
+
panel_id: "fatigue_panel",
|
|
112
|
+
results: [
|
|
113
|
+
{ biomarker_key: "tsh", value: 8.0, unit: "mIU/L" },
|
|
114
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
115
|
+
{ biomarker_key: "vitamin-b12", value: 500, unit: "pg/mL" },
|
|
116
|
+
{ biomarker_key: "vitamin-d", value: 45, unit: "ng/mL" },
|
|
117
|
+
{ biomarker_key: "hemoglobin", value: 14, unit: "g/dL" },
|
|
118
|
+
{ biomarker_key: "wbc", value: 7.0, unit: "K/uL" },
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
expect(analysis.summary.overall_status).toBe("some_outside_range");
|
|
122
|
+
expect(analysis.key_findings.length).toBe(1);
|
|
123
|
+
expect(analysis.key_findings[0].biomarker_key).toBe("tsh");
|
|
124
|
+
});
|
|
125
|
+
test("stops analysis on critical value", () => {
|
|
126
|
+
const analysis = analyzePanelResults({
|
|
127
|
+
panel_id: "fatigue_panel",
|
|
128
|
+
results: [
|
|
129
|
+
{ biomarker_key: "tsh", value: 60, unit: "mIU/L" }, // Critical high
|
|
130
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
expect(analysis.red_flags_detected).toBeTruthy();
|
|
134
|
+
expect(analysis.summary.overall_status).toBe("critical");
|
|
135
|
+
});
|
|
136
|
+
console.log("\n=== Next Steps Suggestion Tests ===\n");
|
|
137
|
+
test("suggests thyroid deep dive for abnormal TSH", () => {
|
|
138
|
+
const analysis = analyzePanelResults({
|
|
139
|
+
panel_id: "fatigue_panel",
|
|
140
|
+
results: [
|
|
141
|
+
{ biomarker_key: "tsh", value: 8.0, unit: "mIU/L" },
|
|
142
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
143
|
+
{ biomarker_key: "vitamin-b12", value: 500, unit: "pg/mL" },
|
|
144
|
+
{ biomarker_key: "vitamin-d", value: 45, unit: "ng/mL" },
|
|
145
|
+
{ biomarker_key: "hemoglobin", value: 14, unit: "g/dL" },
|
|
146
|
+
{ biomarker_key: "wbc", value: 7.0, unit: "K/uL" },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
const nextSteps = suggestNextSteps("fatigue_panel", analysis);
|
|
150
|
+
expect(nextSteps.next_step.type).toBe("additional_testing");
|
|
151
|
+
if (nextSteps.next_step.type === "additional_testing") {
|
|
152
|
+
expect(nextSteps.next_step.recommended_panel_id).toBe("thyroid_deep_dive");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
test("suggests clinician discussion for all normal results", () => {
|
|
156
|
+
const analysis = analyzePanelResults({
|
|
157
|
+
panel_id: "fatigue_panel",
|
|
158
|
+
results: [
|
|
159
|
+
{ biomarker_key: "tsh", value: 2.0, unit: "mIU/L" },
|
|
160
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
161
|
+
{ biomarker_key: "vitamin-b12", value: 500, unit: "pg/mL" },
|
|
162
|
+
{ biomarker_key: "vitamin-d", value: 45, unit: "ng/mL" },
|
|
163
|
+
{ biomarker_key: "hemoglobin", value: 14, unit: "g/dL" },
|
|
164
|
+
{ biomarker_key: "wbc", value: 7.0, unit: "K/uL" },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
const nextSteps = suggestNextSteps("fatigue_panel", analysis);
|
|
168
|
+
expect(nextSteps.next_step.type).toBe("clinician_discussion");
|
|
169
|
+
});
|
|
170
|
+
console.log("\n=== Patient Summary Tests ===\n");
|
|
171
|
+
test("generates summary with disclaimer", () => {
|
|
172
|
+
const analysis = analyzePanelResults({
|
|
173
|
+
panel_id: "fatigue_panel",
|
|
174
|
+
results: [
|
|
175
|
+
{ biomarker_key: "tsh", value: 2.0, unit: "mIU/L" },
|
|
176
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
const nextSteps = suggestNextSteps("fatigue_panel", analysis);
|
|
180
|
+
const summary = generatePatientSummary("fatigue_panel", analysis, nextSteps);
|
|
181
|
+
expect(summary.patient_summary.disclaimer).toContain("educational purposes only");
|
|
182
|
+
expect(summary.patient_summary.requires_acknowledgment).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
test("includes CTA only for additional testing", () => {
|
|
185
|
+
const analysis = analyzePanelResults({
|
|
186
|
+
panel_id: "fatigue_panel",
|
|
187
|
+
results: [
|
|
188
|
+
{ biomarker_key: "tsh", value: 8.0, unit: "mIU/L" }, // Abnormal
|
|
189
|
+
{ biomarker_key: "ferritin", value: 80, unit: "ng/mL" },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
const nextSteps = suggestNextSteps("fatigue_panel", analysis);
|
|
193
|
+
const summary = generatePatientSummary("fatigue_panel", analysis, nextSteps);
|
|
194
|
+
expect(summary.patient_summary.cta).toBeTruthy();
|
|
195
|
+
expect(summary.patient_summary.cta?.url).toContain("justlabs.health");
|
|
196
|
+
});
|
|
197
|
+
// Summary
|
|
198
|
+
console.log("\n=================================");
|
|
199
|
+
console.log(`Tests: ${passed} passed, ${failed} failed`);
|
|
200
|
+
console.log("=================================\n");
|
|
201
|
+
if (failed > 0) {
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyze panel results - educational context only, no diagnostic language
|
|
3
|
+
*/
|
|
4
|
+
export interface ResultInput {
|
|
5
|
+
biomarker_key: string;
|
|
6
|
+
value: number;
|
|
7
|
+
unit: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AnalysisInput {
|
|
10
|
+
panel_id: string;
|
|
11
|
+
results: ResultInput[];
|
|
12
|
+
sex_at_birth?: "male" | "female";
|
|
13
|
+
age?: number;
|
|
14
|
+
fasting?: boolean;
|
|
15
|
+
on_thyroid_meds?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface Finding {
|
|
18
|
+
biomarker_key: string;
|
|
19
|
+
display_name: string;
|
|
20
|
+
value: number;
|
|
21
|
+
unit: string;
|
|
22
|
+
status: "low" | "high" | "normal" | "unknown";
|
|
23
|
+
reference_range: string;
|
|
24
|
+
educational_context: string;
|
|
25
|
+
note?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface AnalysisOutput {
|
|
28
|
+
panel_id: string;
|
|
29
|
+
panel_name: string;
|
|
30
|
+
summary: {
|
|
31
|
+
overall_status: "all_normal" | "some_outside_range" | "needs_attention" | "critical";
|
|
32
|
+
headline: string;
|
|
33
|
+
};
|
|
34
|
+
key_findings: Finding[];
|
|
35
|
+
reassuring_findings: string[];
|
|
36
|
+
missing_required_markers: string[];
|
|
37
|
+
red_flags_detected: boolean;
|
|
38
|
+
red_flag_message: string | null;
|
|
39
|
+
disclaimer_acknowledged: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Analyze panel results with educational context
|
|
43
|
+
* Returns structured findings without diagnostic language
|
|
44
|
+
*/
|
|
45
|
+
export declare function analyzePanelResults(input: AnalysisInput): AnalysisOutput;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyze panel results - educational context only, no diagnostic language
|
|
3
|
+
*/
|
|
4
|
+
import { getReferenceRange, getApplicableRange, classifyValue, checkCriticalValue, formatReferenceRange, } from "../ranges/questRanges.js";
|
|
5
|
+
import { getPanel } from "../ranges/panels.js";
|
|
6
|
+
/**
|
|
7
|
+
* Analyze panel results with educational context
|
|
8
|
+
* Returns structured findings without diagnostic language
|
|
9
|
+
*/
|
|
10
|
+
export function analyzePanelResults(input) {
|
|
11
|
+
const panel = getPanel(input.panel_id);
|
|
12
|
+
if (!panel) {
|
|
13
|
+
return {
|
|
14
|
+
panel_id: input.panel_id,
|
|
15
|
+
panel_name: "Unknown Panel",
|
|
16
|
+
summary: {
|
|
17
|
+
overall_status: "needs_attention",
|
|
18
|
+
headline: "Unable to analyze: panel not found",
|
|
19
|
+
},
|
|
20
|
+
key_findings: [],
|
|
21
|
+
reassuring_findings: [],
|
|
22
|
+
missing_required_markers: [],
|
|
23
|
+
red_flags_detected: false,
|
|
24
|
+
red_flag_message: null,
|
|
25
|
+
disclaimer_acknowledged: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Check for critical values first
|
|
29
|
+
const criticalFindings = [];
|
|
30
|
+
for (const result of input.results) {
|
|
31
|
+
const critical = checkCriticalValue(result.biomarker_key, result.value);
|
|
32
|
+
if (critical.isCritical && critical.message) {
|
|
33
|
+
criticalFindings.push({
|
|
34
|
+
key: result.biomarker_key,
|
|
35
|
+
message: critical.message,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (criticalFindings.length > 0) {
|
|
40
|
+
return {
|
|
41
|
+
panel_id: input.panel_id,
|
|
42
|
+
panel_name: panel.name,
|
|
43
|
+
summary: {
|
|
44
|
+
overall_status: "critical",
|
|
45
|
+
headline: "Important: Please contact a healthcare provider promptly",
|
|
46
|
+
},
|
|
47
|
+
key_findings: [],
|
|
48
|
+
reassuring_findings: [],
|
|
49
|
+
missing_required_markers: [],
|
|
50
|
+
red_flags_detected: true,
|
|
51
|
+
red_flag_message: `One or more values require prompt medical attention. ${criticalFindings.map((f) => f.message).join(" ")} Please contact your healthcare provider or seek medical care.`,
|
|
52
|
+
disclaimer_acknowledged: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Check for missing required markers
|
|
56
|
+
const providedKeys = input.results.map((r) => r.biomarker_key.toLowerCase());
|
|
57
|
+
const missingMarkers = panel.requiredMarkers.filter((m) => !providedKeys.includes(m.toLowerCase()));
|
|
58
|
+
// Analyze each result
|
|
59
|
+
const keyFindings = [];
|
|
60
|
+
const reassuringFindings = [];
|
|
61
|
+
for (const result of input.results) {
|
|
62
|
+
const biomarkerRange = getReferenceRange(result.biomarker_key);
|
|
63
|
+
if (!biomarkerRange) {
|
|
64
|
+
keyFindings.push({
|
|
65
|
+
biomarker_key: result.biomarker_key,
|
|
66
|
+
display_name: result.biomarker_key,
|
|
67
|
+
value: result.value,
|
|
68
|
+
unit: result.unit,
|
|
69
|
+
status: "unknown",
|
|
70
|
+
reference_range: "Not available",
|
|
71
|
+
educational_context: "Reference range not available for this marker.",
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const applicableRange = getApplicableRange(biomarkerRange, input.sex_at_birth);
|
|
76
|
+
const status = classifyValue(result.value, applicableRange);
|
|
77
|
+
const referenceRangeStr = formatReferenceRange(applicableRange);
|
|
78
|
+
// Build educational context based on status
|
|
79
|
+
let educationalContext = biomarkerRange.educationalContext;
|
|
80
|
+
let note;
|
|
81
|
+
if (status === "low" && biomarkerRange.lowContext) {
|
|
82
|
+
educationalContext = biomarkerRange.lowContext;
|
|
83
|
+
}
|
|
84
|
+
else if (status === "high" && biomarkerRange.highContext) {
|
|
85
|
+
educationalContext = biomarkerRange.highContext;
|
|
86
|
+
}
|
|
87
|
+
// Add medication notes if relevant
|
|
88
|
+
if (input.on_thyroid_meds && ["tsh", "free-t4", "free-t3"].includes(result.biomarker_key.toLowerCase())) {
|
|
89
|
+
note = "Note: Thyroid medication can affect these values. Results are best interpreted by your prescribing clinician.";
|
|
90
|
+
}
|
|
91
|
+
// Add fasting note if relevant
|
|
92
|
+
if (!input.fasting && ["glucose", "insulin", "triglycerides"].includes(result.biomarker_key.toLowerCase())) {
|
|
93
|
+
note = "Note: This marker is ideally measured after fasting. Non-fasting values may be higher.";
|
|
94
|
+
}
|
|
95
|
+
const finding = {
|
|
96
|
+
biomarker_key: result.biomarker_key,
|
|
97
|
+
display_name: biomarkerRange.displayName,
|
|
98
|
+
value: result.value,
|
|
99
|
+
unit: result.unit,
|
|
100
|
+
status,
|
|
101
|
+
reference_range: referenceRangeStr,
|
|
102
|
+
educational_context: educationalContext,
|
|
103
|
+
note,
|
|
104
|
+
};
|
|
105
|
+
if (status === "normal") {
|
|
106
|
+
reassuringFindings.push(`${biomarkerRange.displayName} is within the expected range.`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
keyFindings.push(finding);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Determine overall status
|
|
113
|
+
let overallStatus = "all_normal";
|
|
114
|
+
let headline = "Your results are within expected ranges.";
|
|
115
|
+
if (keyFindings.length > 0) {
|
|
116
|
+
overallStatus = "some_outside_range";
|
|
117
|
+
const outsideRangeCount = keyFindings.filter((f) => f.status !== "unknown").length;
|
|
118
|
+
headline = `${outsideRangeCount} marker${outsideRangeCount !== 1 ? "s" : ""} outside the typical range. See details below.`;
|
|
119
|
+
}
|
|
120
|
+
if (missingMarkers.length > 0) {
|
|
121
|
+
headline += ` Note: ${missingMarkers.length} expected marker${missingMarkers.length !== 1 ? "s" : ""} not provided.`;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
panel_id: input.panel_id,
|
|
125
|
+
panel_name: panel.name,
|
|
126
|
+
summary: {
|
|
127
|
+
overall_status: overallStatus,
|
|
128
|
+
headline,
|
|
129
|
+
},
|
|
130
|
+
key_findings: keyFindings,
|
|
131
|
+
reassuring_findings: reassuringFindings,
|
|
132
|
+
missing_required_markers: missingMarkers,
|
|
133
|
+
red_flags_detected: false,
|
|
134
|
+
red_flag_message: null,
|
|
135
|
+
disclaimer_acknowledged: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate patient-friendly summary with required disclaimer
|
|
3
|
+
*/
|
|
4
|
+
import { AnalysisOutput } from "./analyzePanelResults.js";
|
|
5
|
+
import { NextStepsOutput } from "./suggestNextSteps.js";
|
|
6
|
+
export interface PatientSummaryCTA {
|
|
7
|
+
label: string;
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
export interface PatientSummary {
|
|
11
|
+
headline: string;
|
|
12
|
+
bullets: string[];
|
|
13
|
+
cta: PatientSummaryCTA | null;
|
|
14
|
+
disclaimer: string;
|
|
15
|
+
requires_acknowledgment: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PatientSummaryOutput {
|
|
18
|
+
patient_summary: PatientSummary;
|
|
19
|
+
safety_warning: string | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Generate a patient-friendly summary
|
|
23
|
+
* Requires disclaimer acknowledgment before showing analysis
|
|
24
|
+
*/
|
|
25
|
+
export declare function generatePatientSummary(panelId: string, analysis: AnalysisOutput, nextSteps: NextStepsOutput): PatientSummaryOutput;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate patient-friendly summary with required disclaimer
|
|
3
|
+
*/
|
|
4
|
+
const DISCLAIMER = `This summary is for educational purposes only and is not medical advice. Reference ranges may vary by lab and individual factors. Always discuss your results with a qualified healthcare provider before making any health decisions.`;
|
|
5
|
+
const SAFETY_DISCLAIMER = `IMPORTANT: One or more of your results requires prompt medical attention. Please contact your healthcare provider or seek medical care before reviewing this summary further.`;
|
|
6
|
+
/**
|
|
7
|
+
* Generate a patient-friendly summary
|
|
8
|
+
* Requires disclaimer acknowledgment before showing analysis
|
|
9
|
+
*/
|
|
10
|
+
export function generatePatientSummary(panelId, analysis, nextSteps) {
|
|
11
|
+
// Safety check first
|
|
12
|
+
if (analysis.red_flags_detected || nextSteps.safety.red_flags_detected) {
|
|
13
|
+
return {
|
|
14
|
+
patient_summary: {
|
|
15
|
+
headline: "Please contact a healthcare provider",
|
|
16
|
+
bullets: [
|
|
17
|
+
"One or more values in your results need prompt medical review.",
|
|
18
|
+
"Do not delay seeking care based on this summary.",
|
|
19
|
+
"Your healthcare provider can evaluate these findings properly.",
|
|
20
|
+
],
|
|
21
|
+
cta: null,
|
|
22
|
+
disclaimer: DISCLAIMER,
|
|
23
|
+
requires_acknowledgment: true,
|
|
24
|
+
},
|
|
25
|
+
safety_warning: nextSteps.safety.message || SAFETY_DISCLAIMER,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Build summary based on analysis and next steps
|
|
29
|
+
const bullets = [];
|
|
30
|
+
// Add headline context
|
|
31
|
+
if (analysis.summary.overall_status === "all_normal") {
|
|
32
|
+
bullets.push("All markers reviewed are within expected ranges.");
|
|
33
|
+
}
|
|
34
|
+
else if (analysis.key_findings.length > 0) {
|
|
35
|
+
bullets.push(`${analysis.key_findings.length} marker${analysis.key_findings.length !== 1 ? "s" : ""} outside the typical range.`);
|
|
36
|
+
}
|
|
37
|
+
// Add key findings (max 2)
|
|
38
|
+
const significantFindings = analysis.key_findings
|
|
39
|
+
.filter((f) => f.status === "low" || f.status === "high")
|
|
40
|
+
.slice(0, 2);
|
|
41
|
+
for (const finding of significantFindings) {
|
|
42
|
+
const direction = finding.status === "low" ? "below" : "above";
|
|
43
|
+
bullets.push(`${finding.display_name}: ${finding.value} ${finding.unit} (${direction} typical range of ${finding.reference_range})`);
|
|
44
|
+
}
|
|
45
|
+
// Add reassuring note if some things are normal
|
|
46
|
+
if (analysis.reassuring_findings.length > 0 && analysis.key_findings.length > 0) {
|
|
47
|
+
bullets.push(`${analysis.reassuring_findings.length} other marker${analysis.reassuring_findings.length !== 1 ? "s" : ""} within expected ranges.`);
|
|
48
|
+
}
|
|
49
|
+
// Add next step guidance
|
|
50
|
+
if (nextSteps.next_step.type === "additional_testing") {
|
|
51
|
+
bullets.push(`For more detail, consider: ${nextSteps.next_step.recommended_panel_name}`);
|
|
52
|
+
}
|
|
53
|
+
else if (nextSteps.next_step.type === "clinician_discussion") {
|
|
54
|
+
bullets.push("Consider discussing these results with your healthcare provider.");
|
|
55
|
+
}
|
|
56
|
+
// Ensure we have 3-5 bullets
|
|
57
|
+
if (bullets.length < 3) {
|
|
58
|
+
bullets.push("These results provide a snapshot of selected health markers.");
|
|
59
|
+
}
|
|
60
|
+
if (bullets.length < 3) {
|
|
61
|
+
bullets.push("A healthcare provider can help put these in context with your overall health.");
|
|
62
|
+
}
|
|
63
|
+
// Cap at 5 bullets
|
|
64
|
+
const finalBullets = bullets.slice(0, 5);
|
|
65
|
+
// Build CTA only for additional testing
|
|
66
|
+
let cta = null;
|
|
67
|
+
if (nextSteps.next_step.type === "additional_testing") {
|
|
68
|
+
cta = {
|
|
69
|
+
label: `Learn more: ${nextSteps.next_step.recommended_panel_name} ($${nextSteps.next_step.price})`,
|
|
70
|
+
url: nextSteps.next_step.order_url,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Build headline
|
|
74
|
+
let headline;
|
|
75
|
+
if (analysis.summary.overall_status === "all_normal") {
|
|
76
|
+
headline = "Your results are within expected ranges";
|
|
77
|
+
}
|
|
78
|
+
else if (analysis.key_findings.length === 1) {
|
|
79
|
+
headline = "One marker to note in your results";
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
headline = "A few markers to review in your results";
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
patient_summary: {
|
|
86
|
+
headline,
|
|
87
|
+
bullets: finalBullets,
|
|
88
|
+
cta,
|
|
89
|
+
disclaimer: DISCLAIMER,
|
|
90
|
+
requires_acknowledgment: true,
|
|
91
|
+
},
|
|
92
|
+
safety_warning: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggest next steps based on panel analysis
|
|
3
|
+
* Returns at most ONE follow-up panel, framed as "more detail" not diagnosis
|
|
4
|
+
*/
|
|
5
|
+
import { AnalysisOutput } from "./analyzePanelResults.js";
|
|
6
|
+
export interface NextStepAdditionalTesting {
|
|
7
|
+
type: "additional_testing";
|
|
8
|
+
recommended_panel_id: string;
|
|
9
|
+
recommended_panel_name: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
confidence: "suggested" | "may_be_helpful";
|
|
12
|
+
order_url: string;
|
|
13
|
+
price: number;
|
|
14
|
+
}
|
|
15
|
+
export interface NextStepNoTesting {
|
|
16
|
+
type: "no_additional_testing";
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export interface NextStepClinicianDiscussion {
|
|
20
|
+
type: "clinician_discussion";
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
export type NextStep = NextStepAdditionalTesting | NextStepNoTesting | NextStepClinicianDiscussion;
|
|
24
|
+
export interface NextStepsOutput {
|
|
25
|
+
next_step: NextStep;
|
|
26
|
+
not_recommended: string[];
|
|
27
|
+
safety: {
|
|
28
|
+
red_flags_detected: boolean;
|
|
29
|
+
message: string | null;
|
|
30
|
+
};
|
|
31
|
+
clinician_guidance: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Suggest next steps based on analysis
|
|
35
|
+
* Conservative approach: one follow-up max, educational framing
|
|
36
|
+
*/
|
|
37
|
+
export declare function suggestNextSteps(panelId: string, analysis: AnalysisOutput): NextStepsOutput;
|