From 4e78c2cfb22b37e615a64c6fb9d12e23e976bbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:00:57 +0200 Subject: [PATCH] docs(api): document coach conversation endpoints Register the four /api/insights/chat surfaces in the OpenAPI contract: list (cursor-paginated), streaming send (SSE), detail (messages decrypted server-side), and delete. Adds CoachConversation / CoachConversationsPage / CoachConversationDetail / CoachMessage / CoachProvenance / CoachChatRequest schemas and regenerates docs/api/openapi.yaml. Additive; the endpoints themselves are unchanged. --- docs/api/openapi.yaml | 459 ++++++++++++++++++++++++++++++++ src/lib/openapi/routes/coach.ts | 289 ++++++++++++++++++++ 2 files changed, 748 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5e957b8d5..6dc5a0f2a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3927,6 +3927,224 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/chat: + get: + tags: + - Insights + summary: List the caller's Coach conversations + description: v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent + activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. + `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at + the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; + the owner is narrowed from the session. + parameters: + - name: cursor + in: query + required: false + schema: + type: string + description: Id of the last conversation on the previous page. Omit for the first page. + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + description: Page size. Defaults to 20, capped at 50. + responses: + "200": + description: A page of the caller's conversations. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachConversationsPageResponse" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Insights + summary: Send a Coach turn (streaming reply) + description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is + `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk + of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ + type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a + provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame + types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and + rate-limited. Auth via cookie or Bearer." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + conversationId: + type: string + minLength: 1 + maxLength: 64 + message: + type: string + minLength: 1 + maxLength: 4000 + prefill: + type: string + maxLength: 2000 + locale: + type: string + enum: + - en + - de + scope: + type: object + properties: + sources: + maxItems: 40 + type: array + items: + type: string + enum: + - bp + - weight + - pulse + - mood + - compliance + - hrv + - sleep + - resting_hr + - steps + - active_energy + - flights + - distance + - vo2_max + - body_temp + - walking_hr + - respiratory_rate + - spo2 + - pulse_wave_velocity + - vascular_age + - body_fat + - fat_mass + - fat_free_mass + - muscle_mass + - lean_body_mass + - bone_mass + - total_body_water + - bmi + - visceral_fat + - glucose + - walking_steadiness + - walking_asymmetry + - walking_double_support + - walking_step_length + - walking_speed + - audio_env + - audio_headphone + - audio_event + - daylight + - skin_temp + - workouts + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + guidedQuestion: + type: string + minLength: 1 + maxLength: 500 + required: + - message + responses: + "200": + description: Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames. + content: + text/event-stream: + schema: + type: string + description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." + "401": *a1 + "403": + description: "Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: + consent.ai.required`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: Request body exceeds the 64 KB cap. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/insights/chat/{id}: + get: + tags: + - Insights + summary: Read one Coach conversation with all messages + description: v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus + the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is + involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. + Coach-gated. Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + responses: + "200": + description: The conversation with all decrypted messages. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachConversationDetailResponse" + "401": *a1 + "404": + description: Conversation not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Insights + summary: Delete one Coach conversation + description: v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never + 403). Coach-gated. Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + responses: + "200": + description: The conversation was deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachConversationDeleted" + "401": *a1 + "404": + description: Conversation not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -13771,6 +13989,247 @@ components: - data - error additionalProperties: false + CoachConversationsPageResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CoachConversationsPage" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachConversationsPage: + type: object + properties: + conversations: + type: array + items: + $ref: "#/components/schemas/CoachConversation" + nextCursor: + anyOf: + - type: string + - type: "null" + description: Id of the last conversation on this page; pass it back as `cursor` for the next page. Null at the end of + the list. + required: + - conversations + - nextCursor + additionalProperties: false + description: Cursor-paginated page of the caller's Coach conversations, most-recent activity first. + CoachConversation: + type: object + properties: + id: + type: string + title: + type: string + description: Title summarised from the first user message. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Bumped on every appended message; the rail orders by this. + messageCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - id + - title + - createdAt + - updatedAt + - messageCount + additionalProperties: false + description: Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt. + CoachConversationDetailResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CoachConversationDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachConversationDetail: + type: object + properties: + id: + type: string + title: + type: string + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + messageCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + messages: + type: array + items: + $ref: "#/components/schemas/CoachMessage" + description: Every message in the conversation, decrypted, oldest-first. + summary: + description: Rolling summary of turns elided past the history window; null when none is on file. + anyOf: + - type: string + - type: "null" + required: + - id + - title + - createdAt + - updatedAt + - messageCount + - messages + additionalProperties: false + description: Full conversation with every message decrypted server-side. The client renders the bodies directly; no + decryption key is involved. + CoachMessage: + type: object + properties: + id: + type: string + role: + type: string + enum: + - user + - assistant + content: + type: string + description: Decrypted message body — the server decrypts on read. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + metricSource: + anyOf: + - $ref: "#/components/schemas/CoachProvenance" + - type: "null" + description: Provenance envelope (assistant turns); null for user turns. + providerType: + anyOf: + - type: string + - type: "null" + description: Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns. + promptVersion: + anyOf: + - type: string + - type: "null" + description: Coach prompt version that produced the reply; null for user turns. + required: + - id + - role + - content + - createdAt + - metricSource + - providerType + - promptVersion + additionalProperties: false + description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation. + CoachProvenance: + type: object + properties: + windows: + type: array + items: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + description: Analysis windows the assistant drew on this turn. + metrics: + type: array + items: + type: string + description: Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot + sentinel. The client translates these labels; the server never localises them. + counts: + description: Per-metric sample-count summary; absent on an empty snapshot. + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + keyValues: + description: Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 + entries. + type: array + items: + type: object + properties: + label: + type: string + value: + type: string + unit: + type: string + window: + type: string + required: + - label + - value + additionalProperties: false + required: + - windows + - metrics + additionalProperties: false + description: Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence + key-values. No raw timestamps. + CoachConversationDeleted: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + description: Always true on success; a foreign / unknown id is a 404. + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InsightsLayout: type: object properties: diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 19222890c..92e5653e3 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -13,6 +13,7 @@ import { INSIGHTS_SECTION_IDS, } from "@/lib/insights-layout"; import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; +import { coachChatRequestSchema } from "@/lib/ai/coach/types"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; @@ -147,7 +148,295 @@ const coachFactDeletedResponse = z.object({ ), }); +// ── Coach conversation history (v1.18.0) ───────────────────────────── +// List + detail + delete surface for the Coach's persisted chat +// conversations. Bodies are stored encrypted at rest; the detail +// endpoint decrypts every message server-side, so the client never +// handles a key. The list endpoint stays metadata-only (no decryption). + +const coachProvenanceSchema = z + .object({ + windows: z + .array( + z.enum([ + "last7days", + "last30days", + "last90days", + "lastYear", + "allTime", + ]), + ) + .describe("Analysis windows the assistant drew on this turn."), + metrics: z + .array(z.string()) + .describe( + "Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.", + ), + counts: z + .record(z.string(), z.number().int()) + .optional() + .describe("Per-metric sample-count summary; absent on an empty snapshot."), + keyValues: z + .array( + z.object({ + label: z.string(), + value: z.string(), + unit: z.string().optional(), + window: z.string().optional(), + }), + ) + .optional() + .describe( + "Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.", + ), + }) + .meta({ + id: "CoachProvenance", + description: + "Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.", + }); + +const coachMessageSchema = z + .object({ + id: z.string(), + role: z.enum(["user", "assistant"]), + content: z + .string() + .describe("Decrypted message body — the server decrypts on read."), + createdAt: z.iso.datetime({ offset: true }), + metricSource: coachProvenanceSchema + .nullable() + .describe("Provenance envelope (assistant turns); null for user turns."), + providerType: z + .string() + .nullable() + .describe( + "Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns.", + ), + promptVersion: z + .string() + .nullable() + .describe("Coach prompt version that produced the reply; null for user turns."), + }) + .meta({ + id: "CoachMessage", + description: + "One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.", + }); + +const coachConversationSchema = z + .object({ + id: z.string(), + title: z.string().describe("Title summarised from the first user message."), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z + .iso.datetime({ offset: true }) + .describe("Bumped on every appended message; the rail orders by this."), + messageCount: z.number().int(), + }) + .meta({ + id: "CoachConversation", + description: + "Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt.", + }); + +const coachConversationsPageSchema = z + .object({ + conversations: z.array(coachConversationSchema), + nextCursor: z + .string() + .nullable() + .describe( + "Id of the last conversation on this page; pass it back as `cursor` for the next page. Null at the end of the list.", + ), + }) + .meta({ + id: "CoachConversationsPage", + description: + "Cursor-paginated page of the caller's Coach conversations, most-recent activity first.", + }); + +const coachConversationDetailSchema = z + .object({ + id: z.string(), + title: z.string(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + messageCount: z.number().int(), + messages: z + .array(coachMessageSchema) + .describe("Every message in the conversation, decrypted, oldest-first."), + summary: z + .string() + .nullable() + .optional() + .describe( + "Rolling summary of turns elided past the history window; null when none is on file.", + ), + }) + .meta({ + id: "CoachConversationDetail", + description: + "Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved.", + }); + +const coachConversationDeletedResponse = z.object({ + deleted: z + .literal(true) + .describe("Always true on success; a foreign / unknown id is a 404."), +}); + +coachChatRequestSchema.meta({ + id: "CoachChatRequest", + description: + "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.", +}); + export const coachPaths: NonNullable = { + "/api/insights/chat": { + get: { + tags: ["Insights"], + summary: "List the caller's Coach conversations", + description: + "v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). Coach-gated (`requireAssistantSurface(\"coach\")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.", + parameters: [ + { + name: "cursor", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Id of the last conversation on the previous page. Omit for the first page.", + }, + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + description: "Page size. Defaults to 20, capped at 50.", + }, + ], + responses: { + "200": { + description: "A page of the caller's conversations.", + content: { + "application/json": { + schema: dataEnvelope( + coachConversationsPageSchema, + "CoachConversationsPageResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Insights"], + summary: "Send a Coach turn (streaming reply)", + description: + "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: coachChatRequestSchema }, + }, + }, + responses: { + "200": { + description: + "Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.", + content: { + "text/event-stream": { + schema: { + type: "string", + description: + "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes.", + }, + }, + }, + }, + "403": { + description: + "Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: consent.ai.required`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds the 64 KB cap.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/chat/{id}": { + get: { + tags: ["Insights"], + summary: "Read one Coach conversation with all messages", + description: + "v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Conversation id.", + }, + ], + responses: { + "200": { + description: "The conversation with all decrypted messages.", + content: { + "application/json": { + schema: dataEnvelope( + coachConversationDetailSchema, + "CoachConversationDetailResponse", + ), + }, + }, + }, + "404": { + description: "Conversation not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Delete one Coach conversation", + description: + "v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never 403). Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Conversation id.", + }, + ], + responses: { + "200": { + description: "The conversation was deleted.", + content: { + "application/json": { + schema: dataEnvelope( + coachConversationDeletedResponse, + "CoachConversationDeleted", + ), + }, + }, + }, + "404": { + description: "Conversation not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/layout": { get: { tags: ["Insights"],