Files
HealthLog/src/lib/openapi/routes/ai-refusal.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

38 lines
2.1 KiB
TypeScript

/**
* The published wording for an AI capability refusal, shared by every
* operation that asks `requireAiCapability`.
*
* One envelope, three statuses: 403 for the operator, record, module, opt-out
* and consent reasons; 422 for a missing provider (unless the route keeps a
* typed code of its own); 503 when the capability could not be resolved. The
* mapping from reason to `meta.errorCode` is written once on `ErrorEnvelope`
* (`meta.reason`); these strings name the capability and point there.
*/
import {
AI_CAPABILITIES,
type AiCapabilityKey,
} from "@/lib/ai/capabilities/types";
import { errorEnvelope } from "./shared";
/** The 403 sentence for one capability. */
export function aiRefusal403Description(key: AiCapabilityKey): string {
const operatorSwitch = AI_CAPABILITIES[key].operatorSwitch;
return `The \`${key}\` AI capability is unavailable for this record. \`meta.capability\` = \`${key}\` and \`meta.reason\` says which layer refused: the operator's switch (\`assistant.disabled.${operatorSwitch}\`, or \`assistant.disabled.enabled\` for the master switch), an owning module or the person's own AI opt-out (\`module.disabled\` with \`meta.module\`), a delegate inside somebody else's record (\`ai.record.notPermitted\`), or a missing consent receipt (\`consent.ai.required\`). Nothing was generated, read from a stored model answer, or enqueued.`;
}
/** The 422 a capability answers when no provider can serve it. */
export function aiNoProviderResponse(key: AiCapabilityKey) {
return {
description: `No configured AI provider can serve the \`${key}\` capability: \`meta.errorCode\` = \`ai.provider.none\`, \`meta.reason\` = \`no_provider\`.`,
content: { "application/json": { schema: errorEnvelope } },
};
}
/** The 503 every capability answers when its inputs could not be read. */
export const aiCheckFailedResponse = {
description:
"The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it.",
content: { "application/json": { schema: errorEnvelope } },
};