From ad72ae3e4cdbd42bcaa9d6152f0d72697100ed84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 10:45:18 +0200 Subject: [PATCH] feat(coach): serve a message's stored tables and publish the stream contract GET /api/insights/chat/{id}/messages/{messageId}/results returns the tables one assistant message read, narrowed to the conversation's owner like the conversation read, with a table withheld as module_disabled when its domain's module is off and as unavailable when it cannot be read. A data read: no AI capability. The published document now describes every stream frame as the CoachStreamEvent union, including the suggestedAction frame it never named, the new step, result, followUps and clarification frames, their components, the provenance fields, the followUp and clarification request fields, and the reuse provider type. --- docs/api/openapi.yaml | 1381 +++++++++++++++-- src/app/api/__tests__/ai-route-inventory.ts | 2 + .../results/__tests__/route.test.ts | 86 + .../messages/[messageId]/results/route.ts | 53 + src/lib/ai/coach/results/domain-module.ts | 40 + src/lib/ai/coach/stream-events.ts | 2 +- src/lib/openapi/routes/coach.ts | 126 +- 7 files changed, 1523 insertions(+), 167 deletions(-) create mode 100644 src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts create mode 100644 src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts create mode 100644 src/lib/ai/coach/results/domain-module.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 824b25a94..00b530298 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8879,18 +8879,27 @@ paths: - 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 }`), `suggestion` - (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ - type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, - messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, - completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, - code, message, reason? }`). 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. Requires the `coach` AI capability, checked right after auth: an - unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, - which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. - Auth via cookie or Bearer.' + `text/event-stream`, not JSON: one `data: \n\n` frame per event, each a `CoachStreamEvent` dispatched on + `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{ + type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains, + windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence + envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`, + zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{ + type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`; + nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification` + (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with + `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type, + followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional + reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and + `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ + totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never + recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a + top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. 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. + Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the + capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` + error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.' requestBody: required: true content: @@ -8899,12 +8908,12 @@ paths: $ref: "#/components/schemas/CoachChatRequest" responses: "200": - description: Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames. + description: "Server-Sent Events stream: each `data: \\n\\n` frame is one `CoachStreamEvent`. Keepalive comment + lines (`: ka`) carry no data and are ignored." content: text/event-stream: schema: - type: string - description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." + $ref: "#/components/schemas/CoachStreamEvent" "401": *a1 "403": description: "The `coach` AI capability is unavailable for this record. `meta.capability` = `coach` and `meta.reason` @@ -8931,6 +8940,47 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/insights/chat/{id}/messages/{messageId}/results: + get: + tags: + - Insights + summary: Read the tables one Coach message read + description: "v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns + the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full + `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is + switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables + answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are + stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id + maps to 404 (never 403). Auth via cookie or Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + - name: messageId + in: path + required: true + schema: + type: string + description: Assistant message id within the conversation. + responses: + "200": + description: The message's tables, each served whole or withheld. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachMessageResultsResponse" + "401": *a1 + "404": + description: Conversation or message not found, or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/insights/chat/{id}: get: tags: @@ -25598,13 +25648,44 @@ components: workoutId: type: string maxLength: 64 + followUp: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + id: + type: string + minLength: 1 + maxLength: 8 + required: + - messageId + - id + clarification: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + choiceId: + type: string + minLength: 1 + maxLength: 8 + required: + - messageId required: - message - description: Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a + 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. + (client-side bubble, never persisted). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person + tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest + assistant message, and a chip that is no longer current is treated as a plain message. `clarification: { + messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer. + No `userId` field — the owner is narrowed from the session / Bearer." RenameCoachConversationRequest: type: object properties: @@ -37230,6 +37311,15 @@ components: anyOf: - type: string - type: "null" + resultsEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + resultsJson: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -49950,6 +50040,1181 @@ components: - documentId - title additionalProperties: false + CoachStreamEvent: + oneOf: + - type: object + properties: + type: + type: string + const: token + token: + type: string + required: + - type + - token + additionalProperties: false + - type: object + properties: + type: + type: string + const: provenance + metricSource: + $ref: "#/components/schemas/CoachProvenance" + required: + - type + - metricSource + additionalProperties: false + - type: object + properties: + type: + type: string + const: suggestion + suggestion: + $ref: "#/components/schemas/CoachSuggestion" + required: + - type + - suggestion + additionalProperties: false + - type: object + properties: + type: + type: string + const: suggestedAction + suggestedAction: + $ref: "#/components/schemas/CoachSuggestedActionCard" + required: + - type + - suggestedAction + additionalProperties: false + - type: object + properties: + type: + type: string + const: reasoning + text: + type: string + required: + - type + - text + additionalProperties: false + - type: object + properties: + type: + type: string + const: done + conversationId: + type: string + messageId: + type: string + usage: + $ref: "#/components/schemas/CoachUsage" + required: + - type + - conversationId + - messageId + additionalProperties: false + - type: object + properties: + type: + type: string + const: error + code: + type: string + message: + type: string + reason: + type: string + enum: + - check_failed + - operator_disabled + - not_permitted_for_record + - module_disabled + - user_disabled + - no_provider + - consent_required + required: + - type + - code + - message + additionalProperties: false + - type: object + properties: + type: + type: string + const: step + step: + $ref: "#/components/schemas/CoachStep" + required: + - type + - step + additionalProperties: false + - type: object + properties: + type: + type: string + const: result + result: + $ref: "#/components/schemas/CoachResultTable" + required: + - type + - result + additionalProperties: false + - type: object + properties: + type: + type: string + const: followUps + followUps: + type: array + items: + $ref: "#/components/schemas/CoachFollowUp" + required: + - type + - followUps + additionalProperties: false + - type: object + properties: + type: + type: string + const: clarification + clarification: + $ref: "#/components/schemas/CoachClarification" + required: + - type + - clarification + additionalProperties: false + description: "One Server-Sent Events frame of a Coach turn, sent as `data: \\n\\n` and dispatched on `type`. + Order: `step*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `clarification?` → + `followUps?` → `done`, or a single `error`. Clients ignore a `type` they do not know." + type: object + CoachProvenance: + type: object + properties: + windows: + readOnly: true + description: Analysis windows the assistant drew on this turn. + type: array + items: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + metrics: + readOnly: true + 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. + 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 + - general + counts: + description: Per-metric sample-count summary; absent on an empty snapshot. + type: object + propertyNames: + 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 + - general + 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. + readOnly: true + type: array + items: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 80 + value: + type: string + minLength: 1 + maxLength: 40 + unit: + type: string + maxLength: 16 + window: + type: string + maxLength: 40 + required: + - label + - value + additionalProperties: false + suggestion: + description: The cadence-suggestion card this turn carried, if any. + $ref: "#/components/schemas/CoachSuggestion" + suggestedAction: + description: The confirm-to-apply action card this turn carried, if any. + $ref: "#/components/schemas/CoachSuggestedActionCard" + toolCalls: + description: "v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found + data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools." + readOnly: true + type: array + items: + type: object + properties: + name: + type: string + present: + type: boolean + required: + - name + - present + additionalProperties: false + groundedFigures: + description: Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed, + label-less. + readOnly: true + type: array + items: + type: number + unverifiedFigures: + description: v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the + editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent + when the turn withheld nothing. + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + steps: + description: v1.39.4 — what the Coach read on this turn. + type: array + items: + $ref: "#/components/schemas/CoachStep" + method: + description: v1.39.4 — how the answer was reached. + $ref: "#/components/schemas/CoachMethod" + results: + description: v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint. + type: array + items: + $ref: "#/components/schemas/CoachResultMeta" + followUps: + description: v1.39.4 — the chips offered under this reply. + type: array + items: + $ref: "#/components/schemas/CoachFollowUp" + clarification: + description: v1.39.4 — the choices, when this reply is a question. + $ref: "#/components/schemas/CoachClarification" + forcedFinal: + description: v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise. + type: boolean + const: true + required: + - windows + - metrics + additionalProperties: false + description: Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence + key-values and cards. No raw timestamps and no table values. + CoachSuggestion: + type: object + properties: + cadenceId: + type: string + measurementType: + type: string + label: + type: string + required: + - cadenceId + - measurementType + - label + additionalProperties: false + description: A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`. + CoachSuggestedActionCard: + type: object + properties: + actionType: + type: string + enum: + - checkup.create + - reminder.note + summary: + type: string + titleKey: + type: string + params: + oneOf: + - type: object + properties: + actionType: + type: string + const: checkup.create + label: + type: string + interval: + type: string + required: + - actionType + - label + - interval + additionalProperties: false + - type: object + properties: + actionType: + type: string + const: reminder.note + note: + type: string + when: + type: string + metric: + type: string + required: + - actionType + - note + additionalProperties: false + type: object + required: + - actionType + - summary + - titleKey + - params + additionalProperties: false + description: A confirm-to-apply action card from a closed allowlist. Nothing is created until the person confirms it + through `POST /api/coach/suggested-actions`. + CoachStep: + type: object + properties: + id: + type: string + description: "`s1`..`s12`, unique within a turn." + tool: + type: string + enum: + - get_metric_series + - get_glucose_panel + - get_sleep + - get_medication_compliance + - get_labs + - get_illness_recovery + - get_workouts + - get_cycle + - get_correlations + - show_result + - snapshot + labelKey: + type: string + description: Closed catalog key (`coach.step.*`) the client localises. + label: + type: string + description: The label rendered on the server in the request locale. Render it when the client has no string for + `labelKey`. + domain: + $ref: "#/components/schemas/CoachStepDomain" + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + period: + type: string + enum: + - current + - previous + - yearAgo + granularity: + type: string + enum: + - day + - week + - month + status: + type: string + enum: + - running + - done + - empty + - failed + count: + description: Readings or rows the server counted. Never a health value. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + reason: + type: string + enum: + - no_data + - outside_window + - module_disabled + - retrieval_failed + - invalid_arguments + resultRef: + description: The table this step produced (`r1`..), when it produced one. + type: string + required: + - id + - tool + - labelKey + - label + - status + additionalProperties: false + description: "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` + / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: + never free text, an analyte name or a value." + CoachStepDomain: + 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 + - labs + - illness + - cycle + - correlations + - snapshot + description: "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a + whole (labs, illness, cycle, correlations, the full snapshot)." + CoachMethod: + type: object + properties: + entries: + type: array + items: + type: object + properties: + domain: + $ref: "#/components/schemas/CoachStepDomain" + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + period: + type: string + enum: + - current + - previous + - yearAgo + granularity: + type: string + enum: + - day + - week + - month + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + aggregation: + type: string + enum: + - mean + - median + - latest + - sum + - count + - rate + absent: + type: string + enum: + - no_data + - outside_window + - module_disabled + required: + - domain + additionalProperties: false + text: + type: string + description: Rendered on the server in the request locale. + required: + - entries + - text + additionalProperties: false + description: "How an answer was reached: which sources, windows, counts and aggregation. Never a health value." + CoachResultMeta: + type: object + properties: + ref: + type: string + description: "`r1`..`r6`, unique within a message." + source: + type: object + properties: + tool: + type: string + enum: + - get_metric_series + - get_glucose_panel + - get_sleep + - get_medication_compliance + - get_labs + - get_illness_recovery + - get_workouts + - get_cycle + - get_correlations + domain: + $ref: "#/components/schemas/CoachStepDomain" + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + period: + type: string + enum: + - current + - previous + - yearAgo + granularity: + type: string + enum: + - day + - week + - month + required: + - tool + - domain + - window + - period + additionalProperties: false + shape: + type: string + enum: + - timeSeries + - categoryCounts + - distribution + - single + titleKey: + type: string + title: + type: string + rowCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The full row count, before any trim. + chartKind: + anyOf: + - type: string + enum: + - line + - bar + - histogram + - type: "null" + displayed: + type: boolean + description: True when the answer referenced the table (shown expanded); false when it sits under the data-used + disclosure. + reusedFrom: + description: Set when the table was copied from an earlier message. + type: object + properties: + messageId: + type: string + ref: + type: string + required: + - messageId + - ref + additionalProperties: false + required: + - ref + - source + - shape + - titleKey + - title + - rowCount + - chartKind + - displayed + additionalProperties: false + description: "A table's metadata, persisted in the plaintext provenance (`metricSource.results`). The values are not + here: fetch them from `GET /api/insights/chat/{id}/messages/{messageId}/results`." + CoachFollowUp: + type: object + properties: + id: + type: string + description: "`f1`..`f3`." + kind: + type: string + enum: + - widen_window + - previous_period + - year_ago + - as_chart + - as_table + - related_metric + - continue + labelKey: + type: string + label: + type: string + description: Rendered on the server from a catalog; never model text. + anchor: + type: object + properties: + ref: + type: string + domain: + $ref: "#/components/schemas/CoachStepDomain" + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + granularity: + type: string + enum: + - day + - week + - month + period: + type: string + enum: + - current + - previous + - yearAgo + required: + - ref + - domain + additionalProperties: false + reuse: + type: boolean + description: True when the chip is answered from a stored table without a model call. + origin: + type: string + enum: + - server + - model + required: + - id + - kind + - labelKey + - label + - reuse + - origin + additionalProperties: false + description: "A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the + chip's label as `message`." + CoachClarification: + type: object + properties: + kind: + type: string + enum: + - metric + - window + - context + choices: + type: array + items: + type: object + properties: + id: + type: string + labelKey: + type: string + label: + type: string + value: + type: object + properties: + metric: + 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 + additionalProperties: false + required: + - id + - labelKey + - label + - value + additionalProperties: false + description: At most 4. Metric choices are always ones the record holds. + freeText: + type: boolean + description: Whether a typed answer is welcome. + required: + - kind + - choices + - freeText + additionalProperties: false + description: "The choices for a clarifying question. The question itself is the assistant message's text. Answer with + `clarification: { messageId, choiceId? }`." + CoachUsage: + type: object + properties: + totalTokens: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + promptTokens: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + completionTokens: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + model: + anyOf: + - type: string + - type: "null" + required: + - totalTokens + additionalProperties: false + description: "Per-turn token usage. Server-authoritative: clients display it, never recompute it." + CoachResultTable: + type: object + properties: + ref: + type: string + description: "`r1`..`r6`, unique within a message." + source: + type: object + properties: + tool: + type: string + enum: + - get_metric_series + - get_glucose_panel + - get_sleep + - get_medication_compliance + - get_labs + - get_illness_recovery + - get_workouts + - get_cycle + - get_correlations + domain: + $ref: "#/components/schemas/CoachStepDomain" + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + period: + type: string + enum: + - current + - previous + - yearAgo + granularity: + type: string + enum: + - day + - week + - month + required: + - tool + - domain + - window + - period + additionalProperties: false + shape: + type: string + enum: + - timeSeries + - categoryCounts + - distribution + - single + titleKey: + type: string + title: + type: string + rowCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The full row count, before any trim. + chartKind: + anyOf: + - type: string + enum: + - line + - bar + - histogram + - type: "null" + displayed: + type: boolean + description: True when the answer referenced the table (shown expanded); false when it sits under the data-used + disclosure. + reusedFrom: + description: Set when the table was copied from an earlier message. + type: object + properties: + messageId: + type: string + ref: + type: string + required: + - messageId + - ref + additionalProperties: false + columns: + type: array + items: + type: object + properties: + key: + type: string + kind: + type: string + enum: + - period + - category + - number + - count + labelKey: + type: string + label: + type: string + unit: + description: Display unit token such as `mmHg`, never free text. + type: string + decimals: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - kind + - labelKey + - label + additionalProperties: false + rows: + type: array + items: + type: array + items: + anyOf: + - type: string + - type: number + - type: "null" + description: Row-major cells, at most 400 rows. `null` is a period with no reading. + truncated: + type: boolean + description: True when `rowCount` exceeds the rows. + chart: + anyOf: + - oneOf: + - type: object + properties: + kind: + type: string + const: line + x: + type: string + series: + type: array + items: + type: string + required: + - kind + - x + - series + additionalProperties: false + - type: object + properties: + kind: + type: string + const: bar + x: + type: string + series: + type: array + items: + type: string + orientation: + type: string + enum: + - vertical + - horizontal + required: + - kind + - x + - series + - orientation + additionalProperties: false + - type: object + properties: + kind: + type: string + const: histogram + column: + type: string + unit: + type: string + bins: + type: array + items: + type: object + properties: + from: + type: number + to: + type: number + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - from + - to + - count + additionalProperties: false + required: + - kind + - column + - bins + additionalProperties: false + type: object + - type: "null" + description: The chart the server chose for the table, or null. + required: + - ref + - source + - shape + - titleKey + - title + - rowCount + - chartKind + - displayed + - columns + - rows + - truncated + - chart + additionalProperties: false + description: "A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns + the conversation." + CoachMessageResultsResponse: + type: object + properties: + data: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/CoachResultEntry" + required: + - results + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachResultEntry: + anyOf: + - $ref: "#/components/schemas/CoachResultTable" + - type: object + properties: + ref: + type: string + withheld: + type: string + enum: + - module_disabled + - unavailable + description: "`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables + could not be read." + required: + - ref + - withheld + additionalProperties: false + description: "One stored table, or the reason it is not served. Absence is explicit: a withheld table names why." CoachConversationDetailResponse: type: object properties: @@ -50053,9 +51318,11 @@ components: anyOf: - type: string - type: "null" - description: Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks + description: 'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should - render it as an interrupted turn with a retry affordance. Null for user turns. + render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn + answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no + model call: render it as a normal assistant turn without a token footer. Null for user turns.' promptVersion: anyOf: - type: string @@ -50087,82 +51354,6 @@ components: - model 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 - toolCalls: - description: "v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found - data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools." - type: array - items: - type: object - properties: - name: - type: string - present: - type: boolean - required: - - name - - present - additionalProperties: false - unverifiedFigures: - description: v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the - editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent - when the turn withheld nothing. - type: integer - exclusiveMinimum: 0 - maximum: 9007199254740991 - 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. CoachConversationRenamed: type: object properties: diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 7e90462ed..617556132 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -217,6 +217,8 @@ export const DATA_ROUTES: Readonly> = { "One stored conversation: read, rename, delete.", "src/app/api/insights/chat/messages/[id]/feedback/route.ts": "A rating on a message that already exists.", + "src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts": + "The stored tables of one message: the person's record, withheld per module, never per AI capability.", "src/app/api/insights/breathing-screening/route.ts": "Device breathing data, owned by the sleep module.", "src/app/api/insights/labs-changes/route.ts": diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts new file mode 100644 index 000000000..b6bac0a18 --- /dev/null +++ b/src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +const { requireAuth, readMessageResults, resolveModuleMap } = vi.hoisted( + () => ({ + requireAuth: vi.fn(), + readMessageResults: vi.fn(), + resolveModuleMap: vi.fn(), + }), +); + +vi.mock("@/lib/api-handler", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + apiHandler: unknown>(handler: T) => + handler, + requireAuth, + }; +}); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap })); +vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults })); + +import { GET } from "../route"; + +const USER_ID = "user-1"; + +function call(id = "c1", messageId = "m1") { + return GET( + new NextRequest( + `http://localhost/api/insights/chat/${id}/messages/${messageId}/results`, + ), + { params: Promise.resolve({ id, messageId }) }, + ) as Promise; +} + +beforeEach(() => { + vi.clearAllMocks(); + requireAuth.mockResolvedValue({ user: { id: USER_ID } }); + resolveModuleMap.mockResolvedValue({ sleep: false, mood: true }); +}); + +describe("GET /api/insights/chat/[id]/messages/[messageId]/results", () => { + it("reads under the caller's own id and the path ids only", async () => { + readMessageResults.mockResolvedValue([]); + const response = await call("c1", "m1"); + expect(response.status).toBe(200); + expect(readMessageResults).toHaveBeenCalledWith( + USER_ID, + "c1", + "m1", + expect.any(Function), + ); + expect(await response.json()).toEqual({ + data: { results: [] }, + error: null, + }); + }); + + it("answers 404 for a message the caller does not own", async () => { + readMessageResults.mockResolvedValue(null); + const response = await call(); + expect(response.status).toBe(404); + }); + + it("withholds a domain whose module is switched off, and only that one", async () => { + readMessageResults.mockResolvedValue([]); + await call(); + const withhold = readMessageResults.mock.calls[0][3] as ( + domain: string, + ) => boolean; + expect(withhold("sleep")).toBe(true); + expect(withhold("mood")).toBe(false); + // No module owns blood pressure or labs: never withheld. + expect(withhold("bp")).toBe(false); + expect(withhold("labs")).toBe(false); + }); + + it("refuses an unauthenticated caller before any read", async () => { + requireAuth.mockRejectedValue(new Error("Not authenticated")); + await expect(call()).rejects.toThrow("Not authenticated"); + expect(readMessageResults).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts new file mode 100644 index 000000000..29d6e1214 --- /dev/null +++ b/src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/insights/chat/[id]/messages/[messageId]/results — the tables of + * values one assistant message read, decrypted for their owner. + * + * A data read, never AI-gated: the tables are the person's stored record, + * readable while the Coach is unavailable for any reason, like the + * conversation they belong to. Narrowed exactly like + * `GET /api/insights/chat/[id]`: the message must sit in a conversation the + * caller owns, and a foreign or unknown id is 404, never 403. + * + * Each table is served whole or withheld with a reason: `module_disabled` + * when its domain's module is switched off for the record now (the table is + * not even decrypted), `unavailable` when the stored tables cannot be read. + * The list follows the order of `metricSource.results` on the message. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { readMessageResults } from "@/lib/ai/coach/persistence"; +import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module"; + +interface RouteCtx { + params: Promise<{ id: string; messageId: string }>; +} + +export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => { + const auth = await requireAuth(); + const userId = auth.user.id; + const { id, messageId } = await ctx.params; + if (!id || !messageId) return apiError("coach.message.notFound", 404); + + const modules = await resolveModuleMap(userId); + const results = await readMessageResults(userId, id, messageId, (domain) => + isCoachDomainWithheld(domain, modules), + ); + if (!results) return apiError("coach.message.notFound", 404); + + annotate({ + action: { name: "insights.coach.results.read" }, + meta: { + conversationId: id, + tables: results.length, + withheld: results.filter((entry) => "withheld" in entry).length, + }, + }); + + return apiSuccess({ results }); +}); + +export const dynamic = "force-dynamic"; diff --git a/src/lib/ai/coach/results/domain-module.ts b/src/lib/ai/coach/results/domain-module.ts new file mode 100644 index 000000000..624259e07 --- /dev/null +++ b/src/lib/ai/coach/results/domain-module.ts @@ -0,0 +1,40 @@ +/** + * Which module owns the domain a stored Coach table is about, so a table + * read after the module was switched off is withheld rather than served. + * + * The same ownership the Coach applies when it reads: the measurement-level + * table in `MODULE_SCOPED_SOURCES`, plus the cycle and illness domains, + * which the Coach gates on their own modules. Labs, medication compliance, + * correlations and the full snapshot answer to no module there, so none + * here either — gating them on this read would make a stored answer + * stricter than the Coach that wrote it. + */ +import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope"; +import type { ModuleKey } from "@/lib/modules/registry"; +import type { CoachStepDomain } from "@/lib/ai/coach/types"; + +const OWNER_BY_DOMAIN: ReadonlyMap = (() => { + const out = new Map([ + ["cycle", "cycle"], + ["illness", "illness"], + ]); + for (const [key, sources] of Object.entries(MODULE_SCOPED_SOURCES)) { + for (const source of sources ?? []) out.set(source, key as ModuleKey); + } + return out; +})(); + +export function moduleForCoachDomain( + domain: CoachStepDomain, +): ModuleKey | null { + return OWNER_BY_DOMAIN.get(domain) ?? null; +} + +/** True when the domain's module is switched off in `modules`. */ +export function isCoachDomainWithheld( + domain: CoachStepDomain, + modules: Readonly>, +): boolean { + const owner = moduleForCoachDomain(domain); + return owner !== null && modules[owner] === false; +} diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index a6ddccff0..c30ce16d9 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -330,7 +330,7 @@ const coachSuggestedActionSchema = z ]), }) .meta({ - id: "CoachSuggestedAction", + id: "CoachSuggestedActionCard", description: "A confirm-to-apply action card from a closed allowlist. Nothing is created until the person confirms it through `POST /api/coach/suggested-actions`.", }); diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 86fa35cea..4a0ca89ee 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -35,6 +35,11 @@ import { coachPlanPatchSchema, } from "@/lib/validations/coach-plan"; import { coachChatRequestSchema } from "@/lib/ai/coach/types"; +import { + coachProvenanceSchema, + coachResultEntrySchema, + coachStreamEventSchema, +} from "@/lib/ai/coach/stream-events"; import { coachAttachmentCreateSchema, fencedChatRequestSchema, @@ -468,68 +473,8 @@ const coachSuggestedActionResultResponse = z // 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.", - ), - toolCalls: z - .array( - z.object({ - name: z.string(), - present: z.boolean(), - }), - ) - .optional() - .describe( - "v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.", - ), - unverifiedFigures: z - .number() - .int() - .positive() - .optional() - .describe( - "v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing.", - ), - }) - .meta({ - id: "CoachProvenance", - description: - "Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.", - }); +// The provenance envelope and the stream frames come from the Coach's own +// wire mirror, which a type test holds equal to the TypeScript contract. const coachMessageSchema = z .object({ @@ -546,7 +491,7 @@ const coachMessageSchema = z .string() .nullable() .describe( - "Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. Null for user turns.", + 'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no model call: render it as a normal assistant turn without a token footer. Null for user turns.', ), promptVersion: z .string() @@ -720,7 +665,7 @@ const coachAttachmentsResponseSchema = z const coachChatRequest = 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.", + "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). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest assistant message, and a chip that is no longer current is treated as a plain message. `clarification: { messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer. No `userId` field — the owner is narrowed from the session / Bearer.", }); const anamnesisFactCurrentExistsResponse = { @@ -790,7 +735,7 @@ export const coachPaths: NonNullable = { 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 }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message, reason? }`). 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. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.', + '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, each a `CoachStreamEvent` dispatched on `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{ type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains, windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`, zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`; nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification` (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type, followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. 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. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.', requestBody: { required: true, content: { @@ -800,14 +745,10 @@ export const coachPaths: NonNullable = { responses: { "200": { description: - "Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.", + "Server-Sent Events stream: each `data: \\n\\n` frame is one `CoachStreamEvent`. Keepalive comment lines (`: ka`) carry no data and are ignored.", content: { "text/event-stream": { - schema: { - type: "string", - description: - "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes.", - }, + schema: coachStreamEventSchema, }, }, }, @@ -824,6 +765,49 @@ export const coachPaths: NonNullable = { }, }, }, + "/api/insights/chat/{id}/messages/{messageId}/results": { + get: { + tags: ["Insights"], + summary: "Read the tables one Coach message read", + description: + "v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id maps to 404 (never 403). Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Conversation id.", + }, + { + name: "messageId", + in: "path", + required: true, + schema: { type: "string" }, + description: "Assistant message id within the conversation.", + }, + ], + responses: { + "200": { + description: "The message's tables, each served whole or withheld.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ results: z.array(coachResultEntrySchema) }), + "CoachMessageResultsResponse", + ), + }, + }, + }, + "404": { + description: + "Conversation or message not found, or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/chat/{id}": { get: { tags: ["Insights"],