mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
9f1d1341f9
commit
1f6a13e2f7
19 files changed
+621
-207
No files matched your search
+77
-25
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user