Files
HealthLog/src/lib/ai/__tests__/reference-id-schema.test.ts
T
Marc-André BombeckandClaude Opus 4.7 8a438a0680 feat(ai): schema requires rationale (window + comparedTo + deviation) per recommendation
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>
2026-05-10 01:17:15 +02:00

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);
});
});