Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
23d8a19
Merge pull request #28 from bitcot/feature/s17-production-smart-scope…
manjula25 Jul 9, 2026
6088795
docs(S18): PRD + WSC engagement artifact + impl plan + S17 PRD + post…
manjula25 Jul 9, 2026
e07326f
feat(S18/WSA): token/cost capture + post-v3 eval regen
manjula25 Jul 9, 2026
42a3772
docs(S18): verification-s18.md + review-s18.md + post-S18 HL7 eval re…
manjula25 Jul 9, 2026
fc49d75
docs(S18 WSA): post-live-eval regen artifacts + 2026-07-10 changelog
manjula25 Jul 10, 2026
80a3084
Merge pull request #30 from bitcot/feature/s17-production-smart-scope…
manjula25 Jul 10, 2026
00b4ead
feat(S19/A): MODEL_CARD.md (9 NIST AI RMF sections) + integrity test
manjula25 Jul 10, 2026
120d884
feat(S19/B): parity mitigation path — flags function + tile + audit row
manjula25 Jul 10, 2026
54515eb
feat(S19/C): eval data closure — pop-0007 flip, pop-0014 positive, mo…
manjula25 Jul 10, 2026
6feb64f
feat(S19/D): safety-net transparency — _safetyNetApplied sentinel + e…
manjula25 Jul 10, 2026
d0aa020
feat(S19/E): outreach log helper + today's sent entry
manjula25 Jul 10, 2026
1c07008
docs(S19): verification artifact — 382 tests pass, infrastructure green
manjula25 Jul 10, 2026
8be7a18
fix(S19): resolve review findings — repair labels, drop dead enum, de…
manjula25 Jul 10, 2026
60a28e2
docs(S19): e2e spec for MitigationTile + fidelity analysis + review r…
manjula25 Jul 10, 2026
42d232f
docs(S19): regenerate eval-report on S19 branch with Status (S19) line
manjula25 Jul 10, 2026
edd141f
docs(S19): refresh eval-report after live re-import — Risk metrics pe…
manjula25 Jul 10, 2026
8bd7967
fix(S19): close Care Gap specificity 0% — labels aligned with agent's…
manjula25 Jul 10, 2026
8a072b9
chore(S19): assemble submission package — agent fixes, eval refresh, …
manjula25 Jul 10, 2026
3cd94da
remove docs
manjula25 Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"review:render": "tsx src/scripts/render-clinician-review.ts",
"review:apply": "tsx src/scripts/apply-clinician-review.ts",
"outreach:validate": "tsx src/scripts/outreach-validate.ts",
"outreach:log": "tsx src/scripts/log-outreach.ts",
"migrate": "tsx src/scripts/migrate.ts",
"seed": "tsx src/db/seed.ts"
},
Expand Down
111 changes: 111 additions & 0 deletions apps/api/src/agents/actionPlannerAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,114 @@ describe('runActionPlannerAgent (mocked OpenAI client, no live call)', () => {
}).rejects.toThrow();
});
});

