feat(ai): answer the status notes with a null note when AI is unavailable

The status family (the five metric cards, medication compliance, the
generic metric status and the biomarker assessment) asked the retired
switch and the `insights` module and refused with 403. The note is model
text and the card around it is data, so they are mixed reads now: while
the `statusText` capability is available the note is served and warmed
as before, with `ai` beside it; otherwise the answer is 200 with the
note null, `preparing: false`, `hasProvider` as provider presence only
and `ai` naming the reason, and neither the cache nor the warm queue is
touched. The generic metric status still reports `insufficient` from the
data. The mood card keeps its mood module gate.

Kept apart so it can be held back while the shipped iPhone app's
rendering of a 200 without a note is confirmed.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:30:23 +02:00
1 parent 9f1d1341f9
commit 1f6a13e2f7
19 files changed
+621 -207

No files matched your search

+77 -25
View File
@@ -10557,8 +10557,11 @@ paths:
- Insights
summary: Blood-pressure assessment
description: "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache
miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via
cookie or Bearer."
miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note
is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the
answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the
reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the
AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: locale
@@ -10586,8 +10589,11 @@ paths:
- Insights
summary: Pulse assessment
description: "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss
warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie
or Bearer."
warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is
model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the
answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the
reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the
AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: locale
@@ -10615,8 +10621,11 @@ paths:
- Insights
summary: Weight assessment
description: "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a
generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or
Bearer."
generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text:
it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200
with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is
read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis
opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: locale
@@ -10644,8 +10653,11 @@ paths:
- Insights
summary: BMI assessment
description: "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a
generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or
Bearer."
generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text:
it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200
with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is
read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis
opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: locale
@@ -10673,8 +10685,11 @@ paths:
- Insights
summary: Mood assessment
description: "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a
generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or
Bearer."
generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text:
it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200
with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is
read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis
opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: locale
@@ -10703,7 +10718,11 @@ paths:
summary: Medication-compliance assessment
description: "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a
per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good
envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer."
envelope meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the
`statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false,
`hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never
refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability.
Auth via cookie or Bearer."
parameters:
- in: query
name: locale
@@ -10734,7 +10753,10 @@ paths:
sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via
archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and
serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed
registry enum. Auth via cookie or Bearer."
registry enum. The note is model text: it is served, and warmed, only while the `statusText` AI capability is
available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence
and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the
`insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: metric
@@ -10826,7 +10848,10 @@ paths:
Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a
cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the
assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient`
without an LLM call. Auth via cookie or Bearer."
without an LLM call. The note is model text: it is served, and warmed, only while the `statusText` AI capability
is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider
presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason,
and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer."
parameters:
- in: query
name: biomarkerId
@@ -53833,13 +53858,14 @@ components:
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance.
description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never
false because the operator switched notes off or consent is missing; `ai` says that."
text:
anyOf:
- type: string
- type: "null"
description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is
preparing.
description: The assessment note (plain text, rendered as React text children). Null while a first generation is
preparing, and whenever the `statusText` capability is unavailable (see `ai`).
cached:
type: boolean
description: True when `text` is served from cache (incl. last-good).
@@ -53859,11 +53885,16 @@ components:
flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the
warmed assessment without a remount.
type: boolean
ai:
description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is
false, the cache is not read and nothing is warmed.
$ref: "#/components/schemas/AiCapabilityState"
required:
- hasProvider
- text
- cached
- updatedAt
- ai
additionalProperties: false
description: "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to
the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only +
@@ -53958,12 +53989,14 @@ components:
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.
description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never
false because the operator switched notes off or consent is missing; `ai` says that."
summary:
anyOf:
- type: string
- type: "null"
description: The overall compliance narrative (plain text). Null while a first generation is preparing.
description: The overall compliance note (plain text). Null while a first generation is preparing, and whenever the
`statusText` capability is unavailable (see `ai`).
medications:
type: array
items:
@@ -53987,12 +54020,17 @@ components:
description: True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is
in flight. The client keeps polling on `preparing || revalidating` (bounded).
type: boolean
ai:
description: The `statusText` capability behind the note. While it is unavailable `summary` is null, `medications` is
empty, `preparing` is false, the cache is not read and nothing is warmed.
$ref: "#/components/schemas/AiCapabilityState"
required:
- hasProvider
- summary
- medications
- cached
- updatedAt
- ai
additionalProperties: false
description: Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary`
plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.
@@ -54031,13 +54069,15 @@ components:
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance.
description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never
false because the operator switched notes off or consent is missing; `ai` says that."
text:
anyOf:
- type: string
- type: "null"
description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is
preparing, or when the metric has insufficient data.
description: The assessment note (plain text, rendered as React text children). Null while a first generation is
preparing, when the metric has insufficient data, and whenever the `statusText` capability is unavailable
(see `ai`).
cached:
type: boolean
description: True when `text` is served from cache (incl. last-good).
@@ -54059,13 +54099,18 @@ components:
type: boolean
insufficient:
description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its
insufficient-data state.
insufficient-data state. Still computed while `ai` is unavailable.
type: boolean
ai:
description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is
false, the cache is not read and nothing is warmed.
$ref: "#/components/schemas/AiCapabilityState"
required:
- hasProvider
- text
- cached
- updatedAt
- ai
additionalProperties: false
description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the
`InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation
@@ -54092,13 +54137,15 @@ components:
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance.
description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never
false because the operator switched notes off or consent is missing; `ai` says that."
text:
anyOf:
- type: string
- type: "null"
description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is
preparing, or when the marker has no numeric readings.
description: The assessment note (plain text, rendered as React text children). Null while a first generation is
preparing, when the marker has no numeric readings, and whenever the `statusText` capability is unavailable
(see `ai`).
cached:
type: boolean
description: True when `text` is served from cache (incl. last-good).
@@ -54122,11 +54169,16 @@ components:
description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not
rendered.
type: boolean
ai:
description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is
false, the cache is not read and nothing is warmed.
$ref: "#/components/schemas/AiCapabilityState"
required:
- hasProvider
- text
- cached
- updatedAt
- ai
additionalProperties: false
description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the
`InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation
+40 -16
View File
@@ -74,6 +74,46 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
capabilities: ["aboutMeQuestions"],
why: "The profile is data; only model-written follow-up questions follow the capability.",
},
"src/app/api/insights/biomarker-assessment/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/blood-pressure-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/bmi-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/medication-compliance-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/metric-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/mood-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/pulse-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/weight-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/workouts/[id]/route.ts": {
kind: "mixed",
capabilities: ["workoutInsights"],
@@ -165,22 +205,6 @@ export const PENDING_ROUTES: Readonly<Record<string, string>> = {
"Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data, stays ungated once the document routes move.",
"src/app/api/insights/biomarker-assessment/route.ts":
"Status family: moves to `statusText` (200 with null text) in its own change.",
"src/app/api/insights/blood-pressure-status/route.ts":
"Status family: moves to `statusText` in its own change.",
"src/app/api/insights/bmi-status/route.ts":
"Status family: moves to `statusText` in its own change.",
"src/app/api/insights/medication-compliance-status/route.ts":
"Status family: moves to `statusText` in its own change.",
"src/app/api/insights/metric-status/route.ts":
"Status family: moves to `statusText` in its own change.",
"src/app/api/insights/mood-status/route.ts":
"Status family: moves to `statusText` in its own change.",
"src/app/api/insights/pulse-status/route.ts":
"Status family: moves to `statusText` in its own change.",
"src/app/api/insights/weight-status/route.ts":
"Status family: moves to `statusText` in its own change.",
};
/** The trees in which every route has to be classified above. */
@@ -6,6 +6,12 @@ vi.mock("@/lib/db", () => ({
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
// The route resolves the `insights` module gate after `requireAuth()`. Mock
// it default-enabled so the assertions ride through; the off → 403 coverage
@@ -57,6 +63,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -80,6 +87,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(generateBiomarkerStatus).mockResolvedValue({
hasProvider: true,
text: "ok",
@@ -121,19 +133,22 @@ describe("GET /api/insights/biomarker-assessment", () => {
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
it("answers 200 with no note while statusText is unavailable, calling no generator", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
});
const res = await callGet(makeReq("bm-1"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toMatchObject({
text: null,
preparing: false,
ai: { available: false, reason: "user_disabled" },
});
expect(generateBiomarkerStatus).not.toHaveBeenCalled();
});
it("preserves the safe terminal fallback produced when a causal claim is screened", async () => {
@@ -161,6 +176,7 @@ describe("GET /api/insights/biomarker-assessment", () => {
text: "The assessment could not be completed safely. Your recorded measurements remain available.",
cached: true,
updatedAt: null,
ai: { available: true, reason: null, onDeviceAllowed: true },
});
expect(JSON.stringify(body)).not.toContain("medication caused");
expect(JSON.stringify(body)).not.toContain("causal_claim");
@@ -12,6 +12,12 @@
* provider. The generator regenerates ONLY when the latest reading
* fingerprint changes, so an idle marker re-stamps cached text without an
* LLM round-trip.
*
* A mixed read: the note is served, and warmed, only while the `statusText`
* AI capability is available. Otherwise the route answers 200 with no note
* and an `ai` state saying why, without reading the cache or queueing
* anything. The `insights` module (the AI analysis opt-out) folds into the
* capability.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -19,8 +25,8 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
@@ -35,10 +41,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = querySchema.safeParse({
biomarkerId: request.nextUrl.searchParams.get("biomarkerId"),
});
@@ -50,6 +52,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.biomarker-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
@@ -71,5 +82,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { biomarkerId: parsed.data.biomarkerId },
});
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -1,19 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
/**
* The status family is a mixed read. The note is model text: served only
* while the `statusText` capability is available. Otherwise the route
* answers 200 with `text: null`, `preparing: false`, `hasProvider` as provider
* presence and an `ai` state, and the generator (the cache read and the warm
* enqueue behind it) is never called. The `insights` module is folded into
* the capability and no longer refuses the route.
*/
vi.mock("@/lib/db", () => ({ prisma: {} }));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate inventory
// test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
requireModuleEnabled: vi.fn(),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
@@ -38,22 +39,28 @@ vi.mock("next/headers", () => ({
})),
}));
// Stub the heavy generator so the gate test stays isolated from the
// real status pipeline.
vi.mock("@/lib/insights/blood-pressure-status", () => ({
generateBloodPressureStatusForUser: vi.fn(async () => ({
available: true,
locale: "en",
text: "ok",
})),
generateBloodPressureStatusForUser: vi.fn(),
resolveBloodPressureStatusLocale: () => "en",
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { probeProviderPresence } from "@/lib/ai/provider";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -72,30 +79,61 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE);
vi.mocked(probeProviderPresence).mockResolvedValue(true);
vi.mocked(generateBloodPressureStatusForUser).mockResolvedValue({
hasProvider: true,
text: "ok",
cached: true,
updatedAt: "2026-09-20T06:00:00.000Z",
});
});
describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => {
it("returns 200 when insightStatus is enabled (default)", async () => {
describe("GET /api/insights/blood-pressure-status", () => {
it("serves the note and the state while statusText is available", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.text).toBe("ok");
expect(body.data.ai).toEqual(AI_AVAILABLE);
expect(getAiCapability).toHaveBeenCalledWith("statusText");
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
it("returns 403 + errorCode when insightStatus is off", async () => {
it.each([
"operator_disabled",
"user_disabled",
"consent_required",
"check_failed",
] as const)(
"answers 200 with no note for %s, reading and warming nothing",
async (reason) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable(reason));
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toEqual({
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: false,
ai: aiUnavailable(reason),
});
expect(generateBloodPressureStatusForUser).not.toHaveBeenCalled();
},
);
it("reports hasProvider as presence only: false for a missing provider", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider"));
vi.mocked(probeProviderPresence).mockResolvedValue(false);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.hasProvider).toBe(false);
expect(body.data.text).toBeNull();
});
it("returns 401 when unauthenticated", async () => {
@@ -104,33 +142,3 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () =
expect(res.status).toBe(401);
});
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
// Representative coverage that a disabled module short-circuits with the
// 403 module.disabled envelope before any provider / cache work; the
// inventory test guards that EVERY insights AI route carries this gate.
describe("GET /api/insights/blood-pressure-status — insights module gate", () => {
it("returns 200 when insights is enabled (default)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
});
it("returns 403 + module.disabled when insights is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { errorCode?: string; module?: string };
};
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("insights");
});
});
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.blood-pressure-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.blood-pressure-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
+21 -6
View File
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.bmi-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.bmi-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableComplianceStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`summary: null`, no per-medication notes, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.medication-compliance-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableComplianceStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.medication-compliance-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -4,8 +4,15 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
measurement: { count: vi.fn() },
},
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()` (in addition to the existing per-metric sleep/glucose/
@@ -55,6 +62,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -77,6 +85,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("GET /api/insights/metric-status", () => {
@@ -126,18 +139,29 @@ describe("GET /api/insights/metric-status", () => {
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
it.each([
[0, true],
[3, false],
] as const)(
"answers 200 with no note while statusText is unavailable (%i readings → insufficient %s)",
async (readings, insufficient) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
vi.mocked(prisma.measurement.count).mockResolvedValue(readings as never);
const res = await callGet(makeReq("RESTING_HEART_RATE"));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toMatchObject({
text: null,
preparing: false,
insufficient,
ai: { available: false, reason: "operator_disabled" },
});
expect(generateMetricStatus).not.toHaveBeenCalled();
},
);
});
+35 -6
View File
@@ -13,6 +13,12 @@
* text, never blocking on the provider. An unknown `metric` 422s via the
* closed registry enum.
*
* A mixed read: the note is model text, served (and warmed on a miss) only
* while the `statusText` AI capability is available. Otherwise the route
* answers 200 with no note, `insufficient` still computed from the data, and
* an `ai` state saying why; the cache is not read and nothing is queued. The
* `insights` module (the AI analysis opt-out) folds into the capability.
*
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
* establishing the pattern the legacy routes can be backfilled onto later.
@@ -23,14 +29,17 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { prisma } from "@/lib/db";
import {
generateMetricStatus,
resolveMetricStatusLocale,
} from "@/lib/insights/metric-status";
import {
METRIC_STATUS_IDS,
getMetricStatusMeta,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
@@ -72,10 +81,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = metricQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
});
@@ -99,6 +104,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
}
const ai = await getAiCapability("statusText");
if (!ai.available) {
// The note is not served, but whether there is anything to assess is
// data: the card keeps its insufficient-data state.
const meta = getMetricStatusMeta(metric);
const readings = meta
? await prisma.measurement.count({
where: {
userId: user.id,
type: meta.measurementType,
deletedAt: null,
},
})
: 0;
annotate({
action: { name: "insights.metric-status.unavailable" },
meta: { metric, reason: ai.reason },
});
return apiSuccess({
...(await unavailableStatusBody(user.id, ai)),
insufficient: readings === 0,
});
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
@@ -120,5 +149,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { metric },
});
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -10,6 +10,12 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -45,6 +51,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -74,6 +81,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("GET /api/insights/mood-status — module gate", () => {
@@ -102,6 +114,30 @@ describe("GET /api/insights/mood-status — module gate", () => {
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
});
it("keeps the mood module gate ahead of the capability", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({ mood: false }) as never,
);
await callGet(makeReq());
expect(getAiCapability).not.toHaveBeenCalled();
});
it("answers 200 with no note while statusText is unavailable", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "no_provider",
onDeviceAllowed: true,
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.text).toBeNull();
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
+21 -6
View File
@@ -7,26 +7,41 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Per-domain gate: the mood module must be enabled for this account
// before the mood AI-status surface is served (mirrors the cycle gate).
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
await requireAssistantSurface("insightStatus");
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.mood-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -47,5 +62,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.mood-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
+21 -6
View File
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.pulse-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.pulse-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
+21 -6
View File
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await getAiCapability("statusText");
if (!ai.available) {
annotate({
action: { name: "insights.weight-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.weight-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -110,17 +110,7 @@ const AWAITING_READER: Record<string, string> = {
/**
* Every file that still calls the retired gate. Only ever shrinks.
*/
const RETIRED_GATE_CALLERS = [
"src/app/api/insights/biomarker-assessment/route.ts",
"src/app/api/insights/blood-pressure-status/route.ts",
"src/app/api/insights/bmi-status/route.ts",
"src/app/api/insights/medication-compliance-status/route.ts",
"src/app/api/insights/metric-status/route.ts",
"src/app/api/insights/mood-status/route.ts",
"src/app/api/insights/pulse-status/route.ts",
"src/app/api/insights/weight-status/route.ts",
"src/app/api/medications/extract/route.ts",
];
const RETIRED_GATE_CALLERS = ["src/app/api/medications/extract/route.ts"];
function sourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
+70
View File
@@ -0,0 +1,70 @@
/**
* What a status route answers while the `statusText` capability is
* unavailable.
*
* The status family (`*-status`, `metric-status`, `biomarker-assessment`) is
* a mixed read: the card around the note is data, the note is model output.
* With the capability unavailable, for any reason, the route answers 200 with
* no note and says why:
*
* - `text: null`: stored model text is never served, however fresh.
* - `preparing: false`: nothing is warming, because nothing was enqueued.
* - `hasProvider`: provider presence and nothing else. A shipped native
* client renders "no provider" copy for `false`, so it must not be false
* because the operator switched notes off or consent is missing.
* - `ai`: the resolved state, so a client can say why without guessing.
*
* The route builds this instead of calling the generator, so no cache row is
* read and no generation is queued.
*/
import { probeProviderPresence } from "@/lib/ai/provider";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
export interface UnavailableStatusBody {
hasProvider: boolean;
text: null;
cached: false;
updatedAt: null;
preparing: false;
ai: AiCapabilityState;
}
export async function unavailableStatusBody(
recordId: string,
ai: AiCapabilityState,
): Promise<UnavailableStatusBody> {
return {
hasProvider: await probeProviderPresence(recordId),
text: null,
cached: false,
updatedAt: null,
preparing: false,
ai,
};
}
/** The medication-compliance card's shape: a summary plus per-medication notes. */
export interface UnavailableComplianceStatusBody {
hasProvider: boolean;
summary: null;
medications: [];
cached: false;
updatedAt: null;
preparing: false;
ai: AiCapabilityState;
}
export async function unavailableComplianceStatusBody(
recordId: string,
ai: AiCapabilityState,
): Promise<UnavailableComplianceStatusBody> {
return {
hasProvider: await probeProviderPresence(recordId),
summary: null,
medications: [],
cached: false,
updatedAt: null,
preparing: false,
ai,
};
}
+8 -8
View File
@@ -596,7 +596,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Blood-pressure assessment",
description:
"Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
"Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: insightStatusQuery,
},
@@ -622,7 +622,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Pulse assessment",
description:
"Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
"Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: insightStatusQuery,
},
@@ -648,7 +648,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Weight assessment",
description:
"Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
"Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: insightStatusQuery,
},
@@ -674,7 +674,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "BMI assessment",
description:
"Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
"Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: insightStatusQuery,
},
@@ -700,7 +700,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Mood assessment",
description:
"Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
"Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: insightStatusQuery,
},
@@ -726,7 +726,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Medication-compliance assessment",
description:
"Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
"Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: insightStatusQuery,
},
@@ -753,7 +753,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Generic per-HealthKit-metric assessment",
description:
"v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.",
"v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: metricStatusQuery,
},
@@ -779,7 +779,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Per-biomarker assessment",
description:
"Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.",
"Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.",
requestParams: {
query: biomarkerAssessmentQuery,
},
+21 -9
View File
@@ -88,13 +88,13 @@ export const metricStatusResponse = z
hasProvider: z
.boolean()
.describe(
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
"Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.",
),
text: z
.string()
.nullable()
.describe(
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.",
"The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, when the metric has insufficient data, and whenever the `statusText` capability is unavailable (see `ai`).",
),
cached: z
.boolean()
@@ -119,8 +119,11 @@ export const metricStatusResponse = z
.boolean()
.optional()
.describe(
"True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.",
"True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state. Still computed while `ai` is unavailable.",
),
ai: aiCapabilityState.describe(
"The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed.",
),
})
.meta({
id: "MetricStatusResponse",
@@ -728,13 +731,13 @@ export const insightStatusResponse = z
hasProvider: z
.boolean()
.describe(
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
"Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.",
),
text: z
.string()
.nullable()
.describe(
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.",
"The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, and whenever the `statusText` capability is unavailable (see `ai`).",
),
cached: z
.boolean()
@@ -755,6 +758,9 @@ export const insightStatusResponse = z
.describe(
"True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.",
),
ai: aiCapabilityState.describe(
"The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed.",
),
})
.meta({
id: "InsightStatusResponse",
@@ -786,13 +792,13 @@ export const biomarkerAssessmentResponse = z
hasProvider: z
.boolean()
.describe(
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
"Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.",
),
text: z
.string()
.nullable()
.describe(
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the marker has no numeric readings.",
"The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, when the marker has no numeric readings, and whenever the `statusText` capability is unavailable (see `ai`).",
),
cached: z
.boolean()
@@ -819,6 +825,9 @@ export const biomarkerAssessmentResponse = z
.describe(
"True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered.",
),
ai: aiCapabilityState.describe(
"The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed.",
),
})
.meta({
id: "BiomarkerAssessmentResponse",
@@ -834,13 +843,13 @@ export const medicationComplianceStatusResponse = z
hasProvider: z
.boolean()
.describe(
"False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.",
"Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.",
),
summary: z
.string()
.nullable()
.describe(
"The overall compliance narrative (plain text). Null while a first generation is preparing.",
"The overall compliance note (plain text). Null while a first generation is preparing, and whenever the `statusText` capability is unavailable (see `ai`).",
),
medications: z
.array(
@@ -879,6 +888,9 @@ export const medicationComplianceStatusResponse = z
.describe(
"True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).",
),
ai: aiCapabilityState.describe(
"The `statusText` capability behind the note. While it is unavailable `summary` is null, `medications` is empty, `preparing` is false, the cache is not read and nothing is warmed.",
),
})
.meta({
id: "MedicationComplianceStatusResponse",
@@ -367,3 +367,55 @@ describe("the snapshot applies the capability per read", () => {
expect(after.data.briefingAi.reason).toBe("operator_disabled");
});
});
describe("the status family", () => {
async function readStatus(path: string) {
const route =
path === "weight"
? await import("@/app/api/insights/weight-status/route")
: await import("@/app/api/insights/metric-status/route");
const url =
path === "weight"
? "http://localhost/api/insights/weight-status"
: "http://localhost/api/insights/metric-status?metric=RESTING_HEART_RATE";
return json<Record<string, unknown>>(await route.GET(new NextRequest(url)));
}
it("S0 publishes the capability beside the note", async () => {
const world = await enterState("S0");
// The available path resolves (never calls) the provider chain, so the
// key has to decrypt.
const { encrypt } = await import("@/lib/crypto");
await getPrismaClient().user.update({
where: { id: world.recordId },
data: { aiAnthropicKeyEncrypted: encrypt("sk-ant-integration") },
});
const status = await readStatus("weight");
expect(status.status).toBe(200);
expect(status.data.ai).toMatchObject({ available: true });
});
it.each([
["S1", "operator_disabled", true],
["S3", "no_provider", false],
["S6", "user_disabled", true],
] as const)(
"%s answers 200 with no note and provider presence in hasProvider",
async (state, reason, hasProvider) => {
await enterState(state);
for (const path of ["weight", "metric"]) {
const status = await readStatus(path);
expect(status.status, path).toBe(200);
expect(status.data, path).toMatchObject({
text: null,
preparing: false,
hasProvider,
ai: { available: false, reason },
});
}
const metric = await readStatus("metric");
// No readings were seeded: the data half still says so.
expect(metric.data.insufficient).toBe(true);
},
);
});