Files
HealthLog/src/app/api/insights/labs-changes/route.ts
T
Marc-André Bombeck 3309ac177c feat(ai): serve Insights and Coach data whatever AI says
Data never depends on AI. The Insights data reads no longer ask an AI
switch or the `insights` module (now only the AI analysis opt-out): ECG
list, detail and upload, rhythm events, derived scores, rule cards,
correlations and patterns, the baseline strip, health status, intraday
pulse, and the Today digest with its dismiss. Breathing screening moves
to the sleep module and lab changes to the labs module. Stored Coach
facts, conversations, message ratings and the seen stamp stay readable
and erasable while the Coach is unavailable. Cards now say
`provider: "rules"`.

Reads that mix data with model text answer 200, null the model text
and publish an `ai` state from the capability resolver, without warming
anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider`
from the presence probe), the derived assessment override, the period
narrative, nudge status, the seeded opener, about-me questions, the
workout paragraph, the pregenerate warm (`queued: false`), the Today
digest (lead, reaction line, check-in) and the dashboard briefing,
which is now applied per read after the snapshot cache. Hiding the
Coach no longer hides the briefing.

`insights/generate` GET and POST ask the `briefing` capability and
`insights/chat` POST the `coach` capability, with the typed refusal
envelope; a missing provider keeps the chat's `coach.provider.none`
frame and adds `reason`. The /coach page, the MCP ECG and intraday
tools and `aiAvailable` on the provider route follow the same answer.

An inventory of AI and data routes replaces the Coach gate inventory:
every route that asks a capability names it, and no data route asks.

Refs #975
2026-09-24 09:19:36 +02:00

87 lines
3.1 KiB
TypeScript

/**
* v1.25 — "what changed since your last panel" read.
*
* GET /api/insights/labs-changes
*
* Groups the user's live numeric lab results by panel date, pairs the two
* most-recent panels, and reports the per-analyte delta + reference-band
* standing (`summariseLabChanges`). Absent when there are fewer than two
* panels, no analyte is shared, or the newest panel is older than the
* recency window — the card's "since your last panel" wording is a claim
* about recent news, and it stops being made rather than being made about a
* years-old draw. Neutral framing only — a delta is not a diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `labs`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
* session; soft-deleted rows (`deletedAt`) are filtered out.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { summariseLabChanges } from "@/lib/insights/labs-changes";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
// Lab deltas belong to labs: with the labs module off the card hides like
// every other labs surface. Not an AI read, so the `insights` module (the
// AI analysis opt-out) does not gate it.
const m = await requireModuleEnabled(user.id, "labs");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const rows = await prisma.labResult.findMany({
where: { userId: user.id, deletedAt: null, value: { not: null } },
select: {
analyte: true,
unit: true,
value: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
},
orderBy: { takenAt: "desc" },
});
const summary = summariseLabChanges(
rows.map((r) => ({
analyte: r.analyte,
unit: r.unit,
value: r.value as number,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
sourceReferenceHigh: r.sourceReferenceHigh,
sourceReferenceText: r.sourceReferenceText,
takenAt: r.takenAt,
})),
);
annotate({
action: { name: "insights.labs-changes.read" },
meta: {
present: summary.present,
changes: summary.changes.length,
// Distinguishes "no comparable panels" from "the comparison aged out",
// so an absent card is diagnosable without reaching for the database.
lab_rows: rows.length,
},
});
return apiSuccess({ ...summary, generatedAt: new Date().toISOString() });
});