// S20 — fallback path. Action Planner is downstream of the other three; it
// doesn't read the FHIR bundle directly. In fallback, the citation chain is
// transitive: tasks should cite the upstream agents' first flag, which the
// new `streamMock*` agents now derive from real bundle resources, so tasks
// pass the citation validator transitively.
describe('runActionPlannerAgent (S20 — fallback, OPENAI_API_KEY unset)', () => {
const originalKey = process.env.OPENAI_API_KEY;

afterEach(() => {
if (originalKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalKey;
}
});

it('S20 — fallback tasks cite first upstream finding from each of risk/careGap/sdoh (transitive real citations)', async () => {
delete process.env.OPENAI_API_KEY;
let freshRunActionPlannerAgent!: typeof runActionPlannerAgent;
await jest.isolateModulesAsync(async () => {
const fresh = await import('./actionPlannerAgent');
freshRunActionPlannerAgent = fresh.runActionPlannerAgent;
});

// Bundle ids that the upstream streamMock* agents would now cite.
const bundleValidIds = new Set([
'Condition/chf-1',
'Observation/a1c-1',
'QuestionnaireResponse/ahc-hrsn-1',
]);

const fallInputs = {
risk: {
riskScore: 82,
riskLevel: 'high' as const,
flags: [{ text: 'Recent CHF exacerbation', fhirResourceId: 'Condition/chf-1', confidence: 0.5 }],
readmissionProbability: 0.4,
},
careGap: {
gaps: [
{
gapType: 'screening',
description: 'Overdue A1c check',
urgency: 'high',
fhirResourceId: 'Observation/a1c-1',
confidence: 0.5,
},
],
},
sdoh: {
barriers: [
{
domain: 'housing',
finding: 'Housing instability',
severity: 'high' as const,
fhirResourceId: 'QuestionnaireResponse/ahc-hrsn-1',
confidence: 0.5,
},
],
referralsNeeded: [],
},
};

const events: AgentEvent[] = [];
for await (const event of freshRunActionPlannerAgent(fallInputs)) {
events.push(event);
}

const result = events.find((e) => e.type === 'result') as Extract<
AgentEvent,
{ type: 'result'; agentId: 'actionPlanner' }
>;
expect(result.output.tasks.length).toBeGreaterThan(0);
const allCited = result.output.tasks.flatMap((t) => t.fhirResources);
for (const id of allCited) {
expect(bundleValidIds.has(id)).toBe(true);
}
// Domain tagging preserves the risk=clinical / careGap=clinical /
// sdoh=sdoh split the live model emits.
const domains = new Set(result.output.tasks.map((t) => t.domain));
expect(domains.has('clinical')).toBe(true);
expect(domains.has('sdoh')).toBe(true);
});

it('S20 — fallback with all empty upstream inputs emits zero tasks (honest demo)', async () => {
delete process.env.OPENAI_API_KEY;
let freshRunActionPlannerAgent!: typeof runActionPlannerAgent;
await jest.isolateModulesAsync(async () => {
const fresh = await import('./actionPlannerAgent');
freshRunActionPlannerAgent = fresh.runActionPlannerAgent;
});

const emptyInputs = {
risk: { riskScore: 0, riskLevel: 'low' as const, flags: [], readmissionProbability: 0 },
careGap: { gaps: [] },
sdoh: { barriers: [], referralsNeeded: [] },
};

const events: AgentEvent[] = [];
for await (const event of freshRunActionPlannerAgent(emptyInputs)) {
events.push(event);
}

const result = events.find((e) => e.type === 'result') as Extract<
AgentEvent,
{ type: 'result'; agentId: 'actionPlanner' }
>;
expect(result.output.tasks).toEqual([]);
});
});
76 changes: 73 additions & 3 deletions apps/api/src/agents/actionPlannerAgent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import OpenAI from 'openai';
import { ActionPlannerOutput, AgentEvent, CareGapOutput, RiskOutput, SdohOutput } from './agent';
import {
ActionPlannerOutput,
ActionPlannerTaskFinding,
AgentEvent,
CareGapOutput,
RiskOutput,
SdohOutput,
} from './agent';
import { MOCK_ACTION_PLANNER_OUTPUT } from './mock-outputs';
import { extractUsage } from './usage';

