diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts new file mode 100644 index 000000000..47dfa4d78 --- /dev/null +++ b/src/lib/ai/capabilities/types.ts @@ -0,0 +1,334 @@ +/** + * AI capabilities: the vocabulary. + * + * HealthLog works without AI. Every measurement, device record, computed score, + * statistic and template sentence is served and accepted whatever the + * assistant switches, the provider, the consent receipts or the Coach + * preference say. What those layers decide is narrower: whether a model may be + * called, and whether text a model wrote may be shown. That decision is made + * once, on the server, per request and record, and it is published as one + * object per capability below. Clients render from it and never recompute it. + * + * This file holds only the vocabulary and the frozen capability table. It has + * no I/O and imports nothing that does, so every other part of the system — + * the resolver, the loader, the route and job gates, the account payload, the + * contract, the structural guards — can depend on it without pulling a + * database client along. + */ +import type { ModuleKey } from "@/lib/modules/registry"; + +/** Every AI capability, in publication order. */ +export const AI_CAPABILITY_KEYS = [ + "coach", + "briefing", + "periodNarrative", + "statusText", + "workoutInsights", + "reactionLines", + "aboutMeQuestions", + "documentAi", + "labsOcr", + "medicationExtract", +] as const; + +export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number]; + +/** + * Why a capability is unavailable, listed in precedence order: the first + * reason that applies is the one reported, because it names the layer that + * would have to change first. The order runs outside-in, like `moduleAccess` + * (`unavailable > not_granted > disabled`). + * + * - `check_failed`: an input could not be loaded. Fail closed; safe, because + * no data depends on a capability. + * - `operator_disabled`: the operator's master switch, the capability's own + * switch, or the operator's instance-wide availability of its module. + * - `not_permitted_for_record`: provider work is not admitted for this record + * (a delegate inside somebody else's record). + * - `module_disabled`: an owning module is off for this record, by the + * record's own switch or by the edge of the active grant. + * - `user_disabled`: the record's own AI opt-out: `disableCoach` for the + * Coach, the `insights` module ("AI analysis") for everything else. + * - `no_provider`: no configured provider can serve this capability's input. + * - `consent_required`: the provider chain needs a consent receipt and none + * is active. + */ +export const AI_UNAVAILABLE_REASONS = [ + "check_failed", + "operator_disabled", + "not_permitted_for_record", + "module_disabled", + "user_disabled", + "no_provider", + "consent_required", +] as const; + +export type AiUnavailableReason = (typeof AI_UNAVAILABLE_REASONS)[number]; + +/** One capability, resolved for one record. */ +export interface AiCapabilityState { + available: boolean; + /** `null` exactly when `available` is true. */ + reason: AiUnavailableReason | null; + /** + * Whether an on-device model may do this work. The operator's and the + * person's decisions hold on the device too; a missing server provider or a + * missing consent receipt for server egress do not, because an on-device + * model neither needs the one nor egresses. Resolved here so a native client + * never has to know which reasons are which. + */ + onDeviceAllowed: boolean; +} + +/** The reasons an on-device model is still allowed through. */ +export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet = + new Set(["no_provider", "consent_required"]); + +/** + * The operator's assistant switches below the master. Each maps to a cost or + * egress profile an operator can reason about: the Coach, the daily briefing + * and period narratives, per-reading status notes (the dominant token spend), + * and reading documents (the largest egress). + */ +export const AI_OPERATOR_SWITCHES = [ + "coach", + "briefing", + "insightStatus", + "documentAi", +] as const; + +export type AiOperatorSwitch = (typeof AI_OPERATOR_SWITCHES)[number]; + +/** + * The operator switch set, master applied: when `enabled` is false every + * sub-switch reads false, so a reader never composes the two. + */ +export type AiOperatorSwitchSet = { enabled: boolean } & Record< + AiOperatorSwitch, + boolean +>; + +/** + * Consent receipt kinds the resolver reads. `ai_extraction` is the narrower + * grant the document auto-read toggle mints; `ai_full` satisfies every group. + */ +export type AiConsentKind = + "ai_coach" | "ai_insights_only" | "ai_full" | "ai_extraction"; + +/** + * When a consent receipt is needed at all. + * + * - `self-snapshot`: only when the chain can egress through a credential the + * operator holds (the operator's key or the shared central Codex). A + * person's own key, their own ChatGPT account and a local model are their + * own egress, and configuring them was the consent act. + * - `document`: whenever the input leaves the machine at all. A scanned + * letter or a lab report is a document, and sending one to any third-party + * service needs an explicit receipt; only a local model stays exempt. + */ +export type AiConsentRule = "self-snapshot" | "document"; + +/** + * What a capability hands the model, which decides which providers can serve + * it. `text` needs any provider; `document` needs one that can read an image, + * or a text provider paired with the person's in-browser OCR. + */ +export type AiModality = "text" | "document"; + +/** + * Which order the provider chain is read in when the capability picks a single + * provider rather than cascading. The document class prefers local, then the + * person's own keys, then the operator's key, with the ChatGPT-subscription + * paths last; the labs scan keeps the chain's own order. + */ +export type AiProviderOrder = "chain" | "document"; + +export interface AiCapabilityDefinition { + key: AiCapabilityKey; + /** The operator sub-switch that covers it; the master covers everything. */ + operatorSwitch: AiOperatorSwitch; + /** + * The modules that must be on for this record. `all` needs every one; `any` + * needs at least one (the about-me questions serve both the Coach and the + * AI analysis, and either consumer is enough). + */ + modules: { mode: "all" | "any"; keys: readonly ModuleKey[] }; + /** Consent: when a receipt is needed, and which kinds satisfy it. */ + consent: { rule: AiConsentRule; kinds: readonly AiConsentKind[] }; + modality: AiModality; + providerOrder: AiProviderOrder; +} + +/** + * The modules whose own switch IS the person's AI opt-out. Turned off by the + * record, they report `user_disabled`; every other owning module turned off by + * the record reports `module_disabled`. + */ +export const AI_OPT_OUT_MODULES: ReadonlySet = new Set([ + "coach", + "insights", +]); + +const COACH_KINDS: readonly AiConsentKind[] = ["ai_coach", "ai_full"]; +const INSIGHTS_KINDS: readonly AiConsentKind[] = [ + "ai_insights_only", + "ai_full", +]; +const EXTRACTION_KINDS: readonly AiConsentKind[] = ["ai_extraction", "ai_full"]; + +/** + * The capability table. Frozen, total over {@link AI_CAPABILITY_KEYS}, and the + * one place a capability's layers are declared: the resolver, the guards and + * the contract all read it. + */ +export const AI_CAPABILITIES: Readonly< + Record> +> = Object.freeze({ + // Chat, fenced document chat inside a conversation, attachments, memory + // refresh, AI nudges, and every Coach launcher. + coach: { + key: "coach", + operatorSwitch: "coach", + modules: { mode: "all", keys: ["coach"] }, + consent: { rule: "self-snapshot", kinds: COACH_KINDS }, + modality: "text", + providerOrder: "chain", + }, + // The daily briefing and every place its text is lifted into. + briefing: { + key: "briefing", + operatorSwitch: "briefing", + modules: { mode: "all", keys: ["insights"] }, + consent: { rule: "self-snapshot", kinds: INSIGHTS_KINDS }, + modality: "text", + providerOrder: "chain", + }, + // The model-written half of a period narrative. The deterministic narrative + // is data and is always produced. + periodNarrative: { + key: "periodNarrative", + operatorSwitch: "briefing", + modules: { mode: "all", keys: ["insights"] }, + consent: { rule: "self-snapshot", kinds: INSIGHTS_KINDS }, + modality: "text", + providerOrder: "chain", + }, + // Per-metric status notes and the AI override of a derived assessment. + statusText: { + key: "statusText", + operatorSwitch: "insightStatus", + modules: { mode: "all", keys: ["insights"] }, + consent: { rule: "self-snapshot", kinds: INSIGHTS_KINDS }, + modality: "text", + providerOrder: "chain", + }, + workoutInsights: { + key: "workoutInsights", + operatorSwitch: "insightStatus", + modules: { mode: "all", keys: ["insights", "workouts"] }, + consent: { rule: "self-snapshot", kinds: INSIGHTS_KINDS }, + modality: "text", + providerOrder: "chain", + }, + // The one-line reaction written after a new reading arrives. + reactionLines: { + key: "reactionLines", + operatorSwitch: "insightStatus", + modules: { mode: "all", keys: ["insights"] }, + consent: { rule: "self-snapshot", kinds: INSIGHTS_KINDS }, + modality: "text", + providerOrder: "chain", + }, + // Model-written follow-up questions on the about-me profile. A deterministic + // fallback set is data and stays. + aboutMeQuestions: { + key: "aboutMeQuestions", + operatorSwitch: "coach", + modules: { mode: "any", keys: ["coach", "insights"] }, + consent: { + rule: "self-snapshot", + kinds: ["ai_coach", "ai_insights_only", "ai_full"], + }, + modality: "text", + providerOrder: "chain", + }, + // Suggest, summary, extract, index and chat over a stored document. + documentAi: { + key: "documentAi", + operatorSwitch: "documentAi", + modules: { mode: "all", keys: ["inboundDocuments"] }, + consent: { rule: "document", kinds: EXTRACTION_KINDS }, + modality: "document", + providerOrder: "document", + }, + // Reading a lab report image into structured results. + labsOcr: { + key: "labsOcr", + operatorSwitch: "documentAi", + modules: { mode: "all", keys: ["labs"] }, + consent: { rule: "document", kinds: EXTRACTION_KINDS }, + modality: "document", + providerOrder: "chain", + }, + // Turning a typed medication description into a structured schedule. + // Medications is a core domain for this purpose, so no module owns it. + medicationExtract: { + key: "medicationExtract", + operatorSwitch: "documentAi", + modules: { mode: "all", keys: [] }, + consent: { rule: "document", kinds: EXTRACTION_KINDS }, + modality: "text", + providerOrder: "chain", + }, +}); + +/** + * Where the provider that would serve a record comes from, presence only. + * + * - `user`: the person's own credential (their key, their ChatGPT account, + * their own OpenAI-compatible gateway). + * - `local`: a self-hosted model the person pointed at. + * - `server`: a credential the operator holds (the operator's key, or the + * shared central Codex the person opted into). + */ +export type AiProviderManagedBy = "user" | "local" | "server"; + +/** Account-level companion to the capability map. */ +export interface AiProviderState { + /** At least one configured provider can serve text for this record. */ + configured: boolean; + managedBy: AiProviderManagedBy | null; + /** + * Whether the person in front of the screen may set up a provider for this + * record: false inside somebody else's record (delegate or managed profile) + * and when the operator's master switch is off. + */ + canConfigure: boolean; +} + +/** The `ai` block the account payload publishes. */ +export interface AiCapabilities { + capabilities: Record; + provider: AiProviderState; +} + +// ── Refusal codes ────────────────────────────────────────────────────────── +// +// An AI action refused because a capability is unavailable answers with one +// envelope: `{ data: null, error, meta: { errorCode, capability, reason } }`. +// Every published code is kept; the three below are the new ones. The operator +// family stays the `assistant.disabled.` template, the module family +// stays `module.disabled` (with `meta.module`), and consent stays +// `consent.ai.required`. + +/** Provider work is not admitted for this record. */ +export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted"; +/** No configured provider can serve the capability. */ +export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none"; +/** The capability's inputs could not be loaded; the answer failed closed. */ +export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable"; + +/** Is this string a capability key? */ +export function isAiCapabilityKey(value: string): value is AiCapabilityKey { + return (AI_CAPABILITY_KEYS as readonly string[]).includes(value); +}