mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.
Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.
Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.
Behaviour is unchanged across every surface; the full suite stays green.
173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { generateInsight } from "../generate-insight";
|
|
import { MockAIProvider } from "../mock-client";
|
|
import { singleUserTurn } from "../types";
|
|
|
|
const annotateMock = vi.fn();
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
annotate: (fields: { meta?: Record<string, unknown> }) =>
|
|
annotateMock(fields),
|
|
getEvent: () => null,
|
|
}));
|
|
|
|
/**
|
|
* v1.4.16 phase B5c — wrapper-level rationale handling.
|
|
*
|
|
* 1. The corrective retry message lists the rationale fields so a
|
|
* first-attempt response without rationale gets a targeted
|
|
* reprompt (instead of the model guessing what's wrong).
|
|
* 2. The wrapper's post-validation `annotate()` carries a
|
|
* `ai_rationale_coverage_*` breakdown so the admin AI quality
|
|
* dashboard can chart per-payload coverage of the new field.
|
|
* Mirrors the citation-coverage annotations from B5a.
|
|
*/
|
|
|
|
const baseRationale = {
|
|
dataWindow: "last7days" as const,
|
|
comparedTo: "your 90-day median (122/78)",
|
|
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
|
|
};
|
|
|
|
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 validResponse = {
|
|
summary: "BP runs slightly above your 90-day median.",
|
|
recommendations: [
|
|
{
|
|
id: "rec-1",
|
|
text: "Discuss home BP log with your physician.",
|
|
severity: "important",
|
|
metricSource: baseMetricSource,
|
|
rationale: baseRationale,
|
|
},
|
|
],
|
|
citations: [baseCitation],
|
|
warnings: [],
|
|
};
|
|
|
|
describe("generateInsight() — corrective retry mentions rationale", () => {
|
|
beforeEach(() => {
|
|
annotateMock.mockReset();
|
|
});
|
|
|
|
it("retries with a corrective message naming dataWindow / comparedTo / deviation when rationale missing", async () => {
|
|
const recWithoutRationale = {
|
|
id: "rec-1",
|
|
text: "Walk more",
|
|
severity: "suggestion",
|
|
metricSource: baseMetricSource,
|
|
// rationale missing
|
|
};
|
|
const broken = {
|
|
...validResponse,
|
|
recommendations: [recWithoutRationale],
|
|
};
|
|
const provider = new MockAIProvider({
|
|
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
|
|
});
|
|
const outcome = await generateInsight(
|
|
provider,
|
|
singleUserTurn({ system: "sys", user: "Original user prompt." }),
|
|
);
|
|
expect(outcome.attempts).toBe(2);
|
|
expect(outcome.retried).toBe(true);
|
|
const retryUserPrompt = provider.calls[1].messages[0].content as string;
|
|
expect(retryUserPrompt).toContain("Original user prompt.");
|
|
expect(retryUserPrompt).toContain("rationale");
|
|
expect(retryUserPrompt).toContain("dataWindow");
|
|
expect(retryUserPrompt).toContain("comparedTo");
|
|
expect(retryUserPrompt).toContain("deviation");
|
|
});
|
|
|
|
it("retries when first attempt has empty rationale.deviation", async () => {
|
|
const recWithEmptyDeviation = {
|
|
id: "rec-1",
|
|
text: "Walk more",
|
|
severity: "suggestion",
|
|
metricSource: baseMetricSource,
|
|
rationale: {
|
|
dataWindow: "last7days",
|
|
comparedTo: "your 90-day median",
|
|
deviation: "", // empty — should fail
|
|
},
|
|
};
|
|
const broken = {
|
|
...validResponse,
|
|
recommendations: [recWithEmptyDeviation],
|
|
};
|
|
const provider = new MockAIProvider({
|
|
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
|
|
});
|
|
const outcome = await generateInsight(
|
|
provider,
|
|
singleUserTurn({ system: "sys", user: "user" }),
|
|
);
|
|
expect(outcome.attempts).toBe(2);
|
|
expect(outcome.retried).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("generateInsight() — rationale-coverage annotation", () => {
|
|
beforeEach(() => {
|
|
annotateMock.mockReset();
|
|
});
|
|
|
|
it("annotates ai_rationale_coverage_* fields on a successful generation", async () => {
|
|
const provider = new MockAIProvider({
|
|
responses: JSON.stringify(validResponse),
|
|
});
|
|
await generateInsight(
|
|
provider,
|
|
singleUserTurn({ system: "sys", user: "user" }),
|
|
);
|
|
expect(annotateMock).toHaveBeenCalled();
|
|
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
|
|
expect(meta).toMatchObject({
|
|
ai_total_recommendations: 1,
|
|
ai_rationale_total_recommendations: 1,
|
|
ai_rationale_with_rationale: 1,
|
|
ai_rationale_missing_recommendation_ids: [],
|
|
});
|
|
});
|
|
|
|
it("rationale-coverage = total recommendations when every rec carries one", async () => {
|
|
const twoRecs = {
|
|
...validResponse,
|
|
recommendations: [
|
|
validResponse.recommendations[0],
|
|
{
|
|
id: "rec-2",
|
|
text: "Continue daily logging.",
|
|
severity: "info",
|
|
metricSource: baseMetricSource,
|
|
rationale: baseRationale,
|
|
},
|
|
],
|
|
};
|
|
const provider = new MockAIProvider({
|
|
responses: JSON.stringify(twoRecs),
|
|
});
|
|
await generateInsight(
|
|
provider,
|
|
singleUserTurn({ system: "sys", user: "user" }),
|
|
);
|
|
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
|
|
expect(meta).toMatchObject({
|
|
ai_rationale_total_recommendations: 2,
|
|
ai_rationale_with_rationale: 2,
|
|
ai_rationale_missing_recommendation_ids: [],
|
|
});
|
|
});
|
|
});
|