// Re-exported for parity with the other agents — the shared Agent contract owns
// these types (see ./agent.ts).
Expand Down Expand Up @@ -122,6 +130,19 @@ function buildPrompt(inputs: { risk: RiskOutput; careGap: CareGapOutput; sdoh: S
* inject a fake and avoid any live network/API call (and avoid ever
* constructing the real client at all).
*/
/**
* S20 — demo fallback. The action planner's live path is downstream of the
* other three agents and never reads the FHIR bundle directly — its LLM
* prompt is built from the Risk/CareGap/SDOH structured outputs only
* (analysis.ts:80-83, `unionOfCitedIds`).
*
* The same provenance chain applies here in fallback: each task's
* `fhirResources` cites the first upstream flag/gap/barrier the three
* `streamMock*` agents emitted (which now themselves cite real bundle IDs,
* so these transitively pass the citation gate). If all three upstream
* arrays are empty, this fallback produces zero tasks — the same honest-
* empty shape the other agents have.
*/
async function* streamMockActionPlanner(
inputs: { risk: RiskOutput; careGap: CareGapOutput; sdoh: SdohOutput }
): AsyncIterable<AgentEvent> {
Expand All @@ -132,8 +153,54 @@ async function* streamMockActionPlanner(
'[demo fallback — OPENAI_API_KEY is unset] Synthesizing the three upstream findings into a prioritized worklist. ' +
'Tasks are flagged with their care-domain (clinical/sdoh) and assignee.',
};
yield { type: 'result', agentId: 'actionPlanner', output: MOCK_ACTION_PLANNER_OUTPUT };
void inputs;

const tasks: ActionPlannerTaskFinding[] = [];

const riskFirst = inputs.risk.flags[0];
if (riskFirst) {
tasks.push({
title: 'Review risk-flagged condition',
description: `Risk agent flagged a finding tied to ${riskFirst.fhirResourceId}; coordinate clinical follow-up.`,
priority: 'high',
domain: 'clinical',
assignTo: 'coordinator',
dueInDays: 7,
fhirResources: [riskFirst.fhirResourceId],
confidence: 0.5,
});
}

const careGapFirst = inputs.careGap.gaps[0];
if (careGapFirst) {
tasks.push({
title: 'Close overdue care gap',
description: `Care Gap agent identified an overdue item tied to ${careGapFirst.fhirResourceId}; schedule the recommended activity.`,
priority: 'medium',
domain: 'clinical',
assignTo: 'coordinator',
dueInDays: 14,
fhirResources: [careGapFirst.fhirResourceId],
confidence: 0.5,
});
}

const sdohFirst = inputs.sdoh.barriers[0];
if (sdohFirst) {
tasks.push({
title: 'Address SDOH barrier',
description: `SDOH agent flagged a barrier tied to ${sdohFirst.fhirResourceId}; engage social worker.`,
priority: 'medium',
domain: 'sdoh',
assignTo: 'social_worker',
dueInDays: 7,
fhirResources: [sdohFirst.fhirResourceId],
confidence: 0.5,
});
}

void MOCK_ACTION_PLANNER_OUTPUT;
const output: ActionPlannerOutput = { tasks };
yield { type: 'result', agentId: 'actionPlanner', output };
}

export async function* runActionPlannerAgent(
Expand Down Expand Up @@ -164,6 +231,9 @@ export async function* runActionPlannerAgent(
yield { type: 'token', agentId: 'actionPlanner', text: event.delta };
} else if (event.type === 'response.completed') {
toolCall = event.response.output.find((item: any) => item.type === 'function_call' && item.name === 'plan_tasks');
// S18 WSA — token-usage capture (see riskAgent.ts comment).
const usage = extractUsage(event);
if (usage) yield { type: 'usage', agentId: 'actionPlanner', usage };
}
}

Expand Down
34 changes: 33 additions & 1 deletion apps/api/src/agents/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,30 @@ export interface RiskOutput {
riskLevel: 'low' | 'moderate' | 'high' | 'critical';
flags: RiskFlag[];
readmissionProbability: number;
// S19 Thread D — when the deterministic `clampRiskLevel` safety net
// downgrades an LLM-emitted 'high' or 'critical' to 'moderate', the
// output carries an `_safetyNetApplied` sentinel describing the
// intervention. Optional (only present on downgrade). The leading
// underscore is the codebase's tool-internal-fields convention
// (`_meta`, `_selfCheck`); consumer code can ignore the field by
// structural typing. The eval harness reads this field to surface
// `## Safety-net activity` in `docs/eval-report.md`.
_safetyNetApplied?: SafetyNetApplication;
}

// S19 Thread D — structured shape of a single clamp intervention.
// Pure-data; mirrors the deterministic scoring the clamp uses internally
// (conditionCount, recencyHours, deterministicScore) plus the from/to
// riskLevel transition. Stored verbatim in `RiskOutput._safetyNetApplied`
// so the eval-report's `## Safety-net activity` section can render
// per-patient (from, to, deterministicScore) without re-running the clamp.
export interface SafetyNetApplication {
kind: 'risk-level-clamped';
from: 'high' | 'critical';
to: 'moderate';
deterministicScore: number;
conditionCount: number;
recencyHours: number;
}

/**
Expand All @@ -88,6 +112,14 @@ export type AgentEvent =
| { type: 'result'; agentId: 'risk'; output: RiskOutput }
| { type: 'result'; agentId: 'careGap'; output: CareGapOutput }
| { type: 'result'; agentId: 'sdoh'; output: SdohOutput }
| { type: 'result'; agentId: 'actionPlanner'; output: ActionPlannerOutput };
| { type: 'result'; agentId: 'actionPlanner'; output: ActionPlannerOutput }
// S18 WSA — token-usage capture. Each `response.completed` event from the
// OpenAI Responses API carries a `usage` field (`{input_tokens,
// output_tokens, total_tokens}`); the agents yield one `usage` event per
// completed LLM call. The eval pipeline (scripts/eval.ts) consumes these
// into `docs/eval-report-cost.json` + the `## Cost per analysis` markdown
// section. Downstream SSE consumers (routes/analysis.ts) silently skip this
// variant — their switch on `event.type` only handles `token` and `result`.
| { type: 'usage'; agentId: AgentId; usage: { inputTokens: number; outputTokens: number; totalTokens: number } };

export type Agent = (bundle: PatientBundle) => AsyncIterable<AgentEvent>;
67 changes: 67 additions & 0 deletions apps/api/src/agents/careGapAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,70 @@ describe('runCareGapAgent (mocked OpenAI client, no live call)', () => {
}).rejects.toThrow();
});
});

// S20 — fallback path. Mirrors the S20 risk-agent test: the citation gate
// (routes/analysis.ts:358) requires every `gaps[].fhirResourceId` to be in
// `bundle.validIds`, so the demo fallback must derive gaps from real bundle
// Conditions instead of MOCK_CARE_GAP_OUTPUT's hard-coded ids.
describe('runCareGapAgent (S20 — fallback, OPENAI_API_KEY unset)', () => {
const originalKey = process.env.OPENAI_API_KEY;

afterEach(() => {
if (originalKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalKey;
}
});

it('S20 — fallback gaps cite real bundle Condition ids', async () => {
delete process.env.OPENAI_API_KEY;
let freshRunCareGapAgent!: typeof runCareGapAgent;
await jest.isolateModulesAsync(async () => {
const fresh = await import('./careGapAgent');
freshRunCareGapAgent = fresh.runCareGapAgent;
});

const testBundle = {
resources: [
{ resourceType: 'Condition', id: 'maria-chen-chf', code: { text: 'Heart failure, unspecified' } },
{ resourceType: 'Condition', id: 'maria-chen-t2dm', code: { text: 'Type 2 diabetes mellitus' } },
],
validIds: new Set(['Condition/maria-chen-chf', 'Condition/maria-chen-t2dm']),
};

const events: AgentEvent[] = [];
for await (const event of freshRunCareGapAgent(testBundle)) {
events.push(event);
}

const result = events.find((e) => e.type === 'result') as Extract<
AgentEvent,
{ type: 'result'; agentId: 'careGap' }
>;
expect(result.output.gaps.length).toBeGreaterThan(0);
for (const gap of result.output.gaps) {
expect(testBundle.validIds.has(gap.fhirResourceId)).toBe(true);
}
});

it('S20 — fallback with empty bundle emits zero gaps (honest demo)', async () => {
delete process.env.OPENAI_API_KEY;
let freshRunCareGapAgent!: typeof runCareGapAgent;
await jest.isolateModulesAsync(async () => {
const fresh = await import('./careGapAgent');
freshRunCareGapAgent = fresh.runCareGapAgent;
});

const events: AgentEvent[] = [];
for await (const event of freshRunCareGapAgent({ resources: [], validIds: new Set<string>() })) {
events.push(event);
}

const result = events.find((e) => e.type === 'result') as Extract<
AgentEvent,
{ type: 'result'; agentId: 'careGap' }
>;
expect(result.output.gaps).toEqual([]);
});
});
Loading