From 1f6a13e2f7cd34c8de627d2abe805a0ec175284e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:30:23 +0200 Subject: [PATCH] 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 --- docs/api/openapi.yaml | 102 +++++++++---- src/app/api/__tests__/ai-route-inventory.ts | 56 +++++--- .../__tests__/route.test.ts | 38 +++-- .../insights/biomarker-assessment/route.ts | 25 +++- .../__tests__/route.test.ts | 136 +++++++++--------- .../insights/blood-pressure-status/route.ts | 27 +++- src/app/api/insights/bmi-status/route.ts | 27 +++- .../medication-compliance-status/route.ts | 27 +++- .../metric-status/__tests__/route.test.ts | 52 +++++-- src/app/api/insights/metric-status/route.ts | 41 +++++- .../mood-status/__tests__/route.test.ts | 36 +++++ src/app/api/insights/mood-status/route.ts | 27 +++- src/app/api/insights/pulse-status/route.ts | 27 +++- src/app/api/insights/weight-status/route.ts | 27 +++- .../__tests__/no-dead-assistant-flag.test.ts | 12 +- src/lib/insights/status-unavailable.ts | 70 +++++++++ src/lib/openapi/routes/insights/paths.ts | 16 +-- src/lib/openapi/routes/insights/schemas.ts | 30 ++-- .../ai-optional-mixed-routes.test.ts | 52 +++++++ 19 files changed, 621 insertions(+), 207 deletions(-) create mode 100644 src/lib/insights/status-unavailable.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6b564e0a9..dded8bf92 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 7f3082664..8b619bfbe 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -74,6 +74,46 @@ export const AI_ROUTES: Readonly> = { 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> = { "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. */ diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 68aa38d33..0c5c973b3 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -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 }; + 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"); diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts index 5345c1723..2adc3c7f2 100644 --- a/src/app/api/insights/biomarker-assessment/route.ts +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 9f0ea1def..c5d8177c1 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -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()), - 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 }; + 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 }; + 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 }; + 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"); - }); -}); diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 380a68ad2..58976745a 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index f4dff8de7..160115c27 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 691f6903f..963003a93 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index 196d36573..9e229192e 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -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 }; + expect(body.data).toMatchObject({ + text: null, + preparing: false, + insufficient, + ai: { available: false, reason: "operator_disabled" }, + }); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index ac173fd2e..ac396068e 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/mood-status/__tests__/route.test.ts b/src/app/api/insights/mood-status/__tests__/route.test.ts index 87bdda898..a5cabeac5 100644 --- a/src/app/api/insights/mood-status/__tests__/route.test.ts +++ b/src/app/api/insights/mood-status/__tests__/route.test.ts @@ -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 }; + expect(body.data.text).toBeNull(); + expect(generateMoodStatusForUser).not.toHaveBeenCalled(); + }); + it("401s when unauthenticated", async () => { vi.mocked(getSession).mockResolvedValue(null); const res = await callGet(makeReq()); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index f2ad24afc..187dcaf71 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index 6366fcc71..ef290eceb 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -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 }); }); diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 275dc3da7..2e19b0c01 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -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 }); }); diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index 8f643998b..0955466e3 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -110,17 +110,7 @@ const AWAITING_READER: Record = { /** * 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)) { diff --git a/src/lib/insights/status-unavailable.ts b/src/lib/insights/status-unavailable.ts new file mode 100644 index 000000000..a205a7743 --- /dev/null +++ b/src/lib/insights/status-unavailable.ts @@ -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 { + 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 { + return { + hasProvider: await probeProviderPresence(recordId), + summary: null, + medications: [], + cached: false, + updatedAt: null, + preparing: false, + ai, + }; +} diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index e5d3aa103..79d2e749d 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -596,7 +596,7 @@ export const insightsPaths: NonNullable = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, }, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index ebbc08c88..a61492f8c 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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", diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts index fbe964713..f849f52d9 100644 --- a/tests/integration/ai-optional-mixed-routes.test.ts +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -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>(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); + }, + ); +});