mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Each recommendation now carries a 3-field `rationale` object that
powers the upcoming Oura-style "Contributors" expand-card:
- dataWindow: which time window the rec is based on (matches the
metricSource.timeRange enum so the UI mini-chart can pin to it)
- comparedTo: what the user's data is being compared against
(the user's own baseline or a guideline ceiling)
- deviation: size + direction of the deviation that triggered
the rec
Empty `comparedTo` or `deviation` is rejected so the model cannot
emit a placeholder rationale card. Optional `referenceId` rides
along for defence-in-depth; the rec-level referenceId stays the
canonical citation source.
`findRecommendationsMissingRationale()` lets the route detect
legacy (pre-B5c) cached payloads so the UI can show a "regenerate
for new explainability features" CTA. User-initiated regeneration
stays the trigger; nothing auto-runs.
10 new tests in `rationale-schema.test.ts` pin the contract:
- rationale required on every rec
- comparedTo / deviation reject empty strings
- dataWindow rejects unknown enum values
- legacy payload detector returns missing rec ids only
- mixed payloads flag only the legacy entries
Existing test fixtures in `generate-insight.test.ts`,
`reference-id-schema.test.ts`, `citation-enforcement.test.ts`,
and `citation-coverage-logging.test.ts` updated to include
rationale on every well-formed rec — typecheck-clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
aiInsightResponseSchema,
|
|
aiRecommendationSchema,
|
|
type AIInsightResponse,
|
|
} from "../schema";
|
|
import { MEDICAL_REFERENCES } from "../medical-references";
|
|
|
|
/**
|
|
* v1.4.16 phase B5a — `recommendation.referenceId` validation.
|
|
*
|
|
* The schema accepts optional `referenceId` strings, but only when
|
|
* they point into the curated `MEDICAL_REFERENCES` bundle. A
|
|
* fabricated id ("invented-guideline") MUST fail parse so the model
|
|
* cannot synthesise a citation for a non-existent guideline.
|
|
*
|
|
* Optional-when-absent (the v1.4.16 baseline). Phase B5c will flip
|
|
* the requirement to mandatory for severity >= "important".
|
|
*/
|
|
|
|
const knownRefId = MEDICAL_REFERENCES[0].id;
|
|
|
|
const baseMetricSource = {
|
|
type: "bloodPressure",
|
|
timeRange: "last7days",
|
|
summary: "avg 138/86 across 9 readings",
|
|
};
|
|
|
|
const baseCitation = {
|
|
type: "bloodPressure",
|
|
timeRange: "last7days",
|
|
summary: "avg 138/86 across 9 readings",
|
|
};
|
|
|
|
const baseRec = {
|
|
id: "rec-1",
|
|
text: "Discuss home BP log with your physician.",
|
|
severity: "important" as const,
|
|
metricSource: baseMetricSource,
|
|
rationale: {
|
|
dataWindow: "last7days" as const,
|
|
comparedTo: "your 90-day median (122/78)",
|
|
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
|
|
},
|
|
};
|
|
|
|
const baseValid: AIInsightResponse = {
|
|
summary: "BP runs slightly above your 90-day median.",
|
|
recommendations: [baseRec],
|
|
citations: [baseCitation],
|
|
warnings: [],
|
|
};
|
|
|
|
describe("aiRecommendationSchema — referenceId", () => {
|
|
it("accepts an absent referenceId", () => {
|
|
const result = aiRecommendationSchema.safeParse(baseRec);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a referenceId that exists in the bundle", () => {
|
|
const result = aiRecommendationSchema.safeParse({
|
|
...baseRec,
|
|
referenceId: knownRefId,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.referenceId).toBe(knownRefId);
|
|
}
|
|
});
|
|
|
|
it("rejects a referenceId that is NOT in the bundle", () => {
|
|
const result = aiRecommendationSchema.safeParse({
|
|
...baseRec,
|
|
referenceId: "fabricated-guideline-2099",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
const issue = result.error.issues.find(
|
|
(i) => i.path[i.path.length - 1] === "referenceId",
|
|
);
|
|
expect(issue).toBeDefined();
|
|
expect(issue?.message).toMatch(/not in MEDICAL_REFERENCES/);
|
|
}
|
|
});
|
|
|
|
it("rejects a referenceId that is the empty string", () => {
|
|
const result = aiRecommendationSchema.safeParse({
|
|
...baseRec,
|
|
referenceId: "",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("aiInsightResponseSchema — referenceId integration", () => {
|
|
it("accepts a payload where every rec carries a known referenceId", () => {
|
|
const result = aiInsightResponseSchema.safeParse({
|
|
...baseValid,
|
|
recommendations: [{ ...baseRec, referenceId: knownRefId }],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a payload with one fabricated referenceId, even when the rest is valid", () => {
|
|
const result = aiInsightResponseSchema.safeParse({
|
|
...baseValid,
|
|
recommendations: [
|
|
{ ...baseRec, referenceId: knownRefId },
|
|
{
|
|
...baseRec,
|
|
id: "rec-2",
|
|
referenceId: "esh-9999-fake",
|
|
},
|
|
],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
const refIssue = result.error.issues.find((i) =>
|
|
i.path.includes("referenceId"),
|
|
);
|
|
expect(refIssue).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("accepts a payload with a mix of cited and uncited recs", () => {
|
|
const result = aiInsightResponseSchema.safeParse({
|
|
...baseValid,
|
|
recommendations: [
|
|
{ ...baseRec, referenceId: knownRefId },
|
|
{ ...baseRec, id: "rec-2" }, // no referenceId — observational
|
|
],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("preserves the referenceId on round-trip parse", () => {
|
|
const parsed = aiInsightResponseSchema.parse({
|
|
...baseValid,
|
|
recommendations: [{ ...baseRec, referenceId: knownRefId }],
|
|
});
|
|
expect(parsed.recommendations[0].referenceId).toBe(knownRefId);
|
|
});
|
|
});
|