diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b73a874a5..e427a246d 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: @@ -37240,6 +37321,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 @@ -49960,6 +50050,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: @@ -50063,9 +51328,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 @@ -50097,82 +51364,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/messages/de.json b/messages/de.json index 5fea285f0..f37372452 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9480,7 +9480,180 @@ "promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.", "conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung." }, - "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten." + "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten.", + "step": { + "read": "Lese {domain}", + "readWindow": "Lese {domain}, {window}", + "snapshot": "Lese die Übersicht deiner Daten", + "showResult": "Öffne eine frühere Tabelle", + "reuse": "Nutze die Tabelle von vorhin", + "headerActive": "{label}…", + "headerDoneOne": "1 Quelle angesehen", + "headerDoneFew": "{count} Quellen angesehen", + "headerDoneOther": "{count} Quellen angesehen", + "listLabel": "Was der Coach angesehen hat", + "toggleShow": "Anzeigen, was der Coach angesehen hat", + "toggleHide": "Ausblenden, was der Coach angesehen hat", + "announceDone": "Fertig: {label}", + "readingsOne": "1 Messwert", + "readingsFew": "{count} Messwerte", + "readingsOther": "{count} Messwerte", + "rowsOne": "1 Zeile", + "rowsFew": "{count} Zeilen", + "rowsOther": "{count} Zeilen", + "metricsOne": "1 Messgröße", + "metricsFew": "{count} Messgrößen", + "metricsOther": "{count} Messgrößen", + "reason": { + "noData": "keine Messwerte", + "outsideWindow": "keine Messwerte in diesem Zeitraum", + "moduleDisabled": "ausgeschaltet", + "retrievalFailed": "konnte nicht gelesen werden", + "invalidArguments": "Anfrage nicht verstanden" + }, + "domain": { + "labs": "Laborwerte", + "illness": "Krankheit und Erholung", + "cycle": "Zyklus", + "correlations": "Zusammenhänge zwischen Messgrößen", + "snapshot": "Datenübersicht" + }, + "window": { + "last7days": "letzte 7 Tage", + "last30days": "letzte 30 Tage", + "last90days": "letzte 90 Tage", + "lastYear": "letzte 12 Monate", + "allTime": "gesamter Zeitraum" + }, + "period": { + "current": "dieser Zeitraum", + "previous": "der Zeitraum davor", + "yearAgo": "ein Jahr zuvor" + }, + "granularity": { + "day": "nach Tag", + "week": "nach Woche", + "month": "nach Monat" + } + }, + "result": { + "title": { + "byDay": "{metric} nach Tag", + "byWeek": "{metric} nach Woche", + "byMonth": "{metric} nach Monat", + "previousPeriod": "{metric}, Zeitraum davor", + "yearAgo": "{metric}, ein Jahr zuvor", + "workoutsBySport": "Trainings nach Sportart", + "labsLatest": "Neueste Laborwerte", + "sleepByNight": "Schlaf nach Nacht", + "complianceByDay": "Medikamenten-Einnahmetreue nach Tag", + "distribution": "{metric}: wie oft welcher Bereich vorkam" + }, + "column": { + "day": "Tag", + "week": "Woche", + "month": "Monat", + "night": "Nacht", + "sport": "Sportart", + "sessions": "Einheiten", + "analyte": "Laborwert", + "value": "Wert", + "unit": "Einheit", + "date": "Datum", + "referenceRange": "Referenzbereich", + "systolic": "Systolisch", + "diastolic": "Diastolisch", + "mean": "Durchschnitt", + "min": "Niedrigster", + "max": "Höchster", + "readings": "Messwerte", + "rate": "Einnahmetreue", + "duration": "Dauer", + "range": "Bereich", + "count": "Anzahl" + }, + "other": "Sonstige", + "noReading": "kein Messwert", + "showAll": "Alle anzeigen ({count})", + "showFewer": "Weniger anzeigen", + "copyMenu": "Tabelle kopieren", + "copyForSpreadsheet": "Für Tabellenkalkulation kopieren", + "copyAsText": "Als Text kopieren", + "copied": "Kopiert", + "copyFailed": "Die Tabelle konnte nicht kopiert werden", + "viewLabel": "Ansicht", + "viewChart": "Diagramm", + "viewTable": "Tabelle", + "chartSummary": "{title} als Diagramm. Die Tabellenansicht listet jeden Wert auf.", + "dataUsed": "Verwendete Daten ({count})", + "truncated": "Die ersten {shown} von {total} Zeilen", + "withheld": { + "moduleDisabled": "Diese Tabelle ist ausgeblendet, weil ihr Modul ausgeschaltet ist.", + "unavailable": "Diese Tabelle konnte nicht geladen werden." + }, + "loading": "Tabelle wird geladen…", + "loadFailed": "Die Tabellen konnten nicht geladen werden.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Wie das ermittelt wurde", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 Messwert", + "readingsFew": "{count} Messwerte", + "readingsOther": "{count} Messwerte", + "aggregation": { + "mean": "Durchschnitt", + "median": "Median", + "latest": "letzter Messwert", + "sum": "Summe", + "count": "Anzahl", + "rate": "Quote" + }, + "averages": { + "day": "Tagesdurchschnitte", + "week": "Wochendurchschnitte", + "month": "Monatsdurchschnitte" + }, + "totals": { + "day": "Tagessummen", + "week": "Wochensummen", + "month": "Monatssummen" + }, + "absent": { + "noData": "keine Messwerte", + "outsideWindow": "keine Messwerte in diesem Zeitraum", + "moduleDisabled": "ausgeschaltet" + } + }, + "followUp": { + "groupLabel": "Vorgeschlagene Anschlussfragen", + "widenWindow": "Weiter zurückschauen", + "previousPeriod": "Mit dem Zeitraum davor vergleichen", + "yearAgo": "Mit dem Vorjahr vergleichen", + "asChart": "Als Diagramm zeigen", + "asTable": "Als Tabelle zeigen", + "relatedMetric": "Auch {metric} ansehen", + "continue": "Weitersuchen", + "settingLabel": "Anschlussfragen vorschlagen", + "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage." + }, + "clarify": { + "cardLabel": "Der Coach hat eine Rückfrage", + "choicesLabel": "Mögliche Antworten", + "freeTextHint": "Du kannst auch selbst eine Antwort schreiben.", + "contextHint": "Schreib deine Antwort unten.", + "window": { + "last7days": "Letzte 7 Tage", + "last30days": "Letzte 30 Tage", + "last90days": "Letzte 90 Tage", + "lastYear": "Letzte 12 Monate", + "allTime": "Gesamter Zeitraum" + } + }, + "reuse": { + "caption": "Hier sind dieselben Werte aus meiner letzten Antwort, unverändert." + } }, "safety": { "floor": { diff --git a/messages/en.json b/messages/en.json index ff67a71e9..2a914438f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9480,7 +9480,180 @@ "promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.", "conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation." }, - "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers." + "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.", + "step": { + "read": "Reading {domain}", + "readWindow": "Reading {domain}, {window}", + "snapshot": "Reading your record summary", + "showResult": "Opening an earlier table", + "reuse": "Using the table from before", + "headerActive": "{label}…", + "headerDoneOne": "Looked at 1 source", + "headerDoneFew": "Looked at {count} sources", + "headerDoneOther": "Looked at {count} sources", + "listLabel": "What the Coach looked at", + "toggleShow": "Show what the Coach looked at", + "toggleHide": "Hide what the Coach looked at", + "announceDone": "Done: {label}", + "readingsOne": "1 reading", + "readingsFew": "{count} readings", + "readingsOther": "{count} readings", + "rowsOne": "1 row", + "rowsFew": "{count} rows", + "rowsOther": "{count} rows", + "metricsOne": "1 metric", + "metricsFew": "{count} metrics", + "metricsOther": "{count} metrics", + "reason": { + "noData": "no readings", + "outsideWindow": "no readings in this window", + "moduleDisabled": "switched off", + "retrievalFailed": "couldn't be read", + "invalidArguments": "request not understood" + }, + "domain": { + "labs": "Lab results", + "illness": "Illness and recovery", + "cycle": "Cycle", + "correlations": "Connections between metrics", + "snapshot": "Record summary" + }, + "window": { + "last7days": "last 7 days", + "last30days": "last 30 days", + "last90days": "last 90 days", + "lastYear": "last 12 months", + "allTime": "all time" + }, + "period": { + "current": "this period", + "previous": "the period before", + "yearAgo": "a year earlier" + }, + "granularity": { + "day": "by day", + "week": "by week", + "month": "by month" + } + }, + "result": { + "title": { + "byDay": "{metric} by day", + "byWeek": "{metric} by week", + "byMonth": "{metric} by month", + "previousPeriod": "{metric}, the period before", + "yearAgo": "{metric}, a year earlier", + "workoutsBySport": "Workouts by sport", + "labsLatest": "Latest lab results", + "sleepByNight": "Sleep by night", + "complianceByDay": "Medication adherence by day", + "distribution": "{metric}: how often each range came up" + }, + "column": { + "day": "Day", + "week": "Week", + "month": "Month", + "night": "Night", + "sport": "Sport", + "sessions": "Sessions", + "analyte": "Test", + "value": "Value", + "unit": "Unit", + "date": "Date", + "referenceRange": "Reference range", + "systolic": "Systolic", + "diastolic": "Diastolic", + "mean": "Average", + "min": "Lowest", + "max": "Highest", + "readings": "Readings", + "rate": "Adherence", + "duration": "Duration", + "range": "Range", + "count": "Times" + }, + "other": "Other", + "noReading": "no reading", + "showAll": "Show all ({count})", + "showFewer": "Show fewer", + "copyMenu": "Copy table", + "copyForSpreadsheet": "Copy for spreadsheet", + "copyAsText": "Copy as text", + "copied": "Copied", + "copyFailed": "Couldn't copy the table", + "viewLabel": "View", + "viewChart": "Chart", + "viewTable": "Table", + "chartSummary": "{title}, shown as a chart. The table view lists every value.", + "dataUsed": "Data used ({count})", + "truncated": "Showing the first {shown} of {total} rows", + "withheld": { + "moduleDisabled": "This table is hidden because its module is switched off.", + "unavailable": "This table couldn't be loaded." + }, + "loading": "Loading table…", + "loadFailed": "Couldn't load the tables.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "How this was worked out", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 reading", + "readingsFew": "{count} readings", + "readingsOther": "{count} readings", + "aggregation": { + "mean": "average", + "median": "median", + "latest": "latest reading", + "sum": "total", + "count": "count", + "rate": "rate" + }, + "averages": { + "day": "daily averages", + "week": "weekly averages", + "month": "monthly averages" + }, + "totals": { + "day": "daily totals", + "week": "weekly totals", + "month": "monthly totals" + }, + "absent": { + "noData": "no readings", + "outsideWindow": "no readings in this window", + "moduleDisabled": "switched off" + } + }, + "followUp": { + "groupLabel": "Suggested follow-ups", + "widenWindow": "Look further back", + "previousPeriod": "Compare with the period before", + "yearAgo": "Compare with a year earlier", + "asChart": "Show as a chart", + "asTable": "Show as a table", + "relatedMetric": "Look at {metric} too", + "continue": "Keep looking", + "settingLabel": "Suggest follow-up questions", + "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer." + }, + "clarify": { + "cardLabel": "The Coach has a question", + "choicesLabel": "Possible answers", + "freeTextHint": "You can also type your own answer.", + "contextHint": "Type your answer below.", + "window": { + "last7days": "Last 7 days", + "last30days": "Last 30 days", + "last90days": "Last 90 days", + "lastYear": "Last 12 months", + "allTime": "All time" + } + }, + "reuse": { + "caption": "Here are the same figures from my last answer, unchanged." + } }, "safety": { "floor": { diff --git a/messages/es.json b/messages/es.json index 241ebd154..8c66d2e1d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9480,7 +9480,180 @@ "promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.", "conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor." }, - "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos." + "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos.", + "step": { + "read": "Leyendo {domain}", + "readWindow": "Leyendo {domain}, {window}", + "snapshot": "Leyendo el resumen de tus datos", + "showResult": "Abriendo una tabla anterior", + "reuse": "Usando la tabla de antes", + "headerActive": "{label}…", + "headerDoneOne": "Consultó 1 fuente", + "headerDoneFew": "Consultó {count} fuentes", + "headerDoneOther": "Consultó {count} fuentes", + "listLabel": "Lo que consultó el Coach", + "toggleShow": "Mostrar lo que consultó el Coach", + "toggleHide": "Ocultar lo que consultó el Coach", + "announceDone": "Listo: {label}", + "readingsOne": "1 medición", + "readingsFew": "{count} mediciones", + "readingsOther": "{count} mediciones", + "rowsOne": "1 fila", + "rowsFew": "{count} filas", + "rowsOther": "{count} filas", + "metricsOne": "1 métrica", + "metricsFew": "{count} métricas", + "metricsOther": "{count} métricas", + "reason": { + "noData": "sin mediciones", + "outsideWindow": "sin mediciones en este periodo", + "moduleDisabled": "desactivado", + "retrievalFailed": "no se pudo leer", + "invalidArguments": "solicitud no entendida" + }, + "domain": { + "labs": "Resultados de laboratorio", + "illness": "Enfermedad y recuperación", + "cycle": "Ciclo", + "correlations": "Relaciones entre métricas", + "snapshot": "Resumen de datos" + }, + "window": { + "last7days": "últimos 7 días", + "last30days": "últimos 30 días", + "last90days": "últimos 90 días", + "lastYear": "últimos 12 meses", + "allTime": "todo el periodo" + }, + "period": { + "current": "este periodo", + "previous": "el periodo anterior", + "yearAgo": "un año antes" + }, + "granularity": { + "day": "por día", + "week": "por semana", + "month": "por mes" + } + }, + "result": { + "title": { + "byDay": "{metric} por día", + "byWeek": "{metric} por semana", + "byMonth": "{metric} por mes", + "previousPeriod": "{metric}, periodo anterior", + "yearAgo": "{metric}, un año antes", + "workoutsBySport": "Entrenamientos por deporte", + "labsLatest": "Resultados de laboratorio más recientes", + "sleepByNight": "Sueño por noche", + "complianceByDay": "Adherencia a la medicación por día", + "distribution": "{metric}: con qué frecuencia apareció cada rango" + }, + "column": { + "day": "Día", + "week": "Semana", + "month": "Mes", + "night": "Noche", + "sport": "Deporte", + "sessions": "Sesiones", + "analyte": "Prueba", + "value": "Valor", + "unit": "Unidad", + "date": "Fecha", + "referenceRange": "Rango de referencia", + "systolic": "Sistólica", + "diastolic": "Diastólica", + "mean": "Promedio", + "min": "Mínimo", + "max": "Máximo", + "readings": "Mediciones", + "rate": "Adherencia", + "duration": "Duración", + "range": "Rango", + "count": "Veces" + }, + "other": "Otros", + "noReading": "sin medición", + "showAll": "Mostrar todo ({count})", + "showFewer": "Mostrar menos", + "copyMenu": "Copiar tabla", + "copyForSpreadsheet": "Copiar para hoja de cálculo", + "copyAsText": "Copiar como texto", + "copied": "Copiado", + "copyFailed": "No se pudo copiar la tabla", + "viewLabel": "Vista", + "viewChart": "Gráfico", + "viewTable": "Tabla", + "chartSummary": "{title}, mostrado como gráfico. La vista de tabla recoge cada valor.", + "dataUsed": "Datos usados ({count})", + "truncated": "Se muestran las primeras {shown} de {total} filas", + "withheld": { + "moduleDisabled": "Esta tabla está oculta porque su módulo está desactivado.", + "unavailable": "No se pudo cargar esta tabla." + }, + "loading": "Cargando tabla…", + "loadFailed": "No se pudieron cargar las tablas.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Cómo se obtuvo", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 medición", + "readingsFew": "{count} mediciones", + "readingsOther": "{count} mediciones", + "aggregation": { + "mean": "promedio", + "median": "mediana", + "latest": "última medición", + "sum": "suma", + "count": "recuento", + "rate": "tasa" + }, + "averages": { + "day": "promedios diarios", + "week": "promedios semanales", + "month": "promedios mensuales" + }, + "totals": { + "day": "totales diarios", + "week": "totales semanales", + "month": "totales mensuales" + }, + "absent": { + "noData": "sin mediciones", + "outsideWindow": "sin mediciones en este periodo", + "moduleDisabled": "desactivado" + } + }, + "followUp": { + "groupLabel": "Preguntas sugeridas", + "widenWindow": "Mirar más atrás", + "previousPeriod": "Comparar con el periodo anterior", + "yearAgo": "Comparar con un año antes", + "asChart": "Mostrar como gráfico", + "asTable": "Mostrar como tabla", + "relatedMetric": "Ver también {metric}", + "continue": "Seguir buscando", + "settingLabel": "Sugerir preguntas de seguimiento", + "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach." + }, + "clarify": { + "cardLabel": "El Coach tiene una pregunta", + "choicesLabel": "Respuestas posibles", + "freeTextHint": "También puedes escribir tu propia respuesta.", + "contextHint": "Escribe tu respuesta abajo.", + "window": { + "last7days": "Últimos 7 días", + "last30days": "Últimos 30 días", + "last90days": "Últimos 90 días", + "lastYear": "Últimos 12 meses", + "allTime": "Todo el periodo" + } + }, + "reuse": { + "caption": "Aquí están las mismas cifras de mi última respuesta, sin cambios." + } }, "safety": { "floor": { diff --git a/messages/fr.json b/messages/fr.json index 64cbe69f1..6d333200b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9480,7 +9480,180 @@ "promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.", "conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît." }, - "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs." + "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs.", + "step": { + "read": "Lecture : {domain}", + "readWindow": "Lecture : {domain}, {window}", + "snapshot": "Lecture du résumé de tes données", + "showResult": "Ouverture d'un tableau précédent", + "reuse": "Reprise du tableau précédent", + "headerActive": "{label}…", + "headerDoneOne": "1 source consultée", + "headerDoneFew": "{count} sources consultées", + "headerDoneOther": "{count} sources consultées", + "listLabel": "Ce que le Coach a consulté", + "toggleShow": "Afficher ce que le Coach a consulté", + "toggleHide": "Masquer ce que le Coach a consulté", + "announceDone": "Terminé : {label}", + "readingsOne": "1 mesure", + "readingsFew": "{count} mesures", + "readingsOther": "{count} mesures", + "rowsOne": "1 ligne", + "rowsFew": "{count} lignes", + "rowsOther": "{count} lignes", + "metricsOne": "1 indicateur", + "metricsFew": "{count} indicateurs", + "metricsOther": "{count} indicateurs", + "reason": { + "noData": "aucune mesure", + "outsideWindow": "aucune mesure sur cette période", + "moduleDisabled": "désactivé", + "retrievalFailed": "lecture impossible", + "invalidArguments": "demande non comprise" + }, + "domain": { + "labs": "Résultats d'analyses", + "illness": "Maladie et récupération", + "cycle": "Cycle", + "correlations": "Liens entre indicateurs", + "snapshot": "Résumé des données" + }, + "window": { + "last7days": "7 derniers jours", + "last30days": "30 derniers jours", + "last90days": "90 derniers jours", + "lastYear": "12 derniers mois", + "allTime": "toute la période" + }, + "period": { + "current": "cette période", + "previous": "la période précédente", + "yearAgo": "un an plus tôt" + }, + "granularity": { + "day": "par jour", + "week": "par semaine", + "month": "par mois" + } + }, + "result": { + "title": { + "byDay": "{metric} par jour", + "byWeek": "{metric} par semaine", + "byMonth": "{metric} par mois", + "previousPeriod": "{metric}, période précédente", + "yearAgo": "{metric}, un an plus tôt", + "workoutsBySport": "Entraînements par sport", + "labsLatest": "Derniers résultats d'analyses", + "sleepByNight": "Sommeil par nuit", + "complianceByDay": "Observance des médicaments par jour", + "distribution": "{metric} : fréquence de chaque plage" + }, + "column": { + "day": "Jour", + "week": "Semaine", + "month": "Mois", + "night": "Nuit", + "sport": "Sport", + "sessions": "Séances", + "analyte": "Analyse", + "value": "Valeur", + "unit": "Unité", + "date": "Date", + "referenceRange": "Plage de référence", + "systolic": "Systolique", + "diastolic": "Diastolique", + "mean": "Moyenne", + "min": "Minimum", + "max": "Maximum", + "readings": "Mesures", + "rate": "Observance", + "duration": "Durée", + "range": "Plage", + "count": "Occurrences" + }, + "other": "Autres", + "noReading": "aucune mesure", + "showAll": "Tout afficher ({count})", + "showFewer": "Afficher moins", + "copyMenu": "Copier le tableau", + "copyForSpreadsheet": "Copier pour un tableur", + "copyAsText": "Copier en texte", + "copied": "Copié", + "copyFailed": "Impossible de copier le tableau", + "viewLabel": "Affichage", + "viewChart": "Graphique", + "viewTable": "Tableau", + "chartSummary": "{title}, sous forme de graphique. La vue tableau liste chaque valeur.", + "dataUsed": "Données utilisées ({count})", + "truncated": "Affichage des {shown} premières lignes sur {total}", + "withheld": { + "moduleDisabled": "Ce tableau est masqué car son module est désactivé.", + "unavailable": "Impossible de charger ce tableau." + }, + "loading": "Chargement du tableau…", + "loadFailed": "Impossible de charger les tableaux.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Comment cela a été calculé", + "entry": "{domain}, {window} : {detail}", + "entryNoWindow": "{domain} : {detail}", + "readingsOne": "1 mesure", + "readingsFew": "{count} mesures", + "readingsOther": "{count} mesures", + "aggregation": { + "mean": "moyenne", + "median": "médiane", + "latest": "dernière mesure", + "sum": "somme", + "count": "nombre", + "rate": "taux" + }, + "averages": { + "day": "moyennes journalières", + "week": "moyennes hebdomadaires", + "month": "moyennes mensuelles" + }, + "totals": { + "day": "totaux journaliers", + "week": "totaux hebdomadaires", + "month": "totaux mensuels" + }, + "absent": { + "noData": "aucune mesure", + "outsideWindow": "aucune mesure sur cette période", + "moduleDisabled": "désactivé" + } + }, + "followUp": { + "groupLabel": "Questions suggérées", + "widenWindow": "Remonter plus loin", + "previousPeriod": "Comparer avec la période précédente", + "yearAgo": "Comparer avec l'an dernier", + "asChart": "Afficher en graphique", + "asTable": "Afficher en tableau", + "relatedMetric": "Regarder aussi {metric}", + "continue": "Continuer à chercher", + "settingLabel": "Suggérer des questions de suivi", + "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach." + }, + "clarify": { + "cardLabel": "Le Coach a une question", + "choicesLabel": "Réponses possibles", + "freeTextHint": "Tu peux aussi écrire ta propre réponse.", + "contextHint": "Écris ta réponse ci-dessous.", + "window": { + "last7days": "7 derniers jours", + "last30days": "30 derniers jours", + "last90days": "90 derniers jours", + "lastYear": "12 derniers mois", + "allTime": "Toute la période" + } + }, + "reuse": { + "caption": "Voici les mêmes chiffres que dans ma dernière réponse, sans changement." + } }, "safety": { "floor": { diff --git a/messages/it.json b/messages/it.json index 404c0ba2d..cbabb0406 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9480,7 +9480,180 @@ "promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.", "conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore." }, - "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati." + "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati.", + "step": { + "read": "Lettura di {domain}", + "readWindow": "Lettura di {domain}, {window}", + "snapshot": "Lettura del riepilogo dei tuoi dati", + "showResult": "Apertura di una tabella precedente", + "reuse": "Uso della tabella di prima", + "headerActive": "{label}…", + "headerDoneOne": "1 fonte consultata", + "headerDoneFew": "{count} fonti consultate", + "headerDoneOther": "{count} fonti consultate", + "listLabel": "Cosa ha consultato il Coach", + "toggleShow": "Mostra cosa ha consultato il Coach", + "toggleHide": "Nascondi cosa ha consultato il Coach", + "announceDone": "Fatto: {label}", + "readingsOne": "1 misurazione", + "readingsFew": "{count} misurazioni", + "readingsOther": "{count} misurazioni", + "rowsOne": "1 riga", + "rowsFew": "{count} righe", + "rowsOther": "{count} righe", + "metricsOne": "1 parametro", + "metricsFew": "{count} parametri", + "metricsOther": "{count} parametri", + "reason": { + "noData": "nessuna misurazione", + "outsideWindow": "nessuna misurazione in questo periodo", + "moduleDisabled": "disattivato", + "retrievalFailed": "lettura non riuscita", + "invalidArguments": "richiesta non compresa" + }, + "domain": { + "labs": "Esami di laboratorio", + "illness": "Malattia e recupero", + "cycle": "Ciclo", + "correlations": "Relazioni tra parametri", + "snapshot": "Riepilogo dei dati" + }, + "window": { + "last7days": "ultimi 7 giorni", + "last30days": "ultimi 30 giorni", + "last90days": "ultimi 90 giorni", + "lastYear": "ultimi 12 mesi", + "allTime": "intero periodo" + }, + "period": { + "current": "questo periodo", + "previous": "il periodo precedente", + "yearAgo": "un anno prima" + }, + "granularity": { + "day": "per giorno", + "week": "per settimana", + "month": "per mese" + } + }, + "result": { + "title": { + "byDay": "{metric} per giorno", + "byWeek": "{metric} per settimana", + "byMonth": "{metric} per mese", + "previousPeriod": "{metric}, periodo precedente", + "yearAgo": "{metric}, un anno prima", + "workoutsBySport": "Allenamenti per sport", + "labsLatest": "Esami di laboratorio più recenti", + "sleepByNight": "Sonno per notte", + "complianceByDay": "Aderenza ai farmaci per giorno", + "distribution": "{metric}: quanto spesso è comparso ogni intervallo" + }, + "column": { + "day": "Giorno", + "week": "Settimana", + "month": "Mese", + "night": "Notte", + "sport": "Sport", + "sessions": "Sessioni", + "analyte": "Esame", + "value": "Valore", + "unit": "Unità", + "date": "Data", + "referenceRange": "Intervallo di riferimento", + "systolic": "Sistolica", + "diastolic": "Diastolica", + "mean": "Media", + "min": "Minimo", + "max": "Massimo", + "readings": "Misurazioni", + "rate": "Aderenza", + "duration": "Durata", + "range": "Intervallo", + "count": "Volte" + }, + "other": "Altro", + "noReading": "nessuna misurazione", + "showAll": "Mostra tutto ({count})", + "showFewer": "Mostra meno", + "copyMenu": "Copia tabella", + "copyForSpreadsheet": "Copia per foglio di calcolo", + "copyAsText": "Copia come testo", + "copied": "Copiato", + "copyFailed": "Impossibile copiare la tabella", + "viewLabel": "Vista", + "viewChart": "Grafico", + "viewTable": "Tabella", + "chartSummary": "{title}, mostrato come grafico. La vista tabella elenca ogni valore.", + "dataUsed": "Dati usati ({count})", + "truncated": "Prime {shown} righe su {total}", + "withheld": { + "moduleDisabled": "Questa tabella è nascosta perché il suo modulo è disattivato.", + "unavailable": "Impossibile caricare questa tabella." + }, + "loading": "Caricamento della tabella…", + "loadFailed": "Impossibile caricare le tabelle.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Come è stato ricavato", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 misurazione", + "readingsFew": "{count} misurazioni", + "readingsOther": "{count} misurazioni", + "aggregation": { + "mean": "media", + "median": "mediana", + "latest": "ultima misurazione", + "sum": "somma", + "count": "conteggio", + "rate": "tasso" + }, + "averages": { + "day": "medie giornaliere", + "week": "medie settimanali", + "month": "medie mensili" + }, + "totals": { + "day": "totali giornalieri", + "week": "totali settimanali", + "month": "totali mensili" + }, + "absent": { + "noData": "nessuna misurazione", + "outsideWindow": "nessuna misurazione in questo periodo", + "moduleDisabled": "disattivato" + } + }, + "followUp": { + "groupLabel": "Domande suggerite", + "widenWindow": "Guarda più indietro", + "previousPeriod": "Confronta con il periodo precedente", + "yearAgo": "Confronta con un anno prima", + "asChart": "Mostra come grafico", + "asTable": "Mostra come tabella", + "relatedMetric": "Guarda anche {metric}", + "continue": "Continua a cercare", + "settingLabel": "Suggerisci domande di approfondimento", + "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach." + }, + "clarify": { + "cardLabel": "Il Coach ha una domanda", + "choicesLabel": "Risposte possibili", + "freeTextHint": "Puoi anche scrivere una tua risposta.", + "contextHint": "Scrivi la tua risposta qui sotto.", + "window": { + "last7days": "Ultimi 7 giorni", + "last30days": "Ultimi 30 giorni", + "last90days": "Ultimi 90 giorni", + "lastYear": "Ultimi 12 mesi", + "allTime": "Intero periodo" + } + }, + "reuse": { + "caption": "Ecco gli stessi valori della mia ultima risposta, invariati." + } }, "safety": { "floor": { diff --git a/messages/ko.json b/messages/ko.json index 8ce36f817..2f031e7b3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9480,7 +9480,180 @@ "promptInjection": "제 지침을 무시하게 만들려는 표현이 보였어요. 저는 HealthLog 코치이고 본인의 건강 데이터만 정리해 드려요 — 그 범위에 맞게 다시 질문해 주세요.", "conversationPoisoned": "이 대화의 앞선 메시지에 제 지침을 무시하게 만드는 표현이 있어요. 새 대화를 시작해 주세요." }, - "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요." + "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요.", + "step": { + "read": "{domain} 확인 중", + "readWindow": "{domain}, {window} 확인 중", + "snapshot": "기록 요약 확인 중", + "showResult": "이전 표 여는 중", + "reuse": "앞서 만든 표 사용 중", + "headerActive": "{label}…", + "headerDoneOne": "출처 1개 확인함", + "headerDoneFew": "출처 {count}개 확인함", + "headerDoneOther": "출처 {count}개 확인함", + "listLabel": "코치가 확인한 내용", + "toggleShow": "코치가 확인한 내용 보기", + "toggleHide": "코치가 확인한 내용 숨기기", + "announceDone": "완료: {label}", + "readingsOne": "측정값 1개", + "readingsFew": "측정값 {count}개", + "readingsOther": "측정값 {count}개", + "rowsOne": "1행", + "rowsFew": "{count}행", + "rowsOther": "{count}행", + "metricsOne": "지표 1개", + "metricsFew": "지표 {count}개", + "metricsOther": "지표 {count}개", + "reason": { + "noData": "측정값 없음", + "outsideWindow": "이 기간에는 측정값 없음", + "moduleDisabled": "꺼져 있음", + "retrievalFailed": "읽지 못함", + "invalidArguments": "요청을 이해하지 못함" + }, + "domain": { + "labs": "검사 결과", + "illness": "질병과 회복", + "cycle": "생리 주기", + "correlations": "지표 간 연관성", + "snapshot": "기록 요약" + }, + "window": { + "last7days": "최근 7일", + "last30days": "최근 30일", + "last90days": "최근 90일", + "lastYear": "최근 12개월", + "allTime": "전체 기간" + }, + "period": { + "current": "이번 기간", + "previous": "이전 기간", + "yearAgo": "1년 전" + }, + "granularity": { + "day": "일별", + "week": "주별", + "month": "월별" + } + }, + "result": { + "title": { + "byDay": "일별 {metric}", + "byWeek": "주별 {metric}", + "byMonth": "월별 {metric}", + "previousPeriod": "{metric}, 이전 기간", + "yearAgo": "{metric}, 1년 전", + "workoutsBySport": "종목별 운동", + "labsLatest": "최근 검사 결과", + "sleepByNight": "밤별 수면", + "complianceByDay": "일별 복약 순응도", + "distribution": "{metric}: 구간별 빈도" + }, + "column": { + "day": "날짜", + "week": "주", + "month": "월", + "night": "밤", + "sport": "종목", + "sessions": "횟수", + "analyte": "검사 항목", + "value": "값", + "unit": "단위", + "date": "일자", + "referenceRange": "참고 범위", + "systolic": "수축기", + "diastolic": "이완기", + "mean": "평균", + "min": "최저", + "max": "최고", + "readings": "측정 횟수", + "rate": "순응도", + "duration": "시간", + "range": "구간", + "count": "빈도" + }, + "other": "기타", + "noReading": "측정값 없음", + "showAll": "모두 보기 ({count})", + "showFewer": "간단히 보기", + "copyMenu": "표 복사", + "copyForSpreadsheet": "스프레드시트용으로 복사", + "copyAsText": "텍스트로 복사", + "copied": "복사됨", + "copyFailed": "표를 복사하지 못했어요", + "viewLabel": "보기 방식", + "viewChart": "차트", + "viewTable": "표", + "chartSummary": "{title}을(를) 차트로 표시해요. 표 보기에서 모든 값을 확인할 수 있어요.", + "dataUsed": "사용한 데이터 ({count})", + "truncated": "전체 {total}행 중 처음 {shown}행 표시", + "withheld": { + "moduleDisabled": "해당 모듈이 꺼져 있어 이 표를 숨겼어요.", + "unavailable": "이 표를 불러오지 못했어요." + }, + "loading": "표 불러오는 중…", + "loadFailed": "표를 불러오지 못했어요.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "산출 방법", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "측정값 1개", + "readingsFew": "측정값 {count}개", + "readingsOther": "측정값 {count}개", + "aggregation": { + "mean": "평균", + "median": "중앙값", + "latest": "최근 측정값", + "sum": "합계", + "count": "개수", + "rate": "비율" + }, + "averages": { + "day": "일 평균", + "week": "주 평균", + "month": "월 평균" + }, + "totals": { + "day": "일 합계", + "week": "주 합계", + "month": "월 합계" + }, + "absent": { + "noData": "측정값 없음", + "outsideWindow": "이 기간에는 측정값 없음", + "moduleDisabled": "꺼져 있음" + } + }, + "followUp": { + "groupLabel": "추천 후속 질문", + "widenWindow": "더 이전까지 보기", + "previousPeriod": "이전 기간과 비교", + "yearAgo": "1년 전과 비교", + "asChart": "차트로 보기", + "asTable": "표로 보기", + "relatedMetric": "{metric}도 보기", + "continue": "계속 찾아보기", + "settingLabel": "후속 질문 추천", + "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요." + }, + "clarify": { + "cardLabel": "코치가 질문이 있어요", + "choicesLabel": "선택할 수 있는 답변", + "freeTextHint": "직접 답을 입력해도 돼요.", + "contextHint": "아래에 답을 입력해 주세요.", + "window": { + "last7days": "최근 7일", + "last30days": "최근 30일", + "last90days": "최근 90일", + "lastYear": "최근 12개월", + "allTime": "전체 기간" + } + }, + "reuse": { + "caption": "지난 답변과 같은 수치를 그대로 보여드릴게요." + } }, "safety": { "floor": { diff --git a/messages/pl.json b/messages/pl.json index fccc01aca..775c3ed9d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9480,7 +9480,180 @@ "promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.", "conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę." }, - "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach." + "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach.", + "step": { + "read": "Czytam: {domain}", + "readWindow": "Czytam: {domain}, {window}", + "snapshot": "Czytam podsumowanie Twoich danych", + "showResult": "Otwieram wcześniejszą tabelę", + "reuse": "Korzystam z tabeli z poprzedniej odpowiedzi", + "headerActive": "{label}…", + "headerDoneOne": "Sprawdzono 1 źródło", + "headerDoneFew": "Sprawdzono {count} źródła", + "headerDoneOther": "Sprawdzono {count} źródeł", + "listLabel": "Co sprawdził Coach", + "toggleShow": "Pokaż, co sprawdził Coach", + "toggleHide": "Ukryj, co sprawdził Coach", + "announceDone": "Gotowe: {label}", + "readingsOne": "1 pomiar", + "readingsFew": "{count} pomiary", + "readingsOther": "{count} pomiarów", + "rowsOne": "1 wiersz", + "rowsFew": "{count} wiersze", + "rowsOther": "{count} wierszy", + "metricsOne": "1 wskaźnik", + "metricsFew": "{count} wskaźniki", + "metricsOther": "{count} wskaźników", + "reason": { + "noData": "brak pomiarów", + "outsideWindow": "brak pomiarów w tym okresie", + "moduleDisabled": "wyłączone", + "retrievalFailed": "nie udało się odczytać", + "invalidArguments": "nie zrozumiano zapytania" + }, + "domain": { + "labs": "Wyniki badań", + "illness": "Choroba i powrót do zdrowia", + "cycle": "Cykl", + "correlations": "Związki między wskaźnikami", + "snapshot": "Podsumowanie danych" + }, + "window": { + "last7days": "ostatnie 7 dni", + "last30days": "ostatnie 30 dni", + "last90days": "ostatnie 90 dni", + "lastYear": "ostatnie 12 miesięcy", + "allTime": "cały okres" + }, + "period": { + "current": "ten okres", + "previous": "poprzedni okres", + "yearAgo": "rok wcześniej" + }, + "granularity": { + "day": "według dni", + "week": "według tygodni", + "month": "według miesięcy" + } + }, + "result": { + "title": { + "byDay": "{metric} według dni", + "byWeek": "{metric} według tygodni", + "byMonth": "{metric} według miesięcy", + "previousPeriod": "{metric}, poprzedni okres", + "yearAgo": "{metric}, rok wcześniej", + "workoutsBySport": "Treningi według dyscypliny", + "labsLatest": "Najnowsze wyniki badań", + "sleepByNight": "Sen według nocy", + "complianceByDay": "Przyjmowanie leków według dni", + "distribution": "{metric}: jak często występował każdy zakres" + }, + "column": { + "day": "Dzień", + "week": "Tydzień", + "month": "Miesiąc", + "night": "Noc", + "sport": "Dyscyplina", + "sessions": "Sesje", + "analyte": "Badanie", + "value": "Wartość", + "unit": "Jednostka", + "date": "Data", + "referenceRange": "Zakres referencyjny", + "systolic": "Skurczowe", + "diastolic": "Rozkurczowe", + "mean": "Średnia", + "min": "Najniższa", + "max": "Najwyższa", + "readings": "Pomiary", + "rate": "Regularność", + "duration": "Czas trwania", + "range": "Zakres", + "count": "Liczba razy" + }, + "other": "Inne", + "noReading": "brak pomiaru", + "showAll": "Pokaż wszystko ({count})", + "showFewer": "Pokaż mniej", + "copyMenu": "Kopiuj tabelę", + "copyForSpreadsheet": "Kopiuj do arkusza kalkulacyjnego", + "copyAsText": "Kopiuj jako tekst", + "copied": "Skopiowano", + "copyFailed": "Nie udało się skopiować tabeli", + "viewLabel": "Widok", + "viewChart": "Wykres", + "viewTable": "Tabela", + "chartSummary": "{title} w formie wykresu. Widok tabeli pokazuje każdą wartość.", + "dataUsed": "Użyte dane ({count})", + "truncated": "Pierwsze {shown} z {total} wierszy", + "withheld": { + "moduleDisabled": "Ta tabela jest ukryta, bo jej moduł jest wyłączony.", + "unavailable": "Nie udało się wczytać tej tabeli." + }, + "loading": "Wczytywanie tabeli…", + "loadFailed": "Nie udało się wczytać tabel.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Jak to ustalono", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 pomiar", + "readingsFew": "{count} pomiary", + "readingsOther": "{count} pomiarów", + "aggregation": { + "mean": "średnia", + "median": "mediana", + "latest": "ostatni pomiar", + "sum": "suma", + "count": "liczba", + "rate": "odsetek" + }, + "averages": { + "day": "średnie dzienne", + "week": "średnie tygodniowe", + "month": "średnie miesięczne" + }, + "totals": { + "day": "sumy dzienne", + "week": "sumy tygodniowe", + "month": "sumy miesięczne" + }, + "absent": { + "noData": "brak pomiarów", + "outsideWindow": "brak pomiarów w tym okresie", + "moduleDisabled": "wyłączone" + } + }, + "followUp": { + "groupLabel": "Proponowane pytania", + "widenWindow": "Sięgnij dalej wstecz", + "previousPeriod": "Porównaj z poprzednim okresem", + "yearAgo": "Porównaj z tym samym okresem rok wcześniej", + "asChart": "Pokaż jako wykres", + "asTable": "Pokaż jako tabelę", + "relatedMetric": "Sprawdź też: {metric}", + "continue": "Szukaj dalej", + "settingLabel": "Proponuj kolejne pytania", + "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha." + }, + "clarify": { + "cardLabel": "Coach ma pytanie", + "choicesLabel": "Możliwe odpowiedzi", + "freeTextHint": "Możesz też wpisać własną odpowiedź.", + "contextHint": "Wpisz odpowiedź poniżej.", + "window": { + "last7days": "Ostatnie 7 dni", + "last30days": "Ostatnie 30 dni", + "last90days": "Ostatnie 90 dni", + "lastYear": "Ostatnie 12 miesięcy", + "allTime": "Cały okres" + } + }, + "reuse": { + "caption": "Oto te same wartości z mojej poprzedniej odpowiedzi, bez zmian." + } }, "safety": { "floor": { diff --git a/prisma/migrations/0360_coach_message_results/migration.sql b/prisma/migrations/0360_coach_message_results/migration.sql new file mode 100644 index 000000000..91969be4f --- /dev/null +++ b/prisma/migrations/0360_coach_message_results/migration.sql @@ -0,0 +1,14 @@ +-- The tables of values a Coach turn read, stored with the assistant message +-- that showed them: a JSON array of result tables, AES-256-GCM encrypted in +-- the same Bytes codec as the message body. The values are health data, so +-- they never sit in the plaintext `metric_source_json`, which carries only the +-- tables' metadata (ref, source, shape, row count). +-- +-- A stored table keeps the values the answer was given with; a later edit or +-- sync does not rewrite what an earlier answer showed. The column goes with +-- its row, so deleting a conversation or wiping the account removes it by +-- the existing cascade. +-- +-- Additive only: a nullable column, no table rewrite, no data change. + +ALTER TABLE "coach_messages" ADD COLUMN "results_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 963b5ce71..d24090870 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6237,6 +6237,12 @@ model CoachMessage { // nullable: user turns, refusals, and pre-feature rows carry NULL. tokensUsed Int? @map("tokens_used") model String? + // v1.39.4 — the tables of values this turn read (JSON `CoachResultTable[]`, + // at most 128 KB), AES-256-GCM encrypted like `encryptedContent`. Health + // values, so they never ride the plaintext `metricSourceJson`, which holds + // only the tables' metadata. Read through `readMessageResults` by the + // owner-narrowed results endpoint; NULL on every turn without a table. + resultsEncrypted Bytes? @map("results_encrypted") createdAt DateTime @default(now()) @map("created_at") conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index db3ef9348..513bbbe90 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -418,7 +418,8 @@ async function main() { } // ───── Coach (Bytes columns) ───── - // "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted" + // "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted" + // "factEncrypted" results.push( await rotateBytesColumn( "CoachMessage", @@ -426,6 +427,15 @@ async function main() { prisma.coachMessage, ), ); + // v1.39.4 — the tables of values a turn read. NULL on every turn without a + // table, which `rotateBytesColumn` skips. + results.push( + await rotateBytesColumn( + "CoachMessage", + "resultsEncrypted", + prisma.coachMessage, + ), + ); results.push( await rotateBytesColumn( "CoachConversation", diff --git a/src/__tests__/coach-stream-ios-compat.test.ts b/src/__tests__/coach-stream-ios-compat.test.ts new file mode 100644 index 000000000..8235fb064 --- /dev/null +++ b/src/__tests__/coach-stream-ios-compat.test.ts @@ -0,0 +1,106 @@ +/** + * No Coach stream frame added after the native client shipped may carry a + * top-level key that client already decodes. + * + * The native client decodes every frame into one flat struct — + * `{ type, token, conversationId, messageId, code, message, suggestion, + * metricSource, usage }` — and dispatches on `type`, ignoring a type it does + * not know. That makes an unknown frame harmless only while its keys are + * unknown too: a new frame that reused `message` or `usage` with a different + * shape would make the whole frame fail to decode, and a new frame that + * reused one with the same shape would leak a value into whatever the client + * does with that key. So a new frame names its payload with a key of its own + * (`step`, `result`, `followUps`, `clarification`). + * + * Reads the frame list from the Zod mirror (`stream-events.ts`), which a + * type test holds equal to `CoachStreamEvent`, so a frame added to the + * TypeScript union is seen here too. + * + * Checked by breaking it: adding `message: z.string()` to the `step` frame in + * `stream-events.ts` turns the second test red and names the frame and key. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; + +import { coachStreamEventSchema } from "@/lib/ai/coach/stream-events"; + +/** The keys the shipped native client decodes, `type` aside. */ +const NATIVE_CLIENT_KEYS = [ + "token", + "conversationId", + "messageId", + "code", + "message", + "suggestion", + "metricSource", + "usage", +] as const; + +/** The frames the shipped client was built against; they own those keys. */ +const FRAMES_THE_CLIENT_KNOWS = new Set([ + "token", + "provenance", + "suggestion", + "suggestedAction", + "reasoning", + "done", + "error", +]); + +type FrameSchema = z.ZodObject<{ type: z.ZodLiteral } & z.ZodRawShape>; + +function frames(): Array<{ type: string; keys: string[] }> { + return (coachStreamEventSchema.options as readonly FrameSchema[]).map( + (option) => ({ + type: String(option.shape.type.value), + keys: Object.keys(option.shape), + }), + ); +} + +/** Every `frame.key` pair where a newer frame reuses a native-client key. */ +function collisions(list: Array<{ type: string; keys: string[] }>): string[] { + const reserved: ReadonlySet = new Set(NATIVE_CLIENT_KEYS); + return list + .filter((frame) => !FRAMES_THE_CLIENT_KNOWS.has(frame.type)) + .flatMap((frame) => + frame.keys + .filter((key) => reserved.has(key)) + .map((key) => `${frame.type}.${key}`), + ); +} + +describe("Coach stream frames stay decodable by the shipped native client", () => { + it("reads every frame, including the ones added after the client shipped", () => { + const types = frames().map((frame) => frame.type); + // An empty or partial list would agree with anything. + expect(types).toEqual( + expect.arrayContaining([ + ...FRAMES_THE_CLIENT_KNOWS, + "step", + "result", + "followUps", + "clarification", + ]), + ); + expect( + types.filter((type) => !FRAMES_THE_CLIENT_KNOWS.has(type)).length, + ).toBeGreaterThan(0); + }); + + it("no newer frame carries a key the native client decodes", () => { + expect( + collisions(frames()), + "rename the payload key: the native client decodes these keys on every frame", + ).toEqual([]); + }); + + it("the check flags a colliding frame", () => { + expect( + collisions([ + { type: "step", keys: ["type", "step", "message"] }, + { type: "done", keys: ["type", "conversationId", "messageId"] }, + ]), + ).toEqual(["step.message"]); + }); +}); 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/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 7bd394e83..627962fe8 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -337,6 +337,11 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // entry here would fail the "does not hide a gate" check below. "src/app/api/insights/chat/[id]/route.ts", "src/app/api/insights/chat/messages/[id]/feedback/route.ts", + // One message's stored tables. No whole-route gate for the same reason, + // but each table is withheld when the module owning its domain is off + // (`isCoachDomainWithheld` over the resolved module map), so a switched-off + // domain's values never come back through a stored answer. + "src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts", // The Today digest and its dismiss: data, with the AI parts masked by the // loader through the capability resolver. "src/app/api/daily/digest/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/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 985da05aa..e89e981c4 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -109,6 +109,8 @@ async function handleChatRequest(request: NextRequest): Promise { scope, guidedQuestion, workoutId, + followUp, + clarification, } = parsed.data; // Per-user request-rate ceiling layered in front of the daily budget @@ -171,6 +173,8 @@ async function handleChatRequest(request: NextRequest): Promise { scope, guidedQuestion, workoutId, + followUp, + clarification, recheckCapability: coachCapabilityRefusal, }); } diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 114883526..1518762b8 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -77,6 +77,10 @@ function streaming( metricSource: null, suggestion: null, suggestedAction: null, + steps: [], + results: [], + followUps: [], + clarification: null, inProgress: false, messageId: null, errorCode: null, diff --git a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts new file mode 100644 index 000000000..add56be66 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts @@ -0,0 +1,180 @@ +/** + * v1.39.4 — the client end of the dialog frames: step upserts, the request + * fields a chip or an answered question adds, and which message the chips + * and the clarification belong to. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (k: string) => k }), +})); + +import type { + CoachMessageDTO, + CoachStep, + CoachStreamEvent, +} from "@/lib/ai/coach/types"; + +import { + parseSseChunk, + resolveCoachSendTarget, + upsertStep, + type CoachStreamingMessage, +} from "../use-coach"; +import { latestClarification, latestFollowUps } from "../message-thread"; + +const STEP: CoachStep = { + id: "s1", + tool: "get_sleep", + labelKey: "coach.step.read", + label: "Reading Sleep", + domain: "sleep", + status: "running", +}; + +const CHIP = { + id: "f1", + kind: "as_table" as const, + labelKey: "coach.followUp.asTable", + label: "Show as a table", + reuse: true, + origin: "server" as const, +}; + +const CLARIFY = { kind: "window" as const, choices: [], freeText: true }; + +function streaming( + partial: Partial, +): CoachStreamingMessage { + return { + content: "", + metricSource: null, + suggestion: null, + suggestedAction: null, + steps: [], + results: [], + followUps: [], + clarification: null, + inProgress: false, + messageId: null, + errorCode: null, + usage: null, + ...partial, + }; +} + +function message(partial: Partial): CoachMessageDTO { + return { + id: "m1", + role: "assistant", + content: "reply", + createdAt: "2026-09-26T00:00:00.000Z", + metricSource: null, + providerType: "anthropic", + promptVersion: null, + tokensUsed: null, + model: null, + ...partial, + }; +} + +describe("dialog frames on the client", () => { + it("parses the new frames like any other", () => { + const frames: CoachStreamEvent[] = [ + { type: "step", step: STEP }, + { type: "followUps", followUps: [CHIP] }, + { type: "clarification", clarification: CLARIFY }, + ]; + const { events } = parseSseChunk( + "", + frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""), + ); + expect(events).toEqual(frames); + }); + + it("upserts a step by id, keeping first-seen order", () => { + const second = { ...STEP, id: "s2" }; + let steps = upsertStep([], STEP); + steps = upsertStep(steps, second); + steps = upsertStep(steps, { ...STEP, status: "done", count: 12 }); + expect(steps.map((s) => [s.id, s.status])).toEqual([ + ["s1", "done"], + ["s2", "running"], + ]); + }); +}); + +describe("resolveCoachSendTarget — chips and answers", () => { + it("carries followUp and clarification on the tool route", () => { + const target = resolveCoachSendTarget({ + conversationId: "c1", + message: "Show as a table", + followUp: { messageId: "m1", id: "f1" }, + clarification: { messageId: "m0", choiceId: "c2" }, + }); + expect(target.url).toBe("/api/insights/chat"); + expect(JSON.parse(target.body)).toMatchObject({ + followUp: { messageId: "m1", id: "f1" }, + clarification: { messageId: "m0", choiceId: "c2" }, + }); + }); + + it("never carries them to the fenced route", () => { + const target = resolveCoachSendTarget({ + conversationId: "c1", + message: "x", + fenced: true, + followUp: { messageId: "m1", id: "f1" }, + clarification: { messageId: "m0" }, + }); + expect(target.url).toBe("/api/insights/chat/fenced"); + const body = JSON.parse(target.body); + expect(body).not.toHaveProperty("followUp"); + expect(body).not.toHaveProperty("clarification"); + }); +}); + +describe("which message the chips and the question belong to", () => { + const withChips = message({ + metricSource: { + windows: [], + metrics: [], + followUps: [CHIP], + clarification: CLARIFY, + }, + }); + + it("offers the latest persisted assistant message's chips", () => { + expect(latestFollowUps([withChips], undefined)).toEqual({ + messageId: "m1", + followUps: [CHIP], + }); + expect(latestClarification([withChips], undefined)).toEqual({ + messageId: "m1", + clarification: CLARIFY, + }); + }); + + it("offers nothing once the person replied, and nothing mid-turn", () => { + const answered = [withChips, message({ id: "m2", role: "user" })]; + expect(latestFollowUps(answered, undefined)).toBeNull(); + expect(latestClarification(answered, undefined)).toBeNull(); + expect( + latestFollowUps([withChips], streaming({ inProgress: true })), + ).toBeNull(); + }); + + it("prefers the just-settled streamed turn over an older message", () => { + const live = streaming({ + messageId: "m9", + followUps: [{ ...CHIP, id: "f2" }], + clarification: CLARIFY, + }); + expect(latestFollowUps([withChips], live)?.messageId).toBe("m9"); + expect(latestClarification([withChips], live)?.messageId).toBe("m9"); + // A settled streamed turn without chips hides an older message's. + expect( + latestFollowUps([withChips], streaming({ messageId: "m9" })), + ).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 02057310a..7cd012496 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -41,6 +41,9 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card"; import { SuggestedActionCard } from "./suggested-action-card"; import { StreamedProse } from "./streamed-prose"; import { MessageTokenFooter } from "./message-token-footer"; +import { CoachTurnSteps } from "./turn-steps"; +import { CoachResults } from "./coach-results"; +import { CoachMethodLine } from "./method-line"; import { COACH_ICON_BUTTON, ReadAloudButton, @@ -387,6 +390,18 @@ interface ChatBubbleProps { */ suggestedAction?: import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null; + /** + * v1.39.4 — live steps from the streaming hook. Persisted messages carry + * them on `metricSource.steps`; the bubble falls back to that. + */ + steps?: import("@/lib/ai/coach/types").CoachStep[]; + /** + * v1.39.4 — live tables from the streaming hook. A persisted message + * lists their metadata on `metricSource.results` and the values are + * fetched lazily, which needs the conversation id. + */ + results?: import("@/lib/ai/coach/types").CoachResultTable[]; + conversationId?: string | null; providerType?: string | null; inProgress?: boolean; errorCode?: string | null; @@ -464,6 +479,9 @@ export function areChatBubblePropsEqual( prev.metricSource === next.metricSource && prev.suggestion === next.suggestion && prev.suggestedAction === next.suggestedAction && + prev.steps === next.steps && + prev.results === next.results && + prev.conversationId === next.conversationId && prev.usage === next.usage && // onRegenerate is a per-render closure — compare only whether it is present. (prev.onRegenerate === undefined) === (next.onRegenerate === undefined) @@ -476,6 +494,9 @@ function ChatBubbleImpl({ metricSource, suggestion, suggestedAction, + steps, + results, + conversationId, providerType, inProgress, errorCode, @@ -654,7 +675,8 @@ function ChatBubbleImpl({ !!metricSource && ((metricSource.metrics?.length ?? 0) > 0 || (metricSource.windows?.length ?? 0) > 0); - const hasProvenance = hasChips || keyValues.length > 0; + const method = metricSource?.method ?? null; + const hasProvenance = hasChips || keyValues.length > 0 || method !== null; // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- // grounded `metric:` chart under a SETTLED assistant turn. Skipped @@ -693,6 +715,12 @@ function ChatBubbleImpl({ )}
+ {/* v1.39.4 — what the Coach read on this turn: live from the step + frames, restored from the provenance on reload. */} + {/* v1.19.1 (C3) — the live turn shows the classic typing animation (three pulsing dots) while it is still thinking with no prose yet, restoring the writing/typing indicator the maintainer @@ -768,6 +796,17 @@ function ChatBubbleImpl({ })}
)} + {/* v1.39.4 — the tables the turn read, on a settled turn only: live + from the result frames, or fetched lazily for a persisted + message whose provenance lists them. */} + {!inProgress && !errorCode && ( + + )} {/* v1.18.6 — a "no provider configured anywhere" turn is a setup gap, not a transient failure: surface a direct link to Settings → AI so the Coach guides the user into BYOK / local @@ -874,6 +913,9 @@ function ChatBubbleImpl({ ))} )} + {/* v1.39.4 — how the answer was worked out, beside the key + values it explains. */} + )} diff --git a/src/components/insights/coach-panel/clarification-card.tsx b/src/components/insights/coach-panel/clarification-card.tsx new file mode 100644 index 000000000..ca8aac2a9 --- /dev/null +++ b/src/components/insights/coach-panel/clarification-card.tsx @@ -0,0 +1,27 @@ +"use client"; + +/** + * v1.39.4 — the choices for a clarifying question, above the composer. + * The question itself is the assistant reply; a tap on a choice sends its + * label with `clarification: { messageId, choiceId }`, and a typed answer + * goes out with `clarification: { messageId }`. + * + * Not built yet: renders nothing. + */ +import type { + CoachClarification, + CoachClarificationChoice, +} from "@/lib/ai/coach/types"; + +export interface CoachClarificationCardProps { + clarification: CoachClarification; + /** The assistant message that asked. */ + messageId: string; + /** True while a turn is in flight. */ + disabled: boolean; + onChoose: (choice: CoachClarificationChoice) => void; +} + +export function CoachClarificationCard(_props: CoachClarificationCardProps) { + return null; +} diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index d4cb5b493..7f2d30cf9 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -18,7 +18,11 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { randomId } from "@/lib/random-id"; import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import type { CoachScope } from "@/lib/ai/coach/types"; +import type { + CoachClarificationChoice, + CoachFollowUp, + CoachScope, +} from "@/lib/ai/coach/types"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; @@ -45,7 +49,12 @@ import { guidedReducer, } from "./guided-questions-machine"; import { HistoryRail } from "./history-rail"; -import { MessageThread, type InterleavedThreadItem } from "./message-thread"; +import { + MessageThread, + latestClarification, + type InterleavedThreadItem, +} from "./message-thread"; +import { CoachClarificationCard } from "./clarification-card"; import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; @@ -606,6 +615,37 @@ export function CoachConversation({ registerConversationIdGetter(() => conversationIdRef.current); }, [registerConversationIdGetter]); + // v1.39.4 — the open clarifying question, if the latest reply asked one. + // A typed message answers it; a chip on the card answers it with a choice. + const openClarification = fenced + ? null + : latestClarification(conversation?.messages ?? [], send.streaming); + + // v1.39.4 — a follow-up chip: its label is the message, and the server + // resolves what it asks for from the message that offered it. + function handleFollowUp(followUp: CoachFollowUp, messageId: string) { + if (send.isStreaming || fenced || currentConversationId === null) return; + void send.send({ + conversationId: currentConversationId, + message: followUp.label, + followUp: { messageId, id: followUp.id }, + }); + } + + // v1.39.4 — a choice on the clarification card. + function handleClarificationChoice(choice: CoachClarificationChoice) { + if (send.isStreaming || !openClarification) return; + if (currentConversationId === null) return; + void send.send({ + conversationId: currentConversationId, + message: choice.label, + clarification: { + messageId: openClarification.messageId, + choiceId: choice.id, + }, + }); + } + async function handleSubmit(value: string) { const trimmed = value.trim(); if (!trimmed || send.isStreaming) return; @@ -654,6 +694,10 @@ export function CoachConversation({ guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined), scope: fenced ? undefined : scope, workoutId: fenced ? undefined : workoutId, + // v1.39.4 — a typed message answers an open clarifying question. + clarification: openClarification + ? { messageId: openClarification.messageId } + : undefined, fenced, pendingAttachmentIds: currentConversationId === null ? pendingAttachmentIds : undefined, @@ -817,6 +861,14 @@ export function CoachConversation({ {t("insights.coach.attach.indexingHint")}

) : null} + {openClarification ? ( + + ) : null} {/* Docked composer — the SAME centred, capped column as the @@ -1220,6 +1273,7 @@ export function CoachConversation({ optimisticUser={send.optimisticUser} interleaved={interleaved} onRegenerate={handleRegenerate} + onFollowUp={handleFollowUp} /> } composer={composerStack} diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx new file mode 100644 index 000000000..8342434ff --- /dev/null +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -0,0 +1,53 @@ +"use client"; + +/** + * v1.39.4 — the tables under an assistant reply. A live turn hands its + * tables in from the `result` frames; a persisted message lists only their + * metadata, and the values are fetched when the message scrolls into view + * (`useCoachMessageResults`). A withheld table is skipped here; the notice + * for it belongs to the table view. + */ +import { useCoachMessageResults } from "@/hooks/use-coach-message-results"; +import type { + CoachResultMeta, + CoachResultTable as CoachResultTableData, +} from "@/lib/ai/coach/types"; + +import { CoachResultTable } from "./result-table"; + +export interface CoachResultsProps { + conversationId: string | null; + /** The persisted message, once it has an id. */ + messageId: string | null; + /** `metricSource.results` of the message. */ + metas: CoachResultMeta[]; + /** The tables a live turn streamed; absent on a persisted message. */ + live?: CoachResultTableData[]; +} + +export function CoachResults({ + conversationId, + messageId, + metas, + live, +}: CoachResultsProps) { + const hasLive = (live?.length ?? 0) > 0; + const { ref, results } = useCoachMessageResults({ + conversationId, + messageId, + enabled: !hasLive && metas.length > 0, + }); + if (!hasLive && metas.length === 0) return null; + const tables: CoachResultTableData[] = hasLive + ? (live ?? []) + : (results ?? []).filter( + (entry): entry is CoachResultTableData => !("withheld" in entry), + ); + return ( +
+ {tables.map((table) => ( + + ))} +
+ ); +} diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx new file mode 100644 index 000000000..8d14cf772 --- /dev/null +++ b/src/components/insights/coach-panel/follow-up-chips.tsx @@ -0,0 +1,23 @@ +"use client"; + +/** + * v1.39.4 — up to three follow-up chips under the latest assistant reply. + * A tap sends the chip's label as the message with + * `followUp: { messageId, id }`; the server resolves what the chip asks for. + * + * Not built yet: renders nothing. + */ +import type { CoachFollowUp } from "@/lib/ai/coach/types"; + +export interface CoachFollowUpChipsProps { + followUps: CoachFollowUp[]; + /** The assistant message that offered the chips. */ + messageId: string; + /** True while a turn is in flight. */ + disabled: boolean; + onSelect: (followUp: CoachFollowUp, messageId: string) => void; +} + +export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) { + return null; +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index feda6a3cb..90d6a6763 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -9,12 +9,17 @@ import { useTranslations } from "@/lib/i18n/context"; import { PlanProposalCards } from "./plan-proposal-card"; import { ChatBubble } from "./chat-bubble"; +import { CoachFollowUpChips } from "./follow-up-chips"; import type { CoachConversationDetailDTO, CoachOptimisticUserMessage, CoachStreamingMessage, } from "./use-coach"; -import type { CoachMessageDTO } from "@/lib/ai/coach/types"; +import type { + CoachClarification, + CoachFollowUp, + CoachMessageDTO, +} from "@/lib/ai/coach/types"; // v1.28.26 file-size split (pure code motion): the bubble renderer + // per-message actions live in `chat-bubble.tsx`, the read-aloud stack in @@ -72,6 +77,54 @@ export interface MessageThreadProps { * resubmits it as a fresh turn. Omitted → the regenerate action is hidden. */ onRegenerate?: (userText: string) => void; + /** + * v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown. + */ + onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void; +} + +/** + * v1.39.4 — the open clarifying question: the latest assistant turn's + * choices, whether it is the just-settled streamed turn or the last + * persisted message. None while a turn is in flight, and none once the + * person has answered (their message is then the last one). + */ +export function latestClarification( + messages: CoachMessageDTO[], + streaming: CoachStreamingMessage | undefined, +): { messageId: string; clarification: CoachClarification } | null { + if (streaming?.inProgress) return null; + if (streaming?.messageId && streaming.clarification) { + return { + messageId: streaming.messageId, + clarification: streaming.clarification, + }; + } + const last = messages[messages.length - 1]; + if (streaming?.messageId && streaming.messageId !== last?.id) return null; + if (last?.role !== "assistant") return null; + const clarification = last.metricSource?.clarification; + return clarification ? { messageId: last.id, clarification } : null; +} + +/** + * v1.39.4 — the chips to offer, and the message that offered them: the + * latest assistant turn only, whether it is the just-settled streamed turn + * or the last persisted message. None while a turn is in flight. + */ +export function latestFollowUps( + messages: CoachMessageDTO[], + streaming: CoachStreamingMessage | undefined, +): { messageId: string; followUps: CoachFollowUp[] } | null { + if (streaming?.inProgress) return null; + if (streaming?.messageId && streaming.followUps.length > 0) { + return { messageId: streaming.messageId, followUps: streaming.followUps }; + } + const last = messages[messages.length - 1]; + if (streaming?.messageId && streaming.messageId !== last?.id) return null; + if (last?.role !== "assistant") return null; + const followUps = last.metricSource?.followUps ?? []; + return followUps.length > 0 ? { messageId: last.id, followUps } : null; } /** @@ -167,6 +220,7 @@ export function MessageThread({ emptyHint, interleaved, onRegenerate, + onFollowUp, }: MessageThreadProps) { const { t } = useTranslations(); const scrollerRef = useRef(null); @@ -253,6 +307,8 @@ export function MessageThread({ optimisticActive && optimisticUser ? optimisticUser.content : null, ); + const chips = latestFollowUps(messages, streaming); + // Track scroll position so we don't yank the viewport when the user // is browsing earlier turns. useEffect(() => { @@ -385,6 +441,7 @@ export function MessageThread({ role={m.role} content={m.content} metricSource={m.metricSource} + conversationId={conversation?.id ?? null} providerType={m.providerType} messageId={m.id} tokensUsed={m.tokensUsed} @@ -437,6 +494,8 @@ export function MessageThread({ metricSource={streaming.metricSource} suggestion={streaming.suggestion} suggestedAction={streaming.suggestedAction} + steps={streaming.steps} + results={streaming.results} providerType={streaming.inProgress ? "streaming" : null} inProgress={streaming.inProgress} errorCode={streaming.errorCode} @@ -446,6 +505,15 @@ export function MessageThread({ /> )} + {/* v1.39.4 — follow-up chips under the latest assistant turn only. */} + {onFollowUp && chips ? ( + + ) : null} {/* v1.16.5 — thread tail: the current guided question and/or the closing summary follow the last completed turn. */} {placement.tail.map((i) => ( diff --git a/src/components/insights/coach-panel/method-line.tsx b/src/components/insights/coach-panel/method-line.tsx new file mode 100644 index 000000000..e1f135fe0 --- /dev/null +++ b/src/components/insights/coach-panel/method-line.tsx @@ -0,0 +1,18 @@ +"use client"; + +/** + * v1.39.4 — how an answer was worked out, as one muted line inside the + * evidence disclosure: sources, windows, counts and aggregation, rendered + * on the server. Never a health value. + * + * Not built yet: renders nothing. + */ +import type { CoachMethod } from "@/lib/ai/coach/types"; + +export interface CoachMethodLineProps { + method: CoachMethod | null; +} + +export function CoachMethodLine(_props: CoachMethodLineProps) { + return null; +} diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx new file mode 100644 index 000000000..591d5b378 --- /dev/null +++ b/src/components/insights/coach-panel/result-table.tsx @@ -0,0 +1,18 @@ +"use client"; + +/** + * v1.39.4 — one table of values a Coach turn read: a captioned table with + * the first rows shown, the rest behind "Show all", and copy for a + * spreadsheet or as text. + * + * Not built yet: renders nothing. + */ +import type { CoachResultTable as CoachResultTableData } from "@/lib/ai/coach/types"; + +export interface CoachResultTableProps { + result: CoachResultTableData; +} + +export function CoachResultTable(_props: CoachResultTableProps) { + return null; +} diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx new file mode 100644 index 000000000..e066d4245 --- /dev/null +++ b/src/components/insights/coach-panel/turn-steps.tsx @@ -0,0 +1,20 @@ +"use client"; + +/** + * v1.39.4 — what the Coach read on a turn: a compact list of steps under + * the assistant avatar, live while the turn runs (from the `step` frames) + * and restored from `metricSource.steps` on reload. + * + * Not built yet: renders nothing. + */ +import type { CoachStep } from "@/lib/ai/coach/types"; + +export interface CoachTurnStepsProps { + steps: CoachStep[]; + /** True while the turn is still running. */ + active: boolean; +} + +export function CoachTurnSteps(_props: CoachTurnStepsProps) { + return null; +} diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index a46aff9ef..75df5b9a0 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -11,11 +11,15 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { toast } from "sonner"; import type { + CoachClarification, CoachConversationAttachmentDTO, CoachConversationDetailDTO, CoachConversationsPage, + CoachFollowUp, CoachProvenance, + CoachResultTable, CoachScope, + CoachStep, CoachStreamEvent, CoachSuggestion, CoachUsage, @@ -539,6 +543,21 @@ export interface CoachStreamingMessage { * on `metricSource.suggestedAction` instead. Null otherwise. */ suggestedAction: CoachSuggestedAction | null; + /** + * v1.39.4 — the live steps from the additive `step` frames, upserted by + * id in first-seen order. Persisted messages carry them on + * `metricSource.steps`. + */ + steps: CoachStep[]; + /** + * v1.39.4 — the tables from the additive `result` frames. Persisted + * messages carry only their metadata; the values are fetched lazily. + */ + results: CoachResultTable[]; + /** v1.39.4 — the chips from the additive `followUps` frame. */ + followUps: CoachFollowUp[]; + /** v1.39.4 — the choices from the additive `clarification` frame. */ + clarification: CoachClarification | null; /** True until the `done` frame closes the stream. */ inProgress: boolean; /** Final messageId once `done` lands; null otherwise. */ @@ -617,12 +636,25 @@ const EMPTY_STREAMING: CoachStreamingMessage = { metricSource: null, suggestion: null, suggestedAction: null, + steps: [], + results: [], + followUps: [], + clarification: null, inProgress: false, messageId: null, errorCode: null, usage: null, }; +/** v1.39.4 — insert a step, or replace the one with the same id in place. */ +export function upsertStep(steps: CoachStep[], step: CoachStep): CoachStep[] { + const at = steps.findIndex((s) => s.id === step.id); + if (at === -1) return [...steps, step]; + const next = [...steps]; + next[at] = step; + return next; +} + export interface SendCoachMessageParams { conversationId?: string; message: string; @@ -665,6 +697,18 @@ export interface SendCoachMessageParams { * client re-sent it. */ workoutId?: string; + /** + * v1.39.4 — the follow-up chip this message came from: the assistant + * message that offered it and the chip's id. The server resolves the chip + * from what it persisted; the client never sends what the chip asks for. + * Tool route only. + */ + followUp?: { messageId: string; id: string }; + /** + * v1.39.4 — the clarifying question this message answers, and the chip + * tapped (absent when the person typed their own answer). Tool route only. + */ + clarification?: { messageId: string; choiceId?: string }; } /** @@ -712,6 +756,8 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): { scope: params.scope, guidedQuestion: params.guidedQuestion, workoutId: params.workoutId, + followUp: params.followUp, + clarification: params.clarification, }), }; } @@ -857,14 +903,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { conversationId: params.conversationId ?? null, }); setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, + ...EMPTY_STREAMING, inProgress: true, - messageId: null, - errorCode: null, - usage: null, }); // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode @@ -872,14 +912,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { // waiting for the fetch to fail with a generic network error. if (typeof navigator !== "undefined" && navigator.onLine === false) { setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, + ...EMPTY_STREAMING, errorCode: "coach.network", - usage: null, }); return null; } @@ -905,28 +939,16 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { } catch (err) { if ((err as Error).name === "AbortError") return null; setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, + ...EMPTY_STREAMING, errorCode: "coach.network", - usage: null, }); return null; } if (!response.body) { setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, + ...EMPTY_STREAMING, errorCode: `coach.http.${response.status}`, - usage: null, }); return null; } @@ -963,14 +985,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { // body was not JSON; fall through to the http-status fallback } setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, + ...EMPTY_STREAMING, errorCode: structured ?? `coach.http.${response.status}`, - usage: null, }); // v1.16.4 — KEEP the optimistic user bubble: a rejected turn // (budget gate, 4xx) has no persisted twin coming, and dropping @@ -988,6 +1004,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { let collectedProvenance: CoachProvenance | null = null; let collectedSuggestion: CoachSuggestion | null = null; let collectedSuggestedAction: CoachSuggestedAction | null = null; + let collectedSteps: CoachStep[] = []; + const collectedResults: CoachResultTable[] = []; + let collectedFollowUps: CoachFollowUp[] = []; + let collectedClarification: CoachClarification | null = null; let lastError: string | null = null; let messageId: string | null = null; let collectedUsage: CoachUsage | null = null; @@ -1029,6 +1049,39 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { suggestedAction: evt.suggestedAction, })); break; + case "step": { + // v1.39.4 — live progress, upserted by step id. + const step = evt.step; + collectedSteps = upsertStep(collectedSteps, step); + setStreaming((prev) => ({ + ...prev, + steps: upsertStep(prev.steps, step), + })); + break; + } + case "result": { + const result = evt.result; + collectedResults.push(result); + setStreaming((prev) => ({ + ...prev, + results: [...prev.results, result], + })); + break; + } + case "followUps": + collectedFollowUps = evt.followUps; + setStreaming((prev) => ({ + ...prev, + followUps: evt.followUps, + })); + break; + case "clarification": + collectedClarification = evt.clarification; + setStreaming((prev) => ({ + ...prev, + clarification: evt.clarification, + })); + break; case "reasoning": // v1.19.1 — the thinking disclosure was removed, so the // additive `reasoning` frame is accepted off the wire and @@ -1069,6 +1122,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { metricSource: collectedProvenance, suggestion: collectedSuggestion, suggestedAction: collectedSuggestedAction, + steps: collectedSteps, + results: collectedResults, + followUps: collectedFollowUps, + clarification: collectedClarification, inProgress: false, messageId, errorCode: lastError, @@ -1083,6 +1140,15 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { queryKey: QUERY_KEYS.one(resolvedConversationId), }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); + // v1.39.4 — the tables this turn streamed are exactly what the + // persisted message stores, so seed its lazy read rather than + // decrypting them again on the server when the twin lands. + if (messageId && collectedResults.length > 0) { + queryClient.setQueryData( + queryKeys.coachMessageResults(resolvedConversationId, messageId), + collectedResults, + ); + } optsRef.current.onDone?.(resolvedConversationId); } // v1.4.25 W5 — drop the optimistic user bubble; the persisted diff --git a/src/hooks/use-coach-message-results.ts b/src/hooks/use-coach-message-results.ts new file mode 100644 index 000000000..3aeafe801 --- /dev/null +++ b/src/hooks/use-coach-message-results.ts @@ -0,0 +1,74 @@ +"use client"; + +/** + * v1.39.4 — the stored tables of one Coach message, fetched lazily. + * + * A thread can hold many messages with tables, and each table set is + * decrypted on the server per request, so nothing is fetched until the + * message scrolls near the viewport: mount the returned `ref` on the + * element that shows the tables. `enabled` gates the whole read (a message + * whose provenance lists no tables never asks). Once fetched, the tables are + * the stored snapshot and do not change, so they stay fresh indefinitely. + */ +import { useEffect, useState, type RefCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import type { CoachResultEntry } from "@/lib/ai/coach/types"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +export function useCoachMessageResults(args: { + conversationId: string | null; + messageId: string | null; + enabled: boolean; +}): { + ref: RefCallback; + results: CoachResultEntry[] | undefined; + isLoading: boolean; + isError: boolean; +} { + const { conversationId, messageId, enabled } = args; + const [node, setNode] = useState(null); + const [visible, setVisible] = useState(false); + + // Without an observer (an old engine) there is nothing to wait for. + const canObserve = typeof IntersectionObserver !== "undefined"; + + useEffect(() => { + if (!enabled || visible || !node || !canObserve) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) setVisible(true); + }, + { rootMargin: "200px" }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [enabled, visible, node, canObserve]); + + const query = useQuery({ + queryKey: queryKeys.coachMessageResults( + conversationId ?? "", + messageId ?? "", + ), + queryFn: async (): Promise => { + const data = await apiGet<{ results: CoachResultEntry[] }>( + `/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/results`, + ); + return data.results; + }, + enabled: + enabled && + (visible || !canObserve) && + conversationId !== null && + messageId !== null, + staleTime: Infinity, + }); + + return { + ref: setNode, + results: query.data, + isLoading: query.isLoading, + isError: query.isError, + }; +} diff --git a/src/lib/ai/coach/__tests__/dialog-keys.test.ts b/src/lib/ai/coach/__tests__/dialog-keys.test.ts new file mode 100644 index 000000000..c445d0bd8 --- /dev/null +++ b/src/lib/ai/coach/__tests__/dialog-keys.test.ts @@ -0,0 +1,157 @@ +/** + * The dialog's key catalog and the seven bundles agree, both ways. + * + * Forward: every key the catalog can produce — every label map entry, every + * domain, window, period and granularity, every plural tier — resolves in + * every locale. Reverse: every leaf under the namespaces the catalog owns + * (`coach.step`, `coach.result`, `coach.method`, `coach.followUp`, + * `coach.clarify`, `coach.reuse`) is one the catalog can produce, so a + * string cannot sit in the bundles with nothing able to reach it. + * + * Checked by breaking it: deleting `coach.result.copied` from `ko.json` + * fails the forward test by locale and key; adding an unused leaf under + * `coach.step` fails the reverse test by name. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { + coachScopeWindowSchema, + type CoachResultGranularity, + type CoachResultPeriod, +} from "@/lib/ai/coach/types"; +import { coachStepDomainSchema } from "@/lib/ai/coach/stream-events"; +import { + COACH_CLARIFY_UI_KEYS, + COACH_FOLLOW_UP_KEYS, + COACH_FOLLOW_UP_UI_KEYS, + COACH_METHOD_ABSENT_KEYS, + COACH_METHOD_AGGREGATION_KEYS, + COACH_METHOD_KEYS, + COACH_RESULT_COLUMN_KEYS, + COACH_RESULT_TITLE_KEYS, + COACH_RESULT_UI_KEYS, + COACH_RESULT_WITHHELD_KEYS, + COACH_REUSE_CAPTION_KEY, + COACH_STEP_LABEL_KEYS, + COACH_STEP_REASON_KEYS, + COACH_STEP_UI_KEYS, + clarifyWindowLabelKey, + coachDomainLabelKey, + coachGranularityLabelKey, + coachPeriodLabelKey, + coachWindowLabelKey, + methodAveragesKey, + methodReadingsKey, + methodTotalsKey, + stepHeaderDoneKey, + stepMetricsKey, + stepReadingsKey, + stepRowsKey, +} from "@/lib/ai/coach/dialog-keys"; + +const ROOT = join(__dirname, "../../../../.."); + +function bundle(locale: Locale): Record { + return JSON.parse( + readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), + ) as Record; +} + +function leaves(node: unknown, prefix: string, out: Map) { + if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + leaves(value, prefix ? `${prefix}.${key}` : key, out); + } + } else { + out.set(prefix, node); + } + return out; +} + +const PERIODS: CoachResultPeriod[] = ["current", "previous", "yearAgo"]; +const GRANULARITIES: CoachResultGranularity[] = ["day", "week", "month"]; + +/** Counts that land on every plural tier some locale uses. */ +const COUNTS = [0, 1, 2, 3, 5, 12, 22]; + +function catalogKeys(): Set { + const keys = new Set([ + ...Object.values(COACH_STEP_LABEL_KEYS), + ...Object.values(COACH_STEP_UI_KEYS), + ...Object.values(COACH_STEP_REASON_KEYS), + ...Object.values(COACH_RESULT_TITLE_KEYS), + ...Object.values(COACH_RESULT_COLUMN_KEYS), + ...Object.values(COACH_RESULT_UI_KEYS), + ...Object.values(COACH_RESULT_WITHHELD_KEYS), + ...Object.values(COACH_METHOD_KEYS), + ...Object.values(COACH_METHOD_AGGREGATION_KEYS), + ...Object.values(COACH_METHOD_ABSENT_KEYS), + ...Object.values(COACH_FOLLOW_UP_KEYS), + ...Object.values(COACH_FOLLOW_UP_UI_KEYS), + ...Object.values(COACH_CLARIFY_UI_KEYS), + COACH_REUSE_CAPTION_KEY, + ]); + for (const domain of coachStepDomainSchema.options) { + keys.add(coachDomainLabelKey(domain)); + } + for (const window of coachScopeWindowSchema.options) { + keys.add(coachWindowLabelKey(window)); + keys.add(clarifyWindowLabelKey(window)); + } + for (const period of PERIODS) { + keys.add(coachPeriodLabelKey(period)); + } + for (const granularity of GRANULARITIES) { + keys.add(coachGranularityLabelKey(granularity)); + keys.add(methodAveragesKey(granularity)); + keys.add(methodTotalsKey(granularity)); + } + for (const locale of locales) { + for (const count of COUNTS) { + keys.add(stepHeaderDoneKey(count, locale)); + keys.add(stepReadingsKey(count, locale)); + keys.add(stepRowsKey(count, locale)); + keys.add(stepMetricsKey(count, locale)); + keys.add(methodReadingsKey(count, locale)); + } + } + return keys; +} + +const OWNED = [ + "coach.step", + "coach.result", + "coach.method", + "coach.followUp", + "coach.clarify", + "coach.reuse", +]; + +describe("Coach dialog keys", () => { + const keys = catalogKeys(); + + it("produces a non-trivial catalog", () => { + expect(keys.size).toBeGreaterThan(130); + }); + + it.each(locales)("every catalog key resolves in %s", (locale) => { + const strings = leaves(bundle(locale), "", new Map()); + const missing = [...keys].filter( + (key) => typeof strings.get(key) !== "string" || strings.get(key) === "", + ); + expect(missing).toEqual([]); + }); + + it("every leaf under the owned namespaces is reachable from the catalog", () => { + const strings = leaves(bundle("en"), "", new Map()); + const owned = [...strings.keys()].filter((key) => + OWNED.some((ns) => key.startsWith(`${ns}.`)), + ); + expect(owned.length).toBeGreaterThan(0); + expect(owned.filter((key) => !keys.has(key))).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/persistence-results.test.ts b/src/lib/ai/coach/__tests__/persistence-results.test.ts new file mode 100644 index 000000000..bdbc97589 --- /dev/null +++ b/src/lib/ai/coach/__tests__/persistence-results.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * v1.39.4 — the dialog fields of the provenance survive a reload, malformed + * entries are dropped one by one, the tables are sealed into their own + * column, and the owner-narrowed read serves each table whole or names why + * it does not. + */ +const txCreate = { + coachConversation: { update: vi.fn() }, + coachMessage: { create: vi.fn() }, +}; +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn(async (cb: (tx: typeof txCreate) => Promise) => + cb(txCreate), + ), + coachMessage: { findFirst: (...args: unknown[]) => findFirst(...args) }, + }, +})); +vi.mock("../bytes-codec", () => ({ + encryptToBytes: vi.fn((text: string) => + new TextEncoder().encode(`enc:${text}`), + ), + decryptFromBytes: vi.fn((bytes: Uint8Array) => { + const text = new TextDecoder().decode(bytes); + if (!text.startsWith("enc:")) throw new Error("bad ciphertext"); + return text.slice(4); + }), +})); + +import { + RESULTS_MAX_BYTES, + appendMessage, + readMessageResults, +} from "../persistence"; +import type { + CoachProvenance, + CoachResultMeta, + CoachResultTable, +} from "../types"; + +const META: CoachResultMeta = { + ref: "r1", + source: { + tool: "get_metric_series", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.metricByPeriod", + title: "Blood pressure by day", + rowCount: 2, + chartKind: "line", + displayed: true, +}; + +const TABLE: CoachResultTable = { + ...META, + columns: [ + { + key: "period", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "sys", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + ], + rows: [ + ["2026-09-01", 128], + ["2026-09-02", null], + ], + truncated: false, + chart: { kind: "line", x: "period", series: ["sys"] }, +}; + +const SLEEP_META: CoachResultMeta = { + ...META, + ref: "r2", + source: { ...META.source, domain: "sleep", tool: "get_sleep" }, + title: "Sleep by night", +}; + +const FULL: CoachProvenance = { + windows: ["last30days"], + metrics: ["bp"], + steps: [ + { + id: "s1", + tool: "get_metric_series", + labelKey: "coach.step.read", + label: "Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + status: "done", + count: 42, + resultRef: "r1", + }, + ], + method: { + entries: [ + { domain: "bp", window: "last30days", count: 42, aggregation: "mean" }, + ], + text: "Blood pressure, last 30 days: 42 readings", + }, + results: [META], + followUps: [ + { + id: "f1", + kind: "previous_period", + labelKey: "coach.followUp.previousPeriod", + label: "Compare with the period before", + anchor: { ref: "r1", domain: "bp", window: "last30days" }, + reuse: false, + origin: "server", + }, + ], + clarification: { + kind: "window", + choices: [ + { + id: "c1", + labelKey: "coach.clarify.window.last30days", + label: "Last 30 days", + value: { window: "last30days" }, + }, + ], + freeText: true, + }, + forcedFinal: true, +}; + +function stubEchoCreate(overrideJson?: string): void { + txCreate.coachConversation.update.mockResolvedValue({}); + txCreate.coachMessage.create.mockImplementation( + async (arg: { data: { metricSourceJson: string | null } }) => ({ + id: "m1", + role: "assistant", + createdAt: new Date("2026-09-26T00:00:00.000Z"), + metricSourceJson: overrideJson ?? arg.data.metricSourceJson, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("appendMessage — the dialog fields", () => { + it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => { + stubEchoCreate(); + const out = await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: FULL, + }); + expect(out.metricSource).toEqual(FULL); + }); + + it("drops a malformed entry and keeps its well-formed siblings", async () => { + stubEchoCreate( + JSON.stringify({ + windows: [], + metrics: [], + steps: [FULL.steps![0], { id: "s2", status: "exploded" }], + results: [{ ref: "r9" }, META], + followUps: [{ ...FULL.followUps![0], kind: "free_text" }], + method: { entries: "nope", text: "x" }, + clarification: { kind: "metric", choices: [], freeText: "yes" }, + forcedFinal: "true", + }), + ); + const out = await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: { windows: [], metrics: [] }, + }); + expect(out.metricSource).toEqual({ + windows: [], + metrics: [], + steps: FULL.steps, + results: [META], + }); + }); + + it("strips a key the stored blob carries that the contract does not know", async () => { + stubEchoCreate( + JSON.stringify({ + windows: [], + metrics: [], + results: [{ ...META, rows: [["2026-09-01", 128]] }], + }), + ); + const out = await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: { windows: [], metrics: [] }, + }); + // Values never ride the plaintext provenance, even when a blob has them. + expect(out.metricSource?.results?.[0]).not.toHaveProperty("rows"); + }); +}); + +describe("appendMessage — the tables", () => { + it("seals the tables into their own column and leaves them out of the provenance", async () => { + stubEchoCreate(); + await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: { windows: [], metrics: [], results: [META] }, + results: [TABLE], + }); + const data = txCreate.coachMessage.create.mock.calls[0][0].data; + expect(new TextDecoder().decode(data.resultsEncrypted)).toBe( + `enc:${JSON.stringify([TABLE])}`, + ); + expect(data.metricSourceJson).not.toContain("2026-09-01"); + }); + + it("writes NULL on a turn without tables", async () => { + stubEchoCreate(); + await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + results: [], + }); + const data = txCreate.coachMessage.create.mock.calls[0][0].data; + expect(data.resultsEncrypted).toBeNull(); + }); + + it("drops whole tables from the end until the message fits the ceiling", async () => { + stubEchoCreate(); + const big: CoachResultTable = { + ...TABLE, + ref: "r2", + rows: Array.from({ length: 400 }, () => ["x".repeat(400), 1]), + }; + await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + results: [TABLE, big], + }); + const data = txCreate.coachMessage.create.mock.calls[0][0].data; + const json = new TextDecoder().decode(data.resultsEncrypted).slice(4); + expect(Buffer.byteLength(json)).toBeLessThanOrEqual(RESULTS_MAX_BYTES); + expect(JSON.parse(json)).toEqual([TABLE]); + }); +}); + +describe("readMessageResults", () => { + function row(results: CoachResultMeta[], bytes: Uint8Array | null) { + return { + metricSourceJson: JSON.stringify({ windows: [], metrics: [], results }), + resultsEncrypted: bytes, + }; + } + const sealed = (tables: unknown) => + new TextEncoder().encode(`enc:${JSON.stringify(tables)}`); + + it("narrows to the owner through the conversation", async () => { + findFirst.mockResolvedValue(null); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toBeNull(); + expect(findFirst.mock.calls[0][0].where).toEqual({ + id: "m1", + conversationId: "c1", + conversation: { userId: "u1" }, + }); + }); + + it("serves every table the provenance lists, in its order", async () => { + findFirst.mockResolvedValue(row([META], sealed([TABLE]))); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([ + TABLE, + ]); + }); + + it("withholds a table whose domain is switched off, without decrypting it", async () => { + findFirst.mockResolvedValue( + row([META, SLEEP_META], sealed([TABLE, { ...TABLE, ...SLEEP_META }])), + ); + const out = await readMessageResults( + "u1", + "c1", + "m1", + (domain) => domain === "sleep", + ); + expect(out).toEqual([TABLE, { ref: "r2", withheld: "module_disabled" }]); + + findFirst.mockResolvedValue(row([SLEEP_META], sealed([TABLE]))); + const { decryptFromBytes } = await import("../bytes-codec"); + vi.mocked(decryptFromBytes).mockClear(); + await readMessageResults("u1", "c1", "m1", () => true); + expect(decryptFromBytes).not.toHaveBeenCalled(); + }); + + it("names a table it cannot read rather than serving nothing", async () => { + findFirst.mockResolvedValue( + row([META], new TextEncoder().encode("garbage")), + ); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([ + { ref: "r1", withheld: "unavailable" }, + ]); + + // A ref the ciphertext does not carry (dropped at the size ceiling). + findFirst.mockResolvedValue(row([META, SLEEP_META], sealed([TABLE]))); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([ + TABLE, + { ref: "r2", withheld: "unavailable" }, + ]); + }); + + it("answers an empty list for a message without tables", async () => { + findFirst.mockResolvedValue({ + metricSourceJson: null, + resultsEncrypted: null, + }); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts b/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts new file mode 100644 index 000000000..bec015176 --- /dev/null +++ b/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts @@ -0,0 +1,71 @@ +/** + * The Zod mirror of the Coach wire (`stream-events.ts`) and the TypeScript + * contract (`types.ts`) describe the same shapes. + * + * The checks are compile-time: `expectTypeOf(...).toEqualTypeOf<...>()` + * fails `pnpm typecheck` when a field is added, dropped, renamed or retyped + * on one side only. Equality is exact in both directions, so a field that + * is optional on one side and required on the other fails as well. + * + * Checked by breaking it: removing `resultRef` from `coachStepSchema`, and + * making `CoachFollowUp.reuse` optional in `types.ts`, each fail the + * typecheck on the matching line below. + */ +import { describe, expectTypeOf, it } from "vitest"; +import type { z } from "zod/v4"; + +import type { + CoachClarification, + CoachFollowUp, + CoachMethod, + CoachProvenance, + CoachResultEntry, + CoachResultMeta, + CoachResultTable, + CoachStep, + CoachStreamEvent, +} from "@/lib/ai/coach/types"; +import { + coachClarificationSchema, + coachFollowUpSchema, + coachMethodSchema, + coachProvenanceSchema, + coachResultEntrySchema, + coachResultMetaSchema, + coachResultTableSchema, + coachStepSchema, + coachStreamEventSchema, +} from "@/lib/ai/coach/stream-events"; + +describe("Coach wire: Zod mirror equals the TypeScript contract", () => { + it("every frame", () => { + expectTypeOf< + z.infer + >().toEqualTypeOf(); + }); + + it("each component the OpenAPI document names", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts new file mode 100644 index 000000000..f5f0cc3c4 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts @@ -0,0 +1,152 @@ +/** + * v1.39.4 — the loop's progress seam: every call that starts settles, the + * trace keeps only schema-validated arguments, and a forced answer at the + * round cap is reported as such. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +const executeCoachTool = vi.fn<(args?: unknown) => Promise>(); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: (args: unknown) => executeCoachTool(args), +})); +const runRawCompletionWithFallback = vi.fn(); +vi.mock("@/lib/ai/provider-runner", () => ({ + runRawCompletionWithFallback: (args: unknown) => + runRawCompletionWithFallback(args), +})); + +import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; +import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; + +type Call = { id: string; name: string; arguments: string }; + +function round(content: string, toolCalls?: Call[]) { + return { + result: { + content, + tokensUsed: 5, + model: "mock", + providerType: "anthropic" as const, + ...(toolCalls + ? { toolCalls, finishReason: "tool_calls" as const } + : { finishReason: "stop" as const }), + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }; +} + +const baseArgs = { + userId: "u1", + providers: [], + system: "sys", + messages: [{ role: "user" as const, content: "q" }], + tools: COACH_TOOL_DEFS, +}; + +beforeEach(() => { + executeCoachTool.mockReset(); + runRawCompletionWithFallback.mockReset(); +}); + +describe("runCoachToolLoop — progress callbacks", () => { + it("settles every call that started, misses and invalid calls included, with turn-wide indexes", async () => { + executeCoachTool + .mockResolvedValueOnce({ present: true, data: {} }) + .mockResolvedValueOnce({ present: false, reason: "invalid_arguments" }) + .mockResolvedValueOnce({ present: false, reason: "unknown_tool" }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + round("", [ + { id: "a", name: "get_metric_series", arguments: '{"metric":"bp"}' }, + { id: "b", name: "get_sleep", arguments: "{not json" }, + ]), + ) + .mockResolvedValueOnce( + round("", [{ id: "c", name: "get_everything", arguments: "{}" }]), + ) + .mockResolvedValueOnce(round("answer")); + + const started: Array<[string, number]> = []; + const settled: Array<[string, boolean, number]> = []; + const out = await runCoachToolLoop({ + ...baseArgs, + onCallStart: (call, index) => started.push([call.id, index]), + onCallSettled: (call, result, index) => + settled.push([call.id, result.present, index]), + }); + + expect(started).toEqual([ + ["a", 0], + ["b", 1], + ["c", 2], + ]); + expect([...settled].sort((x, y) => x[2] - y[2])).toEqual([ + ["a", true, 0], + ["b", false, 1], + ["c", false, 2], + ]); + expect(out.forcedFinal).toBe(false); + }); + + it("keeps only schema-validated arguments on the trace", async () => { + executeCoachTool.mockResolvedValue({ present: false }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + round("", [ + { id: "a", name: "get_metric_series", arguments: '{"metric":"bp"}' }, + // Unknown key: the strict schema refuses it. + { + id: "b", + name: "get_metric_series", + arguments: '{"metric":"bp","userId":"someone-else"}', + }, + { id: "c", name: "get_sleep", arguments: "{not json" }, + ]), + ) + .mockResolvedValueOnce(round("answer")); + + const out = await runCoachToolLoop(baseArgs); + const byArgs = out.toolTrace.map((t) => t.args ?? null); + expect(byArgs).toContainEqual({ metric: "bp" }); + expect(byArgs.filter((a) => a === null)).toHaveLength(2); + expect(JSON.stringify(out.toolTrace)).not.toContain("someone-else"); + }); + + it("a throwing callback never breaks the turn", async () => { + executeCoachTool.mockResolvedValue({ present: true, data: {} }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + round("", [{ id: "a", name: "get_sleep", arguments: "{}" }]), + ) + .mockResolvedValueOnce(round("answer")); + const out = await runCoachToolLoop({ + ...baseArgs, + onCallStart: () => { + throw new Error("closed stream"); + }, + onCallSettled: () => { + throw new Error("closed stream"); + }, + }); + expect(out.result.content).toBe("answer"); + expect(out.toolTrace).toHaveLength(1); + }); + + it("reports a forced answer at the round cap", async () => { + executeCoachTool.mockResolvedValue({ present: false }); + runRawCompletionWithFallback.mockImplementation( + (args: { params: { toolChoice?: string } }) => + Promise.resolve( + args.params.toolChoice === "none" + ? round("forced") + : round("", [{ id: "x", name: "get_sleep", arguments: "{}" }]), + ), + ); + const out = await runCoachToolLoop(baseArgs); + expect(out.rounds).toBe(HARD_CAP); + expect(out.forcedFinal).toBe(true); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts index c19d6e9b6..ed35284c4 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts @@ -79,7 +79,9 @@ describe("coach tool loop — which results reach the grounding guard", () => { const loop = await runCoachToolLoop(baseArgs); // The trace records that the tool ran and found nothing… - expect(loop.toolTrace).toEqual([{ name: "get_sleep", present: false }]); + expect(loop.toolTrace).toEqual([ + { name: "get_sleep", present: false, args: {} }, + ]); // …but the payload set the route grades against is empty, so the route's // `activatingPayloads.length > 0` gate is false and the numeric grounding // guard never runs for this turn. diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 1d1e2c899..973c1d751 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -88,7 +88,7 @@ describe("runCoachToolLoop", () => { expect(out.result.content).toBe("Your BP looks steady."); expect(out.totalTokens).toBe(50); // summed across both rounds expect(out.toolTrace).toEqual([ - { name: "get_metric_series", present: true }, + { name: "get_metric_series", present: true, args: { metric: "bp" } }, ]); // v1.21.0 (P6) — the present result's payload is retained for the prose // number-verifier. @@ -278,7 +278,7 @@ describe("shared snapshot scope threading (D5-1)", () => { expect(out.toolResults).toHaveLength(1); expect(out.toolResults[0].available).toMatchObject({ count: 1597 }); expect(out.toolTrace).toEqual([ - { name: "get_glucose_panel", present: false }, + { name: "get_glucose_panel", present: false, args: {} }, ]); }); diff --git a/src/lib/ai/coach/clarify.ts b/src/lib/ai/coach/clarify.ts new file mode 100644 index 000000000..2a02ee4a3 --- /dev/null +++ b/src/lib/ai/coach/clarify.ts @@ -0,0 +1,34 @@ +/** + * Clarifying questions. The model may end a reply with a `---CLARIFY---` + * block when the metric or window stays genuinely ambiguous; the server + * validates the choices against what the record holds, and the question text + * is the reply itself. The person answers with + * `clarification: { messageId, choiceId? }` on the next request. + * + * Not built yet: the prose passes through unchanged, no clarification is + * offered, and an answer adds nothing to the turn. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { CoachClarification } from "@/lib/ai/coach/types"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; + +export function parseClarifySentinel(args: { + prose: string; + /** What the record holds; null on the no-tools path. */ + inventory: InventoryEntry[] | null; + locale: Locale; +}): { prose: string; clarification: CoachClarification | null } { + return { prose: args.prose, clarification: null }; +} + +/** + * The turn-context line for an answered clarification, or null when the + * request carries none or its question is no longer current. + */ +export async function resolveClarificationAnswer(_args: { + userId: string; + conversationId: string | undefined; + clarification: { messageId: string; choiceId?: string } | undefined; +}): Promise { + return null; +} diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts new file mode 100644 index 000000000..a04dec646 --- /dev/null +++ b/src/lib/ai/coach/dialog-keys.ts @@ -0,0 +1,255 @@ +/** + * The message keys of the Coach dialog: live steps, result tables, the + * method line, follow-up chips, clarifying questions, the reuse caption. + * + * One closed catalog, shared by the server (which renders every `label` in + * the request locale) and the web client (which renders `labelKey` when it + * has the string, and the server's `label` when it does not). A label never + * carries model text: it is one of these keys plus server-chosen values + * (a domain, a window, a count). + * + * Every key here exists in all seven bundles; `__tests__/dialog-keys.test.ts` + * holds that, in both directions for the namespaces this file owns. + * + * Client-safe: no server import. + */ +import type { Locale } from "@/lib/i18n/config"; +import { pluralKey } from "@/lib/i18n/plural"; +import { + coachScopeSourceSchema, + type CoachFollowUpKind, + type CoachMethodEntry, + type CoachResultGranularity, + type CoachResultPeriod, + type CoachScopeWindow, + type CoachStepDomain, + type CoachStepReason, +} from "@/lib/ai/coach/types"; + +// ── Steps ───────────────────────────────────────────────────────────────── + +/** The step label templates (`{domain}`, `{window}`). */ +export const COACH_STEP_LABEL_KEYS = { + read: "coach.step.read", + readWindow: "coach.step.readWindow", + snapshot: "coach.step.snapshot", + showResult: "coach.step.showResult", + reuse: "coach.step.reuse", +} as const; + +/** The step list's own chrome. */ +export const COACH_STEP_UI_KEYS = { + headerActive: "coach.step.headerActive", + listLabel: "coach.step.listLabel", + toggleShow: "coach.step.toggleShow", + toggleHide: "coach.step.toggleHide", + announceDone: "coach.step.announceDone", +} as const; + +/** "Looked at N sources", by plural tier. */ +export function stepHeaderDoneKey(count: number, locale: Locale): string { + return pluralKey("coach.step.headerDone", count, locale); +} + +/** "N readings" on a step row, by plural tier. */ +export function stepReadingsKey(count: number, locale: Locale): string { + return pluralKey("coach.step.readings", count, locale); +} + +/** "N rows" on a step row, by plural tier. */ +export function stepRowsKey(count: number, locale: Locale): string { + return pluralKey("coach.step.rows", count, locale); +} + +/** "N metrics" on the snapshot step, by plural tier. */ +export function stepMetricsKey(count: number, locale: Locale): string { + return pluralKey("coach.step.metrics", count, locale); +} + +export const COACH_STEP_REASON_KEYS: Readonly> = + { + no_data: "coach.step.reason.noData", + outside_window: "coach.step.reason.outsideWindow", + module_disabled: "coach.step.reason.moduleDisabled", + retrieval_failed: "coach.step.reason.retrievalFailed", + invalid_arguments: "coach.step.reason.invalidArguments", + }; + +const SCOPE_SOURCES: ReadonlySet = new Set( + coachScopeSourceSchema.options, +); + +/** + * A domain's display name. A measurement-backed scope source reuses the + * metric names the sources rail already shows; the domains read as a whole + * have their own. + */ +export function coachDomainLabelKey(domain: CoachStepDomain): string { + return SCOPE_SOURCES.has(domain) + ? `insights.coach.metric.${domain}` + : `coach.step.domain.${domain}`; +} + +/** A window in running text ("last 30 days"). */ +export function coachWindowLabelKey(window: CoachScopeWindow): string { + return `coach.step.window.${window}`; +} + +export function coachPeriodLabelKey(period: CoachResultPeriod): string { + return `coach.step.period.${period}`; +} + +export function coachGranularityLabelKey( + granularity: CoachResultGranularity, +): string { + return `coach.step.granularity.${granularity}`; +} + +// ── Results ─────────────────────────────────────────────────────────────── + +/** Table titles; `{metric}` where the title names one. */ +export const COACH_RESULT_TITLE_KEYS = { + byDay: "coach.result.title.byDay", + byWeek: "coach.result.title.byWeek", + byMonth: "coach.result.title.byMonth", + previousPeriod: "coach.result.title.previousPeriod", + yearAgo: "coach.result.title.yearAgo", + workoutsBySport: "coach.result.title.workoutsBySport", + labsLatest: "coach.result.title.labsLatest", + sleepByNight: "coach.result.title.sleepByNight", + complianceByDay: "coach.result.title.complianceByDay", + distribution: "coach.result.title.distribution", +} as const; + +/** Column headings. */ +export const COACH_RESULT_COLUMN_KEYS = { + day: "coach.result.column.day", + week: "coach.result.column.week", + month: "coach.result.column.month", + night: "coach.result.column.night", + sport: "coach.result.column.sport", + sessions: "coach.result.column.sessions", + analyte: "coach.result.column.analyte", + value: "coach.result.column.value", + unit: "coach.result.column.unit", + date: "coach.result.column.date", + referenceRange: "coach.result.column.referenceRange", + systolic: "coach.result.column.systolic", + diastolic: "coach.result.column.diastolic", + mean: "coach.result.column.mean", + min: "coach.result.column.min", + max: "coach.result.column.max", + readings: "coach.result.column.readings", + rate: "coach.result.column.rate", + duration: "coach.result.column.duration", + range: "coach.result.column.range", + count: "coach.result.column.count", +} as const; + +/** The table's own chrome: paging, copy, the chart/table toggle, notices. */ +export const COACH_RESULT_UI_KEYS = { + other: "coach.result.other", + noReading: "coach.result.noReading", + showAll: "coach.result.showAll", + showFewer: "coach.result.showFewer", + copyMenu: "coach.result.copyMenu", + copyForSpreadsheet: "coach.result.copyForSpreadsheet", + copyAsText: "coach.result.copyAsText", + copied: "coach.result.copied", + copyFailed: "coach.result.copyFailed", + viewLabel: "coach.result.viewLabel", + viewChart: "coach.result.viewChart", + viewTable: "coach.result.viewTable", + chartSummary: "coach.result.chartSummary", + dataUsed: "coach.result.dataUsed", + truncated: "coach.result.truncated", + loading: "coach.result.loading", + loadFailed: "coach.result.loadFailed", + histogramBin: "coach.result.histogramBin", +} as const; + +/** Why a stored table is not shown. */ +export const COACH_RESULT_WITHHELD_KEYS = { + module_disabled: "coach.result.withheld.moduleDisabled", + unavailable: "coach.result.withheld.unavailable", +} as const; + +// ── Method ──────────────────────────────────────────────────────────────── + +export const COACH_METHOD_KEYS = { + label: "coach.method.label", + entry: "coach.method.entry", + entryNoWindow: "coach.method.entryNoWindow", +} as const; + +/** "N readings" in the method line, by plural tier. */ +export function methodReadingsKey(count: number, locale: Locale): string { + return pluralKey("coach.method.readings", count, locale); +} + +export const COACH_METHOD_AGGREGATION_KEYS: Readonly< + Record, string> +> = { + mean: "coach.method.aggregation.mean", + median: "coach.method.aggregation.median", + latest: "coach.method.aggregation.latest", + sum: "coach.method.aggregation.sum", + count: "coach.method.aggregation.count", + rate: "coach.method.aggregation.rate", +}; + +/** "weekly averages" and "weekly totals", by granularity. */ +export function methodAveragesKey(granularity: CoachResultGranularity): string { + return `coach.method.averages.${granularity}`; +} + +export function methodTotalsKey(granularity: CoachResultGranularity): string { + return `coach.method.totals.${granularity}`; +} + +export const COACH_METHOD_ABSENT_KEYS: Readonly< + Record, string> +> = { + no_data: "coach.method.absent.noData", + outside_window: "coach.method.absent.outsideWindow", + module_disabled: "coach.method.absent.moduleDisabled", +}; + +// ── Follow-ups ──────────────────────────────────────────────────────────── + +/** One label per chip kind; `{metric}` on a related-metric chip. */ +export const COACH_FOLLOW_UP_KEYS: Readonly> = + { + widen_window: "coach.followUp.widenWindow", + previous_period: "coach.followUp.previousPeriod", + year_ago: "coach.followUp.yearAgo", + as_chart: "coach.followUp.asChart", + as_table: "coach.followUp.asTable", + related_metric: "coach.followUp.relatedMetric", + continue: "coach.followUp.continue", + }; + +export const COACH_FOLLOW_UP_UI_KEYS = { + groupLabel: "coach.followUp.groupLabel", + settingLabel: "coach.followUp.settingLabel", + settingHint: "coach.followUp.settingHint", +} as const; + +// ── Clarification ───────────────────────────────────────────────────────── + +export const COACH_CLARIFY_UI_KEYS = { + cardLabel: "coach.clarify.cardLabel", + choicesLabel: "coach.clarify.choicesLabel", + freeTextHint: "coach.clarify.freeTextHint", + contextHint: "coach.clarify.contextHint", +} as const; + +/** A window choice on a clarification card ("Last 30 days"). */ +export function clarifyWindowLabelKey(window: CoachScopeWindow): string { + return `coach.clarify.window.${window}`; +} + +// ── Reuse ───────────────────────────────────────────────────────────────── + +/** The prose of a turn answered from a stored table without a model call. */ +export const COACH_REUSE_CAPTION_KEY = "coach.reuse.caption"; diff --git a/src/lib/ai/coach/follow-ups/continue.ts b/src/lib/ai/coach/follow-ups/continue.ts new file mode 100644 index 000000000..34d90ebef --- /dev/null +++ b/src/lib/ai/coach/follow-ups/continue.ts @@ -0,0 +1,15 @@ +/** + * "Keep looking": when the loop forced an answer at its round cap, offer a + * chip that continues the question with what was already fetched. + * + * Not built yet: no chip. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { CoachFollowUp } from "@/lib/ai/coach/types"; + +export function buildContinueFollowUp(_args: { + forcedFinal: boolean; + locale: Locale; +}): CoachFollowUp | null { + return null; +} diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts new file mode 100644 index 000000000..ae54daf34 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/derive.ts @@ -0,0 +1,32 @@ +/** + * The follow-up chips under the latest assistant reply, derived on the + * server from what the turn read: as a chart, as a table, the period before, + * a year ago, a wider window, a related metric. At most three, deduplicated, + * labels from the catalog only. + * + * Pure. Not built yet: no chips. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { + CoachFollowUp, + CoachFollowUpKind, + CoachResultMeta, + CoachStep, + CoachStepDomain, +} from "@/lib/ai/coach/types"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; + +export function deriveFollowUps(_args: { + results: CoachResultMeta[]; + steps: CoachStep[]; + /** What the record holds; null on the no-tools path. */ + inventory: InventoryEntry[] | null; + /** Kind and domain pairs the model proposed, already catalog-checked. */ + proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>; + forcedFinal: boolean; + prefs: CoachPrefs; + locale: Locale; +}): CoachFollowUp[] { + return []; +} diff --git a/src/lib/ai/coach/follow-ups/parse-sentinel.ts b/src/lib/ai/coach/follow-ups/parse-sentinel.ts new file mode 100644 index 000000000..5a1dd3df7 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/parse-sentinel.ts @@ -0,0 +1,15 @@ +/** + * The optional `---FOLLOWUPS---` block in the reply: the model may propose + * a chip's kind and domain from the catalog, nothing else. The block is + * stripped from the prose; a malformed block is dropped silently. + * + * Not built yet: the prose passes through unchanged, with no proposals. + */ +import type { CoachFollowUpKind, CoachStepDomain } from "@/lib/ai/coach/types"; + +export function parseFollowUpsSentinel(prose: string): { + prose: string; + proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>; +} { + return { prose, proposals: [] }; +} diff --git a/src/lib/ai/coach/follow-ups/resolve.ts b/src/lib/ai/coach/follow-ups/resolve.ts new file mode 100644 index 000000000..26787a854 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/resolve.ts @@ -0,0 +1,26 @@ +/** + * Resolve a tapped follow-up chip (`followUp: { messageId, id }` on the + * request) against what the server persisted: the chip must sit on the + * conversation's latest assistant message. A reuse chip is answered from the + * stored table without a model call; any other becomes a turn-context hint. + * A chip that is no longer current degrades to a plain message. + * + * Not built yet: every request is a plain message. + */ +import type { CoachFollowUp } from "@/lib/ai/coach/types"; + +/** A chip that resolved: what it asks for, and how it is answered. */ +export interface ResolvedFollowUp { + sourceMessageId: string; + followUp: CoachFollowUp; + /** A line for the turn context, on a chip answered by the model. */ + contextHint: string | null; +} + +export async function resolveFollowUp(_args: { + userId: string; + conversationId: string | undefined; + followUp: { messageId: string; id: string } | undefined; +}): Promise { + return null; +} diff --git a/src/lib/ai/coach/method.ts b/src/lib/ai/coach/method.ts new file mode 100644 index 000000000..fed6d0265 --- /dev/null +++ b/src/lib/ai/coach/method.ts @@ -0,0 +1,22 @@ +/** + * The method line under an answer: which sources, windows, counts and + * aggregation it rests on, rendered on the server in the request locale. + * "Blood pressure, last 90 days: 142 readings, weekly means · Sleep: no + * readings in this window". Never a health value. + * + * Not built yet: no method line. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { + CoachMethod, + CoachResultMeta, + CoachStep, +} from "@/lib/ai/coach/types"; + +export function buildMethod(_args: { + steps: CoachStep[]; + results: CoachResultMeta[]; + locale: Locale; +}): CoachMethod | null { + return null; +} diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 85f0fc1b1..2f6f764e8 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -10,8 +10,18 @@ * provenance (window names, metric tags, sample counts) and never raw * values, so it can be queried without decryption for analytics. */ +import type { z } from "zod/v4"; + import { prisma } from "@/lib/db"; import { decryptFromBytes, encryptToBytes } from "./bytes-codec"; +import { + coachClarificationSchema, + coachFollowUpSchema, + coachMethodSchema, + coachResultMetaSchema, + coachResultTableSchema, + coachStepSchema, +} from "./stream-events"; import { COACH_CONVERSATION_TITLE_MAX } from "./types"; import { isCheckupIntervalId, @@ -20,12 +30,20 @@ import { } from "./suggest-action"; import type { + CoachClarification, CoachConversationAttachmentDTO, CoachConversationDTO, CoachConversationDetailDTO, + CoachFollowUp, CoachMessageDTO, CoachMessageRole, + CoachMethod, CoachProvenance, + CoachResultEntry, + CoachResultMeta, + CoachResultTable, + CoachStep, + CoachStepDomain, CoachSuggestion, } from "./types"; @@ -56,6 +74,9 @@ export function summariseTitle(input: string): string { return `${cut.trimEnd()}…`; } +/** v1.39.4 — at most this many tables per message (`r1`..`r6`). */ +const MAX_RESULTS_PER_MESSAGE = 6; + function provenanceToJson(provenance: CoachProvenance | null): string | null { if (!provenance) return null; return JSON.stringify(provenance); @@ -162,6 +183,53 @@ function restoreToolCalls(raw: unknown): CoachProvenance["toolCalls"] { return cleaned.length > 0 ? cleaned : undefined; } +/** + * v1.39.4 — restore an array field of the provenance entry by entry against + * the wire schema. A malformed entry is dropped, not the whole field; an + * empty or absent array restores to undefined. The schemas strip keys they do + * not know, so a stored blob can never widen what a reader sees. + */ +function restoreEach( + raw: unknown, + schema: z.ZodType, + cap: number, +): T[] | undefined { + if (!Array.isArray(raw)) return undefined; + const cleaned: T[] = []; + for (const item of raw.slice(0, cap)) { + const parsed = schema.safeParse(item); + if (parsed.success) cleaned.push(parsed.data); + } + return cleaned.length > 0 ? cleaned : undefined; +} + +/** The live steps a turn persisted (at most 12 per turn). */ +function restoreSteps(raw: unknown): CoachStep[] | undefined { + return restoreEach(raw, coachStepSchema, 12); +} + +/** The method line; dropped whole when it does not parse. */ +function restoreMethod(raw: unknown): CoachMethod | undefined { + const parsed = coachMethodSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + +/** The tables' metadata (at most 6 per message). No values live here. */ +function restoreResultMetas(raw: unknown): CoachResultMeta[] | undefined { + return restoreEach(raw, coachResultMetaSchema, MAX_RESULTS_PER_MESSAGE); +} + +/** The chips offered under the reply (at most 3). */ +function restoreFollowUps(raw: unknown): CoachFollowUp[] | undefined { + return restoreEach(raw, coachFollowUpSchema, 3); +} + +/** The clarification choices; dropped whole when they do not parse. */ +function restoreClarification(raw: unknown): CoachClarification | undefined { + const parsed = coachClarificationSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + function provenanceFromJson(raw: string | null): CoachProvenance | null { if (!raw) return null; try { @@ -249,6 +317,14 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { const suggestion = restoreSuggestion(parsed.suggestion); const suggestedAction = restoreSuggestedAction(parsed.suggestedAction); const toolCalls = restoreToolCalls(parsed.toolCalls); + // v1.39.4 — the dialog fields: steps, method, table metadata, chips, the + // clarification choices, and the forced-answer marker. + const steps = restoreSteps(parsed.steps); + const method = restoreMethod(parsed.method); + const results = restoreResultMetas(parsed.results); + const followUps = restoreFollowUps(parsed.followUps); + const clarification = restoreClarification(parsed.clarification); + const forcedFinal = parsed.forcedFinal === true; return { windows, metrics, @@ -259,6 +335,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { ...(suggestedAction ? { suggestedAction } : {}), ...(toolCalls ? { toolCalls } : {}), ...(unverifiedFigures !== undefined ? { unverifiedFigures } : {}), + ...(steps ? { steps } : {}), + ...(method ? { method } : {}), + ...(results ? { results } : {}), + ...(followUps ? { followUps } : {}), + ...(clarification ? { clarification } : {}), + ...(forcedFinal ? { forcedFinal: true as const } : {}), }; } catch { return null; @@ -295,6 +377,39 @@ export interface AppendMessageParams { */ tokensUsed?: number | null; model?: string | null; + /** + * v1.39.4 — the tables of values this turn read. Encrypted into + * `resultsEncrypted`; their metadata rides `metricSource.results`. Omitted + * or empty on every turn without a table. + */ + results?: CoachResultTable[]; +} + +/** + * v1.39.4 — the at-rest ceiling for one message's tables, as JSON before + * encryption. Tables arrive trimmed to 400 rows; a message whose tables still + * exceed this keeps the leading tables that fit, and a reader is told the + * rest are unavailable rather than handed a partial table. + */ +export const RESULTS_MAX_BYTES = 128 * 1024; + +/** + * Serialise a turn's tables for the ciphertext column, or null when there are + * none. Drops whole tables from the end until the JSON fits the ceiling. + */ +function resultsToBytes( + results: CoachResultTable[] | undefined, +): Uint8Array | null { + if (!results || results.length === 0) return null; + const kept = results.slice(0, MAX_RESULTS_PER_MESSAGE); + while (kept.length > 0) { + const json = JSON.stringify(kept); + if (Buffer.byteLength(json, "utf8") <= RESULTS_MAX_BYTES) { + return encryptToBytes(json); + } + kept.pop(); + } + return null; } /** @@ -417,6 +532,7 @@ export async function appendMessage( promptVersion: params.promptVersion ?? null, tokensUsed: params.tokensUsed ?? null, model: params.model ?? null, + resultsEncrypted: resultsToBytes(params.results), }, }); await tx.coachConversation.update({ @@ -547,6 +663,9 @@ export async function fetchConversationWithMessages( // changing the oldest->newest contract the client renders. orderBy: { createdAt: "desc" }, take: CONVERSATION_MESSAGE_DETAIL_CAP, + // v1.39.4 — the tables are read lazily, one message at a time, through + // `readMessageResults`; the detail read never loads their ciphertext. + omit: { resultsEncrypted: true }, }, // v1.29.x (S7) — the LIVE attachment set (join → document label columns // only; the encrypted body is untouched), ordered by attach time. Always @@ -601,6 +720,72 @@ export async function fetchConversationWithMessages( }; } +/** + * v1.39.4 — the stored tables of one assistant message, for its owner. + * + * Narrowed like the conversation read: the message must sit in a + * conversation `userId` owns, and a foreign or missing id is null (the route + * answers 404 for both, so existence never leaks across accounts). + * + * One entry per table the message's provenance lists, in that order. A + * table is served whole or withheld with a reason, never partially: + * - `module_disabled` — `withhold(domain)` said the table's domain is + * switched off for the record now. Such a table is not even decrypted. + * - `unavailable` — the ciphertext did not decrypt, did not parse, or did + * not carry that ref (a table dropped at the size ceiling). Fail closed + * and say so, rather than serve nothing silently. + */ +export async function readMessageResults( + userId: string, + conversationId: string, + messageId: string, + withhold: (domain: CoachStepDomain) => boolean = () => false, +): Promise { + const row = await prisma.coachMessage.findFirst({ + where: { + id: messageId, + conversationId, + conversation: { userId }, + }, + select: { metricSourceJson: true, resultsEncrypted: true }, + }); + if (!row) return null; + + const metas = provenanceFromJson(row.metricSourceJson)?.results ?? []; + if (metas.length === 0) return []; + + const served = metas.filter((meta) => !withhold(meta.source.domain)); + const tables = + served.length > 0 ? decryptResultTables(row.resultsEncrypted) : null; + + return metas.map((meta): CoachResultEntry => { + if (withhold(meta.source.domain)) { + return { ref: meta.ref, withheld: "module_disabled" }; + } + const table = tables?.get(meta.ref); + return table ?? { ref: meta.ref, withheld: "unavailable" }; + }); +} + +/** Decrypt and parse a message's tables by ref; null when unreadable. */ +function decryptResultTables( + bytes: Uint8Array | null, +): Map | null { + if (!bytes || bytes.byteLength === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(decryptFromBytes(bytes)); + } catch { + return null; + } + const tables = restoreEach( + parsed, + coachResultTableSchema, + MAX_RESULTS_PER_MESSAGE, + ); + return new Map((tables ?? []).map((table) => [table.ref, table])); +} + export interface ListConversationsParams { userId: string; cursor?: string | null; diff --git a/src/lib/ai/coach/results/chart-spec.ts b/src/lib/ai/coach/results/chart-spec.ts new file mode 100644 index 000000000..aa3b6e67e --- /dev/null +++ b/src/lib/ai/coach/results/chart-spec.ts @@ -0,0 +1,15 @@ +/** + * The chart a result table gets, chosen on the server from the table's shape + * and the metric: a line for level metrics over time, a bar for totals and + * category counts, a histogram for a distribution, or none (table only) + * when the table is too short, too long or has no numeric column. + * + * Pure. Not built yet: every table stays table-only. + */ +import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types"; + +export function deriveChartSpec( + _table: Omit, +): CoachChartSpec | null { + return null; +} 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/results/project.ts b/src/lib/ai/coach/results/project.ts new file mode 100644 index 000000000..616b36223 --- /dev/null +++ b/src/lib/ai/coach/results/project.ts @@ -0,0 +1,32 @@ +/** + * Project this turn's tool results into result tables: per-sport counts for + * workouts, the latest reading per analyte for labs, per-night sleep, the + * compliance timeline, and the metric table tool's own rows. + * + * Input is only what the loop settled on THIS turn, so a table can never + * carry another turn's, or another account's, values. + * + * Not built yet: answers no tables. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { CoachResultTable } from "@/lib/ai/coach/types"; +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +/** One tool call of this turn, as the loop settled it. */ +export interface SettledToolCall { + name: string; + /** Schema-validated arguments; absent when they did not validate. */ + args?: Record; + result: CoachToolResult; +} + +/** + * Tables for this turn, refs `r1`.. in call order. `chart` and `chartKind` + * are left for `deriveChartSpec` to fill. + */ +export function projectResults(_args: { + calls: SettledToolCall[]; + locale: Locale; +}): CoachResultTable[] { + return []; +} diff --git a/src/lib/ai/coach/results/refs.ts b/src/lib/ai/coach/results/refs.ts new file mode 100644 index 000000000..096b3767d --- /dev/null +++ b/src/lib/ai/coach/results/refs.ts @@ -0,0 +1,14 @@ +/** + * `result:rN` references in the reply prose. The model marks the tables its + * answer relies on; the marks are stripped before the prose reaches the + * client, and a referenced table is shown expanded under the reply. + * + * Not built yet: the prose passes through unchanged and nothing counts as + * referenced. + */ +export function stripResultRefs(prose: string): { + prose: string; + referenced: string[]; +} { + return { prose, referenced: [] }; +} diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts new file mode 100644 index 000000000..c30ce16d9 --- /dev/null +++ b/src/lib/ai/coach/stream-events.ts @@ -0,0 +1,484 @@ +/** + * The Coach chat wire as Zod schemas. + * + * `types.ts` holds the TypeScript contract the server and the web client + * compile against; this file is its mirror for the published OpenAPI + * document (`docs/api/openapi.yaml`), which the native client reads. The two + * are held equal by `__tests__/stream-events-type-parity.test.ts`: a field + * added on one side and not the other fails the typecheck. + * + * Nothing here parses a live frame. The server builds frames from typed + * values and the client dispatches on `type`; these schemas describe the + * shapes, they do not police them. + */ +import { z } from "zod/v4"; + +import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types"; +import { + SUGGESTED_ACTION_TYPES, + type CheckupIntervalId, +} from "@/lib/ai/coach/suggest-action"; +import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions"; +import { + coachKeyValueSchema, + coachScopeSourceSchema, + coachScopeWindowSchema, +} from "@/lib/ai/coach/types"; + +const coachWindowEnum = coachScopeWindowSchema; +const coachToolNameEnum = z.enum(COACH_TOOL_NAMES); + +export const coachProvenanceMetricSchema = z.enum([ + ...coachScopeSourceSchema.options, + "general", +]); + +export const coachStepDomainSchema = z + .enum([ + ...coachScopeSourceSchema.options, + "labs", + "illness", + "cycle", + "correlations", + "snapshot", + ]) + .meta({ + id: "CoachStepDomain", + 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).", + }); + +const coachResultGranularitySchema = z.enum(["day", "week", "month"]); +const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]); + +// ── Steps ───────────────────────────────────────────────────────────────── + +export const coachStepSchema = z + .object({ + id: z.string().describe("`s1`..`s12`, unique within a turn."), + tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]), + labelKey: z + .string() + .describe("Closed catalog key (`coach.step.*`) the client localises."), + label: z + .string() + .describe( + "The label rendered on the server in the request locale. Render it when the client has no string for `labelKey`.", + ), + domain: coachStepDomainSchema.optional(), + window: coachWindowEnum.optional(), + period: coachResultPeriodSchema.optional(), + granularity: coachResultGranularitySchema.optional(), + status: z.enum(["running", "done", "empty", "failed"]), + count: z + .number() + .int() + .optional() + .describe("Readings or rows the server counted. Never a health value."), + reason: z + .enum([ + "no_data", + "outside_window", + "module_disabled", + "retrieval_failed", + "invalid_arguments", + ]) + .optional(), + resultRef: z + .string() + .optional() + .describe("The table this step produced (`r1`..), when it produced one."), + }) + .meta({ + id: "CoachStep", + 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.", + }); + +// ── Results ─────────────────────────────────────────────────────────────── + +const coachResultColumnSchema = z.object({ + key: z.string(), + kind: z.enum(["period", "category", "number", "count"]), + labelKey: z.string(), + label: z.string(), + unit: z + .string() + .optional() + .describe("Display unit token such as `mmHg`, never free text."), + decimals: z.number().int().optional(), +}); + +const coachChartSpecSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("line"), + x: z.string(), + series: z.array(z.string()), + }), + z.object({ + kind: z.literal("bar"), + x: z.string(), + series: z.array(z.string()), + orientation: z.enum(["vertical", "horizontal"]), + }), + z.object({ + kind: z.literal("histogram"), + column: z.string(), + unit: z.string().optional(), + bins: z.array( + z.object({ from: z.number(), to: z.number(), count: z.number().int() }), + ), + }), +]); + +const coachResultSourceSchema = z.object({ + tool: coachToolNameEnum, + domain: coachStepDomainSchema, + window: coachWindowEnum, + period: coachResultPeriodSchema, + granularity: coachResultGranularitySchema.optional(), +}); + +const coachResultMetaShape = { + ref: z.string().describe("`r1`..`r6`, unique within a message."), + source: coachResultSourceSchema, + shape: z.enum(["timeSeries", "categoryCounts", "distribution", "single"]), + titleKey: z.string(), + title: z.string(), + rowCount: z.number().int().describe("The full row count, before any trim."), + chartKind: z.enum(["line", "bar", "histogram"]).nullable(), + displayed: z + .boolean() + .describe( + "True when the answer referenced the table (shown expanded); false when it sits under the data-used disclosure.", + ), + reusedFrom: z + .object({ messageId: z.string(), ref: z.string() }) + .optional() + .describe("Set when the table was copied from an earlier message."), +}; + +export const coachResultMetaSchema = z.object(coachResultMetaShape).meta({ + id: "CoachResultMeta", + 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`.", +}); + +export const coachResultTableSchema = z + .object({ + ...coachResultMetaShape, + columns: z.array(coachResultColumnSchema), + rows: z + .array(z.array(z.union([z.string(), z.number(), z.null()]))) + .describe( + "Row-major cells, at most 400 rows. `null` is a period with no reading.", + ), + truncated: z.boolean().describe("True when `rowCount` exceeds the rows."), + chart: coachChartSpecSchema + .nullable() + .describe("The chart the server chose for the table, or null."), + }) + .meta({ + id: "CoachResultTable", + description: + "A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns the conversation.", + }); + +const coachResultWithheldSchema = z.object({ + ref: z.string(), + withheld: z + .enum(["module_disabled", "unavailable"]) + .describe( + "`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables could not be read.", + ), +}); + +export const coachResultEntrySchema = z + .union([coachResultTableSchema, coachResultWithheldSchema]) + .meta({ + id: "CoachResultEntry", + description: + "One stored table, or the reason it is not served. Absence is explicit: a withheld table names why.", + }); + +// ── Method ──────────────────────────────────────────────────────────────── + +export const coachMethodSchema = z + .object({ + entries: z.array( + z.object({ + domain: coachStepDomainSchema, + window: coachWindowEnum.optional(), + period: coachResultPeriodSchema.optional(), + granularity: coachResultGranularitySchema.optional(), + count: z.number().int().optional(), + aggregation: z + .enum(["mean", "median", "latest", "sum", "count", "rate"]) + .optional(), + absent: z + .enum(["no_data", "outside_window", "module_disabled"]) + .optional(), + }), + ), + text: z.string().describe("Rendered on the server in the request locale."), + }) + .meta({ + id: "CoachMethod", + description: + "How an answer was reached: which sources, windows, counts and aggregation. Never a health value.", + }); + +// ── Follow-ups ──────────────────────────────────────────────────────────── + +export const coachFollowUpSchema = z + .object({ + id: z.string().describe("`f1`..`f3`."), + kind: z.enum([ + "widen_window", + "previous_period", + "year_ago", + "as_chart", + "as_table", + "related_metric", + "continue", + ]), + labelKey: z.string(), + label: z + .string() + .describe("Rendered on the server from a catalog; never model text."), + anchor: z + .object({ + ref: z.string(), + domain: coachStepDomainSchema, + window: coachWindowEnum.optional(), + granularity: coachResultGranularitySchema.optional(), + period: coachResultPeriodSchema.optional(), + }) + .optional(), + reuse: z + .boolean() + .describe( + "True when the chip is answered from a stored table without a model call.", + ), + origin: z.enum(["server", "model"]), + }) + .meta({ + id: "CoachFollowUp", + description: + "A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the chip's label as `message`.", + }); + +// ── Clarification ───────────────────────────────────────────────────────── + +export const coachClarificationSchema = z + .object({ + kind: z.enum(["metric", "window", "context"]), + choices: z + .array( + z.object({ + id: z.string(), + labelKey: z.string(), + label: z.string(), + value: z.object({ + metric: coachScopeSourceSchema.optional(), + window: coachWindowEnum.optional(), + }), + }), + ) + .describe("At most 4. Metric choices are always ones the record holds."), + freeText: z.boolean().describe("Whether a typed answer is welcome."), + }) + .meta({ + id: "CoachClarification", + description: + "The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.", + }); + +// ── The cards and the usage envelope ────────────────────────────────────── + +const coachSuggestionSchema = z + .object({ + cadenceId: z.string(), + measurementType: z.string(), + label: z.string(), + }) + .meta({ + id: "CoachSuggestion", + description: + "A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`.", + }); + +const checkupIntervalSchema = z.string() as z.ZodType; + +const coachSuggestedActionSchema = z + .object({ + actionType: z.enum(SUGGESTED_ACTION_TYPES), + summary: z.string(), + titleKey: z.string(), + params: z.discriminatedUnion("actionType", [ + z.object({ + actionType: z.literal("checkup.create"), + label: z.string(), + interval: checkupIntervalSchema, + }), + z.object({ + actionType: z.literal("reminder.note"), + note: z.string(), + when: z.string().optional(), + metric: z.string().optional(), + }), + ]), + }) + .meta({ + 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`.", + }); + +const coachUsageSchema = z + .object({ + totalTokens: z.number().int().nullable(), + promptTokens: z.number().int().nullable().optional(), + completionTokens: z.number().int().nullable().optional(), + model: z.string().nullable().optional(), + }) + .meta({ + id: "CoachUsage", + description: + "Per-turn token usage. Server-authoritative: clients display it, never recompute it.", + }); + +// ── Provenance ──────────────────────────────────────────────────────────── + +export const coachProvenanceSchema = z + .object({ + windows: z + .array(coachWindowEnum) + .readonly() + .describe("Analysis windows the assistant drew on this turn."), + metrics: z + .array(coachProvenanceMetricSchema) + .readonly() + .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 + .partialRecord(coachProvenanceMetricSchema, z.number().int()) + .optional() + .describe( + "Per-metric sample-count summary; absent on an empty snapshot.", + ), + keyValues: z + .array(coachKeyValueSchema) + .readonly() + .optional() + .describe( + "Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.", + ), + suggestion: coachSuggestionSchema + .optional() + .describe("The cadence-suggestion card this turn carried, if any."), + suggestedAction: coachSuggestedActionSchema + .optional() + .describe("The confirm-to-apply action card this turn carried, if any."), + toolCalls: z + .array(z.object({ name: z.string(), present: z.boolean() })) + .readonly() + .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.", + ), + groundedFigures: z + .array(z.number()) + .readonly() + .optional() + .describe( + "Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed, label-less.", + ), + 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.", + ), + steps: z + .array(coachStepSchema) + .optional() + .describe("v1.39.4 — what the Coach read on this turn."), + method: coachMethodSchema + .optional() + .describe("v1.39.4 — how the answer was reached."), + results: z + .array(coachResultMetaSchema) + .optional() + .describe( + "v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint.", + ), + followUps: z + .array(coachFollowUpSchema) + .optional() + .describe("v1.39.4 — the chips offered under this reply."), + clarification: coachClarificationSchema + .optional() + .describe("v1.39.4 — the choices, when this reply is a question."), + forcedFinal: z + .literal(true) + .optional() + .describe( + "v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.", + ), + }) + .meta({ + id: "CoachProvenance", + 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.", + }); + +// ── Frames ──────────────────────────────────────────────────────────────── + +export const coachStreamEventSchema = z + .discriminatedUnion("type", [ + z.object({ type: z.literal("token"), token: z.string() }), + z.object({ + type: z.literal("provenance"), + metricSource: coachProvenanceSchema, + }), + z.object({ + type: z.literal("suggestion"), + suggestion: coachSuggestionSchema, + }), + z.object({ + type: z.literal("suggestedAction"), + suggestedAction: coachSuggestedActionSchema, + }), + z.object({ type: z.literal("reasoning"), text: z.string() }), + z.object({ + type: z.literal("done"), + conversationId: z.string(), + messageId: z.string(), + usage: coachUsageSchema.optional(), + }), + z.object({ + type: z.literal("error"), + code: z.string(), + message: z.string(), + reason: z.enum(AI_UNAVAILABLE_REASONS).optional(), + }), + z.object({ type: z.literal("step"), step: coachStepSchema }), + z.object({ type: z.literal("result"), result: coachResultTableSchema }), + z.object({ + type: z.literal("followUps"), + followUps: z.array(coachFollowUpSchema), + }), + z.object({ + type: z.literal("clarification"), + clarification: coachClarificationSchema, + }), + ]) + .meta({ + id: "CoachStreamEvent", + 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.", + }); diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index ee8e73c22..8207bac1f 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -98,6 +98,44 @@ export const getCycleArgsSchema = z.object({}).strict(); export const getCorrelationsArgsSchema = z.object({}).strict(); +/** + * v1.39.4 — each tool's argument schema, keyed by name. The record type makes + * a new tool name fail to compile until its schema is listed here. + */ +const COACH_TOOL_ARG_SCHEMAS: Record = { + get_metric_series: getMetricSeriesArgsSchema, + get_glucose_panel: getGlucosePanelArgsSchema, + get_sleep: getSleepArgsSchema, + get_medication_compliance: getMedicationComplianceArgsSchema, + get_labs: getLabsArgsSchema, + get_illness_recovery: getIllnessRecoveryArgsSchema, + get_workouts: getWorkoutsArgsSchema, + get_cycle: getCycleArgsSchema, + get_correlations: getCorrelationsArgsSchema, +}; + +/** + * v1.39.4 — a call's arguments as its schema validates them, or undefined + * when the name is unknown, the JSON does not parse, or the schema refuses + * it. Only this validated form ever rides the tool trace; the model's raw + * argument string never does. + */ +export function parseCoachToolArgs( + name: string, + rawArguments: string, +): Record | undefined { + if (!isCoachToolName(name)) return undefined; + let raw: unknown; + try { + raw = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments); + } catch { + return undefined; + } + const parsed = COACH_TOOL_ARG_SCHEMAS[name].safeParse(raw); + if (!parsed.success) return undefined; + return parsed.data as Record; +} + /** * JSON-Schema parameter shapes handed to the provider. Kept hand-written * (rather than generated from Zod) so the wire description the model reads is diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 099c8e1cb..dbeb060f8 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -99,6 +99,12 @@ export interface CoachToolResult { export interface CoachToolTrace { name: string; present: boolean; + /** + * v1.39.4 — the call's arguments as their schema validated them; absent + * when they did not validate. Turn-internal (steps, method, chips): never + * persisted, the provenance keeps `{ name, present }` only. + */ + args?: Record; } function pickSection( diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts index 6c3972b28..acee46e47 100644 --- a/src/lib/ai/coach/tools/index.ts +++ b/src/lib/ai/coach/tools/index.ts @@ -6,7 +6,7 @@ * figures, and a bounded loop that executes them. The legacy snapshot path * stays alive as the no-tools fallback (per-provider `supportsTools`). */ -export { COACH_TOOL_DEFS } from "./definitions"; +export { COACH_TOOL_DEFS, parseCoachToolArgs } from "./definitions"; export type { CoachToolTrace } from "./executor"; export { buildCoachDataInventory, diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index cd671c0b6..f657a25b4 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -26,9 +26,15 @@ import { annotate } from "@/lib/logging/context"; import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; -import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types"; +import type { + AiMessage, + AiToolCall, + AiToolDef, + CompletionResult, +} from "@/lib/ai/types"; import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger"; import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types"; +import { parseCoachToolArgs, type CoachToolName } from "./definitions"; import { executeCoachTool, type CoachToolResult, @@ -81,6 +87,18 @@ export interface CoachToolLoopResult { * one honest sentence the fix exists to make sayable. */ toolResults: CoachToolResult[]; + /** + * v1.39.4 — true when the loop reached `HARD_CAP`: the model asked for + * tools in every round it was offered them, so the final answer was forced + * with `toolChoice: "none"` rather than given freely. + */ + forcedFinal: boolean; +} + +/** v1.39.4 — a tool call to run before the first model round. */ +interface CoachSeedCall { + name: CoachToolName; + args: Record; } export async function runCoachToolLoop(args: { @@ -108,6 +126,26 @@ export async function runCoachToolLoop(args: { * shared default holds. */ timeoutMs?: number; + /** + * v1.39.4 — fired as each tool call starts. `index` counts calls across + * the whole turn from 0, so it is stable for a step id. A throwing callback + * is swallowed: progress reporting never breaks the loop. + */ + onCallStart?: (call: AiToolCall, index: number) => void; + /** + * v1.39.4 — fired once for every call that started, when its result is in: + * found, missed, invalid or failed alike (`executeCoachTool` never throws). + */ + onCallSettled?: ( + call: AiToolCall, + result: CoachToolResult, + index: number, + ) => void; + /** + * v1.39.4 — calls to run before round one, reusing the loop's own + * assistant-call and tool-result serialisation. Accepted, not read yet. + */ + seedCalls?: ReadonlyArray; }): Promise { const { userId, @@ -121,6 +159,8 @@ export async function runCoachToolLoop(args: { ledger, signal, timeoutMs, + onCallStart, + onCallSettled, } = args; const messages: AiMessage[] = [...args.messages]; @@ -130,6 +170,7 @@ export async function runCoachToolLoop(args: { let workingProviderType = ""; const toolTrace: CoachToolTrace[] = []; const toolResults: CoachToolResult[] = []; + let callCount = 0; // Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool // calls so the model must answer. @@ -184,6 +225,7 @@ export async function runCoachToolLoop(args: { rounds, toolTrace, toolResults, + forcedFinal: isForcedFinal, }; } @@ -197,6 +239,9 @@ export async function runCoachToolLoop(args: { const results = await Promise.all( calls.map(async (call) => { + const index = callCount; + callCount += 1; + notify(() => onCallStart?.(call, index)); const toolResult = await executeCoachTool({ userId, name: call.name, @@ -204,7 +249,13 @@ export async function runCoachToolLoop(args: { fallbackWindow, sharedScope, }); - toolTrace.push({ name: call.name, present: toolResult.present }); + notify(() => onCallSettled?.(call, toolResult, index)); + const validArgs = parseCoachToolArgs(call.name, call.arguments); + toolTrace.push({ + name: call.name, + present: toolResult.present, + ...(validArgs ? { args: validArgs } : {}), + }); // v1.21.0 (P6) — retain the payloads that carried figures for the // post-hoc prose number-verifier (the union of numeric leaves grounds // the figures the model may cite): a present result's `data`, or the @@ -230,3 +281,12 @@ export async function runCoachToolLoop(args: { // defensive guard. throw new Error("coach tool loop exceeded hard cap without a final answer"); } + +/** Run a progress callback; a throw from it never reaches the loop. */ +function notify(callback: () => void): void { + try { + callback(); + } catch { + // Progress is best-effort; the answer is not. + } +} diff --git a/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts new file mode 100644 index 000000000..bcd5f85ed --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts @@ -0,0 +1,187 @@ +/** + * v1.39.4 — the dialog frames go out in the documented order, after the + * provenance, and a blocked turn carries none of the tables, chips or + * clarification. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), +})); + +import type { + CoachClarification, + CoachFollowUp, + CoachResultTable, + CoachStreamEvent, +} from "@/lib/ai/coach/types"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; + +import { emitReply } from "../sse"; +import { assembleTurnDialog, buildTurnProvenance } from "../provenance"; +import type { ModelOutcome } from "../model"; +import type { GuardedReply } from "../reply-guards"; + +const TABLE: CoachResultTable = { + ref: "r1", + source: { + tool: "get_metric_series", + domain: "bp", + window: "last30days", + period: "current", + }, + shape: "timeSeries", + titleKey: "coach.result.title.metricByPeriod", + title: "Blood pressure by day", + rowCount: 1, + chartKind: null, + displayed: false, + columns: [], + rows: [["2026-09-01", 128]], + truncated: false, + chart: null, +}; +const CHIP: CoachFollowUp = { + id: "f1", + kind: "as_table", + labelKey: "coach.followUp.asTable", + label: "Show as a table", + reuse: true, + origin: "server", +}; +const CLARIFY: CoachClarification = { + kind: "window", + choices: [], + freeText: true, +}; + +function collect() { + const frames: CoachStreamEvent[] = []; + return { + frames, + emitter: { + emit: (f: CoachStreamEvent) => frames.push(f), + aborted: () => false, + }, + }; +} + +describe("emitReply — dialog frames", () => { + it("orders token, provenance, result, cards, clarification, followUps, done", async () => { + const { frames, emitter } = collect(); + await emitReply( + emitter, + { + ok: true, + replyText: "Steady.", + provenance: { windows: [], metrics: [] }, + suggestion: { cadenceId: "c", measurementType: "BP", label: "l" }, + action: null, + results: [TABLE], + followUps: [CHIP], + clarification: CLARIFY, + messageId: "m1", + totalTokens: 10, + model: "x", + }, + "c1", + ); + expect(frames.map((f) => f.type)).toEqual([ + "token", + "provenance", + "result", + "suggestion", + "clarification", + "followUps", + "done", + ]); + }); + + it("emits no dialog frame when the turn has none", async () => { + const { frames, emitter } = collect(); + await emitReply( + emitter, + { + ok: true, + replyText: "Steady.", + provenance: { windows: [], metrics: [] }, + suggestion: null, + action: null, + results: [], + followUps: [], + clarification: null, + messageId: "m1", + totalTokens: 0, + model: null, + }, + "c1", + ); + expect(frames.map((f) => f.type)).toEqual(["token", "provenance", "done"]); + }); +}); + +describe("assembleTurnDialog", () => { + const model = { + ok: true, + steps: [], + results: [TABLE], + forcedFinal: false, + inventory: null, + } as unknown as Extract; + const reply = (blocked: boolean): GuardedReply => + ({ + outboundBlocked: blocked, + referencedResults: ["r1"], + followUpProposals: [], + clarification: blocked ? null : CLARIFY, + keyValuesSentinel: { + keyValues: [], + malformed: false, + malformedEntries: [], + }, + groundedFigures: [], + unverifiedStripped: 0, + }) as unknown as GuardedReply; + + it("marks a referenced table displayed and keeps its values out of the provenance", () => { + const dialog = assembleTurnDialog({ + model, + reply: reply(false), + prefs: DEFAULT_COACH_PREFS, + locale: "en", + }); + expect(dialog.results).toEqual([{ ...TABLE, displayed: true }]); + const provenance = buildTurnProvenance({ + snapshotProvenance: { windows: [], metrics: [] }, + reply: reply(false), + suggestion: null, + action: null, + toolTrace: [], + steps: [], + dialog, + forcedFinal: true, + }); + expect(provenance.results?.[0]).not.toHaveProperty("rows"); + expect(provenance.results?.[0]?.displayed).toBe(true); + expect(provenance.clarification).toEqual(CLARIFY); + expect(provenance.forcedFinal).toBe(true); + expect(JSON.stringify(provenance)).not.toContain("128"); + }); + + it("drops tables, chips and clarification on a blocked turn", () => { + const dialog = assembleTurnDialog({ + model, + reply: reply(true), + prefs: DEFAULT_COACH_PREFS, + locale: "en", + }); + expect(dialog).toEqual({ + results: [], + method: null, + followUps: [], + clarification: null, + }); + }); +}); diff --git a/src/lib/ai/coach/turn/addenda/clarify.ts b/src/lib/ai/coach/turn/addenda/clarify.ts new file mode 100644 index 000000000..d5d37b574 --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/clarify.ts @@ -0,0 +1,12 @@ +/** + * Prompt rules for clarifying questions: ask only when the metric or window + * stays genuinely ambiguous, at most one question, never about doses or + * diagnoses. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function clarifyAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/addenda/followups.ts b/src/lib/ai/coach/turn/addenda/followups.ts new file mode 100644 index 000000000..11167ff3b --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/followups.ts @@ -0,0 +1,10 @@ +/** + * Prompt rules for proposing follow-up chips from the catalog. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function followUpsAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/addenda/index.ts b/src/lib/ai/coach/turn/addenda/index.ts new file mode 100644 index 000000000..bb1983e54 --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/index.ts @@ -0,0 +1,23 @@ +/** + * The dialog addenda appended to the tool-mode system prompt: result + * tables, clarification, the method line, follow-up chips. Each lives in its + * own file; an empty one adds nothing, so a turn with every addendum empty + * sends the prompt byte for byte as before. + */ +import type { Locale } from "@/lib/i18n/config"; + +import { clarifyAddendum } from "./clarify"; +import { followUpsAddendum } from "./followups"; +import { methodAddendum } from "./method"; +import { resultsAddendum } from "./results"; + +export function buildDialogAddenda(locale: Locale): string { + return [ + resultsAddendum(locale), + clarifyAddendum(locale), + methodAddendum(locale), + followUpsAddendum(locale), + ] + .filter((block) => block.length > 0) + .join("\n\n"); +} diff --git a/src/lib/ai/coach/turn/addenda/method.ts b/src/lib/ai/coach/turn/addenda/method.ts new file mode 100644 index 000000000..49970dc9d --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/method.ts @@ -0,0 +1,11 @@ +/** + * Prompt rules for the method line and for rechecking a figure the person + * questions. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function methodAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/addenda/results.ts b/src/lib/ai/coach/turn/addenda/results.ts new file mode 100644 index 000000000..5a64721af --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/results.ts @@ -0,0 +1,11 @@ +/** + * Prompt rules for result tables: when to fetch a table, how to reference + * one, and when a stored table answers without a new fetch. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function resultsAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index a6d6ed35d..4c3296383 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -23,14 +23,24 @@ import { renderDataInventory, renderFocusHint, buildToolModeAddendum, + parseCoachToolArgs, runCoachToolLoop, type CoachToolTrace, } from "@/lib/ai/coach/tools"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; +import type { CoachResultTable, CoachStep } from "@/lib/ai/coach/types"; +import { + projectResults, + type SettledToolCall, +} from "@/lib/ai/coach/results/project"; +import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec"; +import { buildDialogAddenda } from "./addenda"; import { refundReservation, type TurnReservation } from "./budget"; import type { TurnChain } from "./chain"; import type { TurnContext } from "./context"; import { classifyBubblingProviderError } from "./errors"; +import { snapshotStep, toStep } from "./steps"; import type { TurnEmitter } from "./types"; export type ModelOutcome = @@ -77,9 +87,56 @@ export type ModelOutcome = * to the daily meter). */ cachedTokens: number; + /** + * v1.39.4 — the steps this turn read, in first-seen order: the tool + * calls on the tool path, one snapshot step on the no-tools path. + * Already streamed as `step` frames; persisted on the provenance. + */ + steps: CoachStep[]; + /** + * v1.39.4 — the tables this turn's tool results project to, with their + * charts. Not streamed here: they go out after the reply guards, and + * not at all on a blocked turn. + */ + results: CoachResultTable[]; + /** v1.39.4 — the loop forced the answer at its round cap. */ + forcedFinal: boolean; + /** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */ + inventory: InventoryEntry[] | null; } | { ok: false; code: string }; +/** + * v1.39.4 — the turn's steps, upserted by id and streamed as they change. + * A frame is never written to a stream the client already closed. + */ +function stepRecorder(emitter: TurnEmitter): { + record(step: CoachStep | null): void; + list(): CoachStep[]; +} { + const byId = new Map(); + return { + record(step) { + if (!step) return; + byId.set(step.id, step); + if (!emitter.aborted()) emitter.emit({ type: "step", step }); + }, + list: () => [...byId.values()], + }; +} + +/** v1.39.4 — a projected table with the chart the server chose for it. */ +function withChart(table: CoachResultTable): CoachResultTable { + const chart = deriveChartSpec(table); + return { ...table, chart, chartKind: chart?.kind ?? null }; +} + +/** v1.39.4 — server-authored lines appended to the system prompt. */ +function appendBlocks(base: string, blocks: string[]): string { + const extra = blocks.filter((block) => block.length > 0); + return extra.length > 0 ? [base, ...extra].join("\n\n") : base; +} + export async function runTurnModel(args: { userId: string; locale: Locale; @@ -89,14 +146,18 @@ export async function runTurnModel(args: { chain: TurnChain; toolMode: boolean; reservation: TurnReservation; - /** - * The turn's frame channel. Not written to yet: the model step is where - * live progress frames will originate. - */ + /** The turn's frame channel; the live `step` frames go out on it. */ emitter: TurnEmitter; + /** + * v1.39.4 — server-authored context lines for this turn (a resolved + * follow-up chip, an answered clarification). Empty on a plain turn, + * which then sends its prompt unchanged. + */ + turnHints: string[]; }): Promise { const { userId, locale, signal, conversationId, ctx, chain, toolMode } = args; const { effectiveScope, workoutEvidence, turnContext, snapshot } = ctx; + const steps = stepRecorder(args.emitter); try { if (toolMode) { // v1.20.0 (F1) — base context: the full system prompt + a tool-mode @@ -108,7 +169,10 @@ export async function runTurnModel(args: { const inventory = await buildCoachDataInventory(userId, effectiveScope); const toolRequest = buildCoachToolRequest({ systemPrompt: ctx.systemPrompt, - toolModeAddendum: buildToolModeAddendum(locale), + toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [ + buildDialogAddenda(locale), + ...args.turnHints, + ]), focusHint: renderFocusHint(effectiveScope?.sources), workoutEvidence, dataInventory: renderDataInventory(inventory), @@ -116,6 +180,9 @@ export async function runTurnModel(args: { transcript: turnContext.transcript, languageName: LANGUAGE_NAMES[locale], }); + // v1.39.4 — every call this turn settled, by its turn-wide index, for + // the result tables. Only this turn's own calls ever reach them. + const settled: SettledToolCall[] = []; const loop = await runCoachToolLoop({ userId, providers: chain, @@ -135,6 +202,26 @@ export async function runTurnModel(args: { signal, // v1.22 (#89) — per-user response timeout for each tool-round call. timeoutMs: ctx.aiResponseTimeoutMs, + // v1.39.4 — live steps: a `running` step as each call starts, its + // final status as it settles. + onCallStart: (call, index) => + steps.record( + toStep({ + call, + index, + parsedArgs: parseCoachToolArgs(call.name, call.arguments), + locale, + }), + ), + onCallSettled: (call, result, index) => { + const parsedArgs = parseCoachToolArgs(call.name, call.arguments); + settled[index] = { + name: call.name, + ...(parsedArgs ? { args: parsedArgs } : {}), + result, + }; + steps.record(toStep({ call, index, parsedArgs, result, locale })); + }, }); // v1.32.1 — the numeric verifier ACTIVATES only when this turn actually // delivered figures the model was told to ground against: a pinned @@ -171,6 +258,13 @@ export async function runTurnModel(args: { inventoryPayloads: [inventory.entries], totalTokens: loop.totalTokens, cachedTokens: loop.cachedTokens, + steps: steps.list(), + results: projectResults({ + calls: settled.filter((call) => call !== undefined), + locale, + }).map(withChart), + forcedFinal: loop.forcedFinal === true, + inventory: inventory.entries, }; } // v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain @@ -180,6 +274,13 @@ export async function runTurnModel(args: { // heartbeat keeps the proxy connection warm and the assembled reply is // returned in full so every guard still runs on the complete text. let streamedDeltas = 0; + // v1.39.4 — the no-tools path reads the whole snapshot: one step. + steps.record( + snapshotStep({ + metricCount: snapshot.provenance.metrics.length, + locale, + }), + ); const fallback = await runStreamingRawCompletionWithFallback({ surface: "coach", userId, @@ -192,7 +293,7 @@ export async function runTurnModel(args: { // on every stateless no-tools request), so it ships as a single user // message. The stable persona rides `system` and is cache-eligible. params: singleUserTurn({ - system: ctx.systemPrompt, + system: appendBlocks(ctx.systemPrompt, args.turnHints), user: ctx.userPrompt, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, @@ -228,6 +329,10 @@ export async function runTurnModel(args: { inventoryPayloads: [], totalTokens: result.tokensUsed ?? 0, cachedTokens: result.cachedInputTokens ?? 0, + steps: steps.list(), + results: [], + forcedFinal: false, + inventory: null, }; } catch (err) { // The provider chain failed outright — no tokens were billed, so refund diff --git a/src/lib/ai/coach/turn/persist.ts b/src/lib/ai/coach/turn/persist.ts index 516fcde44..3db6d66d9 100644 --- a/src/lib/ai/coach/turn/persist.ts +++ b/src/lib/ai/coach/turn/persist.ts @@ -4,7 +4,7 @@ import { annotate } from "@/lib/logging/context"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; -import type { CoachProvenance } from "@/lib/ai/coach/types"; +import type { CoachProvenance, CoachResultTable } from "@/lib/ai/coach/types"; import { appendMessage } from "@/lib/ai/coach/persistence"; import type { TurnContext } from "./context"; @@ -17,6 +17,8 @@ export async function persistAssistantReply(args: { model: Extract; ctx: TurnContext; toolMode: boolean; + /** v1.39.4 — the tables to seal beside the message. */ + results: CoachResultTable[]; }): Promise<{ messageId: string }> { const { conversationId, replyText, provenance, model, ctx, toolMode } = args; const workingProviderType: ProviderChainType = model.workingProviderType; @@ -38,6 +40,8 @@ export async function persistAssistantReply(args: { // reflects the true turn cost on the tool path too. tokensUsed: totalTokensSpent || null, model: model.result.model ?? null, + // v1.39.4 — the tables, encrypted into their own column. + ...(args.results.length > 0 ? { results: args.results } : {}), }); // v1.18.7 — the day's spend was already reconciled against the diff --git a/src/lib/ai/coach/turn/pipeline.ts b/src/lib/ai/coach/turn/pipeline.ts index 7b109ac53..5a3b82f9c 100644 --- a/src/lib/ai/coach/turn/pipeline.ts +++ b/src/lib/ai/coach/turn/pipeline.ts @@ -19,10 +19,14 @@ import { resolveTurnChain } from "./chain"; import { assembleTurnContext } from "./context"; import { persistUserTurn, resolveTurnConversation } from "./conversation"; import { handleProducerFailure } from "./errors"; +import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify"; +import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve"; + import { runTurnModel } from "./model"; import { persistAssistantReply } from "./persist"; -import { buildTurnProvenance } from "./provenance"; +import { assembleTurnDialog, buildTurnProvenance } from "./provenance"; import { guardReply } from "./reply-guards"; +import { runReuseTurn } from "./reuse-turn"; import { SSE_HEADERS, createTurnEmitter, @@ -45,6 +49,32 @@ export async function runCoachTurn(input: TurnInput): Promise { const conversation = resolved.conversation; const workingConversationId = conversation.conversationId; + // v1.39.4 — a tapped chip or an answered question. Resolved against what + // the server persisted, never against what the client says the chip was. + // A reuse chip is answered from a stored table without a model call. + const resolvedFollowUp = await resolveFollowUp({ + userId, + conversationId: input.conversationId, + followUp: input.followUp, + }); + if (resolvedFollowUp?.followUp.reuse) { + const reused = await runReuseTurn({ + input, + conversation, + resolved: resolvedFollowUp, + }); + if (reused) return reused; + } + const clarifiedLine = await resolveClarificationAnswer({ + userId, + conversationId: input.conversationId, + clarification: input.clarification, + }); + const turnHints = [ + ...(resolvedFollowUp?.contextHint ? [resolvedFollowUp.contextHint] : []), + ...(clarifiedLine ? [clarifiedLine] : []), + ]; + await persistUserTurn(workingConversationId, message); const ctx = await assembleTurnContext({ @@ -95,6 +125,7 @@ export async function runCoachTurn(input: TurnInput): Promise { toolMode, reservation, emitter, + turnHints, }); if (!model.ok) return model; @@ -120,12 +151,21 @@ export async function runCoachTurn(input: TurnInput): Promise { coachPrefs: ctx.coachPrefs, reply, }); + const dialog = assembleTurnDialog({ + model, + reply, + prefs: ctx.coachPrefs, + locale, + }); const provenance = buildTurnProvenance({ snapshotProvenance: ctx.snapshot.provenance, reply, suggestion: cards.suggestion, action: cards.action, toolTrace: model.toolTrace, + steps: model.steps, + dialog, + forcedFinal: model.forcedFinal, }); const { messageId } = await persistAssistantReply({ conversationId: workingConversationId, @@ -134,6 +174,7 @@ export async function runCoachTurn(input: TurnInput): Promise { model, ctx, toolMode, + results: dialog.results, }); return { @@ -142,6 +183,9 @@ export async function runCoachTurn(input: TurnInput): Promise { provenance, suggestion: cards.suggestion, action: cards.action, + results: dialog.results, + followUps: dialog.followUps, + clarification: dialog.clarification, messageId, totalTokens: model.totalTokens, model: model.result.model ?? null, diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts index 6000fb403..437d73982 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -1,24 +1,119 @@ /** * The provenance envelope a Coach reply is persisted and streamed with: * what the snapshot covered, plus what this turn added (key values, cards, - * the tool trace, the grounded figures, the withheld-figure count). + * the tool trace, the grounded figures, the withheld-figure count, and the + * dialog: steps, tables, method, chips, clarification). */ import { annotate } from "@/lib/logging/context"; +import type { Locale } from "@/lib/i18n/config"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; -import type { CoachProvenance, CoachSuggestion } from "@/lib/ai/coach/types"; +import type { + CoachClarification, + CoachFollowUp, + CoachMethod, + CoachProvenance, + CoachResultMeta, + CoachResultTable, + CoachStep, + CoachSuggestion, +} from "@/lib/ai/coach/types"; import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; import type { CoachToolTrace } from "@/lib/ai/coach/tools"; +import { buildMethod } from "@/lib/ai/coach/method"; +import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive"; +import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; +import type { ModelOutcome } from "./model"; import type { GuardedReply } from "./reply-guards"; +/** v1.39.4 — at most this many chips under a reply. */ +const MAX_FOLLOW_UPS = 3; + +/** What the dialog adds to a guarded reply. */ +export interface TurnDialog { + /** The tables to stream and persist; empty on a blocked turn. */ + results: CoachResultTable[]; + method: CoachMethod | null; + /** Empty on a blocked turn. */ + followUps: CoachFollowUp[]; + /** Null on a blocked turn. */ + clarification: CoachClarification | null; +} + +/** A table's metadata: everything but the values, for the plaintext blob. */ +function toResultMeta(table: CoachResultTable): CoachResultMeta { + return { + ref: table.ref, + source: table.source, + shape: table.shape, + titleKey: table.titleKey, + title: table.title, + rowCount: table.rowCount, + chartKind: table.chartKind, + displayed: table.displayed, + ...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}), + }; +} + +/** + * v1.39.4 — the dialog of a guarded reply. On an outbound block the tables, + * chips and clarification are dropped, the way key values already are: the + * reply they belonged to was replaced. + */ +export function assembleTurnDialog(args: { + model: Extract; + reply: GuardedReply; + prefs: CoachPrefs; + locale: Locale; +}): TurnDialog { + const { model, reply, prefs, locale } = args; + const blocked = reply.outboundBlocked; + const referenced = new Set(reply.referencedResults); + const results = blocked + ? [] + : model.results.map((table) => + referenced.has(table.ref) ? { ...table, displayed: true } : table, + ); + const metas = results.map(toResultMeta); + const method = buildMethod({ steps: model.steps, results: metas, locale }); + const continueChip = blocked + ? null + : buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale }); + const followUps = blocked + ? [] + : [ + ...(continueChip ? [continueChip] : []), + ...deriveFollowUps({ + results: metas, + steps: model.steps, + inventory: model.inventory, + proposals: reply.followUpProposals, + forcedFinal: model.forcedFinal, + prefs, + locale, + }), + ].slice(0, MAX_FOLLOW_UPS); + return { + results, + method, + followUps, + clarification: reply.clarification, + }; +} + export function buildTurnProvenance(args: { snapshotProvenance: CoachProvenance; reply: GuardedReply; suggestion: CoachSuggestion | null; action: CoachSuggestedAction | null; toolTrace: CoachToolTrace[]; + /** v1.39.4 — the steps the turn read; kept on a blocked turn too. */ + steps: CoachStep[]; + dialog: TurnDialog; + forcedFinal: boolean; }): CoachProvenance { - const { snapshotProvenance, reply, toolTrace } = args; + const { snapshotProvenance, reply, toolTrace, steps, dialog } = args; const surfacedSuggestion = args.suggestion; const surfacedAction = args.action; const sentinel = reply.keyValuesSentinel; @@ -58,6 +153,16 @@ export function buildTurnProvenance(args: { ...(unverifiedStripped > 0 ? { unverifiedFigures: unverifiedStripped } : {}), + // v1.39.4 — the dialog. Metadata only: a table's values are in the + // encrypted column, never in this plaintext envelope. + ...(steps.length > 0 ? { steps } : {}), + ...(dialog.method ? { method: dialog.method } : {}), + ...(dialog.results.length > 0 + ? { results: dialog.results.map(toResultMeta) } + : {}), + ...(dialog.followUps.length > 0 ? { followUps: dialog.followUps } : {}), + ...(dialog.clarification ? { clarification: dialog.clarification } : {}), + ...(args.forcedFinal ? { forcedFinal: true as const } : {}), }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose diff --git a/src/lib/ai/coach/turn/reply-guards.ts b/src/lib/ai/coach/turn/reply-guards.ts index 00be74862..ae7d9d9fd 100644 --- a/src/lib/ai/coach/turn/reply-guards.ts +++ b/src/lib/ai/coach/turn/reply-guards.ts @@ -29,6 +29,10 @@ import { captureReminderFromSentinel, } from "@/lib/ai/coach/reminders"; import { parseSuggestAction } from "@/lib/ai/coach/suggest-action"; +import { parseClarifySentinel } from "@/lib/ai/coach/clarify"; +import { parseFollowUpsSentinel } from "@/lib/ai/coach/follow-ups/parse-sentinel"; +import { stripResultRefs } from "@/lib/ai/coach/results/refs"; +import type { CoachClarification } from "@/lib/ai/coach/types"; import type { TurnContext } from "./context"; import type { ModelOutcome } from "./model"; @@ -43,6 +47,15 @@ export interface GuardedReply { outboundBlocked: boolean; groundedFigures: number[]; unverifiedStripped: number; + /** v1.39.4 — the refs (`r1`..) the prose marked as the tables it uses. */ + referencedResults: string[]; + /** v1.39.4 — the chip kinds and domains the model proposed. */ + followUpProposals: ReturnType["proposals"]; + /** + * v1.39.4 — the validated clarification, when this reply asks one. Null + * when it does not, and on a blocked turn (the question was replaced). + */ + clarification: CoachClarification | null; } export async function guardReply(args: { @@ -131,6 +144,21 @@ export async function guardReply(args: { const actionParse = parseSuggestAction(replyText); replyText = actionParse.prose.trim() || replyText; + // v1.39.4 — the dialog sentinels and marks, stripped before the safety + // screen and the number check read the prose: the optional + // `---FOLLOWUPS---` proposal, the optional `---CLARIFY---` block, and the + // `result:rN` marks (whose digits must never reach the number check). + const followUpsParse = parseFollowUpsSentinel(replyText); + replyText = followUpsParse.prose.trim() || replyText; + const clarifyParse = parseClarifySentinel({ + prose: replyText, + inventory: model.inventory, + locale, + }); + replyText = clarifyParse.prose.trim() || replyText; + const resultRefs = stripResultRefs(replyText); + replyText = resultRefs.prose.trim() || replyText; + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant // reply, before persistence and streaming. The inbound `detectRefusal` // guards the user's message; this guards the model's reply for a @@ -333,6 +361,11 @@ export async function guardReply(args: { outboundBlocked: outbound.block, groundedFigures, unverifiedStripped, + referencedResults: resultRefs.referenced, + followUpProposals: followUpsParse.proposals, + // A blocked turn carries the fallback prose, so a question it asked is + // gone and its choices must not ride along. + clarification: outbound.block ? null : clarifyParse.clarification, }, }; } diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts new file mode 100644 index 000000000..66c14d23e --- /dev/null +++ b/src/lib/ai/coach/turn/reuse-turn.ts @@ -0,0 +1,20 @@ +/** + * A model-free turn: a reuse chip ("as a chart", "as a table") answered from + * a table already stored on the conversation. No budget reservation and no + * provider call; the capability and the rate limit still apply at the route. + * Streams `step → token (caption) → provenance → result → followUps → done` + * and persists an assistant message with `providerType: "reuse"`. + * + * Not built yet: answers null, and the turn runs as a plain model turn. + */ +import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve"; + +import type { TurnConversation, TurnInput } from "./types"; + +export async function runReuseTurn(_args: { + input: TurnInput; + conversation: TurnConversation; + resolved: ResolvedFollowUp; +}): Promise { + return null; +} diff --git a/src/lib/ai/coach/turn/sse.ts b/src/lib/ai/coach/turn/sse.ts index 9eb296148..d1a70394e 100644 --- a/src/lib/ai/coach/turn/sse.ts +++ b/src/lib/ai/coach/turn/sse.ts @@ -87,9 +87,10 @@ function flushTick(): Promise { /** * Emit a finished turn. A failed outcome is one `error` frame. A guarded - * reply goes out as `token* → provenance → suggestion? → suggestedAction? → - * done`; the token frames carry the FULLY-GUARDED text, and every guard ran - * before the first one leaves. + * reply goes out as `token* → provenance → result* → suggestion? → + * suggestedAction? → clarification? → followUps? → done`; the token frames + * carry the FULLY-GUARDED text, and every guard ran before the first one + * leaves. The live `step` frames went out earlier, while the model ran. */ export async function emitReply( emitter: TurnEmitter, @@ -115,6 +116,11 @@ export async function emitReply( } if (emitter.aborted()) return; emitter.emit({ type: "provenance", metricSource: outcome.provenance }); + // v1.39.4 — additive `result` frames: the tables this turn read, after + // every guard and only on the owner's own stream. + for (const result of outcome.results) { + emitter.emit({ type: "result", result }); + } // v1.18.1 (Workstream C) — additive `suggestion` frame. if (outcome.suggestion) { emitter.emit({ type: "suggestion", suggestion: outcome.suggestion }); @@ -126,6 +132,16 @@ export async function emitReply( suggestedAction: outcome.action, }); } + // v1.39.4 — additive `clarification` and `followUps` frames. + if (outcome.clarification) { + emitter.emit({ + type: "clarification", + clarification: outcome.clarification, + }); + } + if (outcome.followUps.length > 0) { + emitter.emit({ type: "followUps", followUps: outcome.followUps }); + } // v1.18.9 — additive `usage` envelope on the `done` frame. emitter.emit({ type: "done", diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts new file mode 100644 index 000000000..72cd3d381 --- /dev/null +++ b/src/lib/ai/coach/turn/steps.ts @@ -0,0 +1,41 @@ +/** + * Live steps: one `CoachStep` per tool call, emitted as `step` frames while + * the turn runs and persisted on `metricSource.steps`. + * + * A step carries a catalog label key, the server-rendered label, the domain, + * the window and a server-counted number. Never free text, an analyte name + * or a health value. + * + * Not built yet: both functions answer null, so a turn emits no step frame. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { AiToolCall } from "@/lib/ai/types"; +import type { CoachStep } from "@/lib/ai/coach/types"; +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +/** + * The step for one tool call: `running` when `result` is absent (the call + * just started), its final status once the result is in. `index` counts + * calls across the turn from 0; `parsedArgs` are the schema-validated + * arguments, absent when they did not validate. + */ +export function toStep(_args: { + call: AiToolCall; + index: number; + parsedArgs: Record | undefined; + result?: CoachToolResult; + locale: Locale; +}): CoachStep | null { + return null; +} + +/** + * The single step a no-tools turn shows: the full snapshot, with the number + * of metrics it covered. + */ +export function snapshotStep(_args: { + metricCount: number; + locale: Locale; +}): CoachStep | null { + return null; +} diff --git a/src/lib/ai/coach/turn/types.ts b/src/lib/ai/coach/turn/types.ts index c8a58b9dd..439c392fd 100644 --- a/src/lib/ai/coach/turn/types.ts +++ b/src/lib/ai/coach/turn/types.ts @@ -15,7 +15,10 @@ import type { Locale } from "@/lib/i18n/config"; import type { CoachTurn } from "@/lib/ai/coach/chat-request-builder"; import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; import type { + CoachClarification, + CoachFollowUp, CoachProvenance, + CoachResultTable, CoachScope, CoachStreamEvent, CoachSuggestion, @@ -32,6 +35,10 @@ export interface TurnInput { scope: CoachScope | undefined; guidedQuestion: string | undefined; workoutId: string | undefined; + /** v1.39.4 — the follow-up chip this message came from, if any. */ + followUp: { messageId: string; id: string } | undefined; + /** v1.39.4 — the clarifying question this message answers, if any. */ + clarification: { messageId: string; choiceId?: string } | undefined; /** * The `coach` capability gate, re-run at the egress site. Owned by the * route (the capability inventory reads it there); returns the refusal @@ -80,6 +87,12 @@ export type ReplyOutcome = provenance: CoachProvenance; suggestion: CoachSuggestion | null; action: CoachSuggestedAction | null; + /** v1.39.4 — tables streamed after the provenance, owner only. */ + results: CoachResultTable[]; + /** v1.39.4 — the chips under the reply. */ + followUps: CoachFollowUp[]; + /** v1.39.4 — the choices, when the reply is a question. */ + clarification: CoachClarification | null; messageId: string; totalTokens: number; model: string | null; diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index ca2282dd4..9420d4b63 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -10,6 +10,7 @@ import { z } from "zod/v4"; import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; import type { CoachSuggestedAction } from "./suggest-action"; +import type { CoachToolName } from "./tools/definitions"; /** * Chat-message role. Stored as a free-form string column server-side @@ -157,6 +158,31 @@ export const coachChatRequestSchema = z.object({ * turn, so per-turn work does not grow with conversation length. */ workoutId: z.string().max(64).optional(), + /** + * v1.39.4 — the person tapped a follow-up chip. `messageId` is the + * assistant message that offered it and `id` the chip on that message + * (`f1`..`f3`). The server resolves the chip from what it persisted on + * the conversation's latest assistant message; the client never sends + * what the chip asks for, only which chip it was. A chip that is no + * longer current degrades to a plain message. + */ + followUp: z + .object({ + messageId: z.string().min(1).max(64), + id: z.string().min(1).max(8), + }) + .optional(), + /** + * v1.39.4 — the person answered a clarifying question. `messageId` is + * the assistant message that asked it; `choiceId` the chip they tapped, + * absent when they typed their own answer instead. + */ + clarification: z + .object({ + messageId: z.string().min(1).max(64), + choiceId: z.string().min(1).max(8).optional(), + }) + .optional(), }); /** @@ -212,6 +238,21 @@ export interface CoachUsage { * inside the thinking disclosure when a reasoning-capable provider * emits a cheap reasoning summary. Providers without reasoning emit * none, and the disclosure falls back to its elapsed-time label. + * + * v1.39.4 — four additive frames: `step` (live progress while the Coach + * reads the record), `result` (a table of the values it read, owner only), + * `followUps` (chips under the latest reply) and `clarification` (a + * question with choices). Frame order on a turn: + * + * step* → token* → provenance → result* → suggestion? → + * suggestedAction? → clarification? → followUps? → done + * + * No new frame carries a top-level key the older native client decodes + * (`token`, `conversationId`, `messageId`, `code`, `message`, `suggestion`, + * `metricSource`, `usage`), so an older client drops them whole. + * `src/__tests__/coach-stream-ios-compat.test.ts` holds that rule, and the + * Zod mirror in `stream-events.ts` is held equal to this union by a type + * test. */ export type CoachStreamEvent = | { type: "token"; token: string } @@ -238,8 +279,205 @@ export type CoachStreamEvent = * ignore it. */ reason?: AiUnavailableReason; + } + | { type: "step"; step: CoachStep } + | { type: "result"; result: CoachResultTable } + | { type: "followUps"; followUps: CoachFollowUp[] } + | { type: "clarification"; clarification: CoachClarification }; + +// ── Steps (v1.39.4) ───────────────────────────────────────────────────── +// What the Coach is reading while a turn runs. Labels come from a closed +// catalog; a step never carries free text, an analyte name, or a value. + +export type CoachStepStatus = "running" | "done" | "empty" | "failed"; + +/** + * The data domain a step, result or chip is about. The measurement-backed + * scope sources plus the domains that are read as a whole. + */ +export type CoachStepDomain = + CoachScopeSource | "labs" | "illness" | "cycle" | "correlations" | "snapshot"; + +/** Why a step or a method entry found nothing. */ +export type CoachStepReason = + | "no_data" + | "outside_window" + | "module_disabled" + | "retrieval_failed" + | "invalid_arguments"; + +export interface CoachStep { + /** `s1`..`s12`, unique within a turn; frames upsert by id. */ + id: string; + tool: CoachToolName | "show_result" | "snapshot"; + /** Closed catalog key, e.g. `coach.step.read`. */ + labelKey: string; + /** The label rendered on the server in the request locale; the fallback. */ + label: string; + domain?: CoachStepDomain; + window?: CoachScopeWindow; + period?: CoachResultPeriod; + granularity?: CoachResultGranularity; + status: CoachStepStatus; + /** Readings or rows the server counted. Never a health value. */ + count?: number; + reason?: CoachStepReason; + /** `r1` when the step produced a table. */ + resultRef?: string; +} + +// ── Results (v1.39.4) ─────────────────────────────────────────────────── +// A table of the values a turn read. The values are health data: encrypted +// at rest (`coach_messages.results_encrypted`) and sent only to the owner. +// The metadata alone rides the plaintext provenance. + +export type CoachResultGranularity = "day" | "week" | "month"; +export type CoachResultPeriod = "current" | "previous" | "yearAgo"; +export type CoachResultShape = + "timeSeries" | "categoryCounts" | "distribution" | "single"; + +export interface CoachResultColumn { + key: string; + kind: "period" | "category" | "number" | "count"; + labelKey: string; + label: string; + /** Display unit token (e.g. `mmHg`), never free text. */ + unit?: string; + decimals?: number; +} + +/** `null` is a period with no reading: absence stays explicit. */ +export type CoachResultCell = string | number | null; + +export type CoachChartSpec = + | { kind: "line"; x: string; series: string[] } + | { + kind: "bar"; + x: string; + series: string[]; + orientation: "vertical" | "horizontal"; + } + | { + kind: "histogram"; + column: string; + unit?: string; + bins: Array<{ from: number; to: number; count: number }>; }; +export interface CoachResultSource { + tool: CoachToolName; + domain: CoachStepDomain; + window: CoachScopeWindow; + period: CoachResultPeriod; + granularity?: CoachResultGranularity; +} + +/** Plaintext; rides `metricSource.results`. */ +export interface CoachResultMeta { + /** `r1`..`r6`, per message. */ + ref: string; + source: CoachResultSource; + shape: CoachResultShape; + titleKey: string; + title: string; + /** The full row count, before any trim. */ + rowCount: number; + chartKind: CoachChartSpec["kind"] | null; + /** The answer referenced it (shown expanded) or it sits under "Data used". */ + displayed: boolean; + /** Set when the table was copied from an earlier message of the thread. */ + reusedFrom?: { messageId: string; ref: string }; +} + +/** Encrypted at rest; on the wire only to the owner. */ +export interface CoachResultTable extends CoachResultMeta { + columns: CoachResultColumn[]; + /** Row-major, at most 400 rows. */ + rows: CoachResultCell[][]; + /** True when `rowCount` exceeds `rows.length`. */ + truncated: boolean; + chart: CoachChartSpec | null; +} + +/** + * One entry of `GET …/messages/{messageId}/results`: the table, or the + * reason it is not served. `module_disabled` — the domain's module is now + * off for the record; `unavailable` — the stored tables could not be read. + */ +export type CoachResultEntry = + | CoachResultTable + | { ref: string; withheld: "module_disabled" | "unavailable" }; + +// ── Method (v1.39.4) ──────────────────────────────────────────────────── +// How the answer was reached: sources, windows, counts, aggregation. Never +// a health value. + +export interface CoachMethodEntry { + domain: CoachStepDomain; + window?: CoachScopeWindow; + period?: CoachResultPeriod; + granularity?: CoachResultGranularity; + count?: number; + aggregation?: "mean" | "median" | "latest" | "sum" | "count" | "rate"; + absent?: "no_data" | "outside_window" | "module_disabled"; +} + +export interface CoachMethod { + entries: CoachMethodEntry[]; + /** Rendered on the server in the request locale. */ + text: string; +} + +// ── Follow-ups (v1.39.4) ──────────────────────────────────────────────── +// Chips under the latest assistant reply. The label is always rendered by +// the server from a catalog; the model may at most pick a kind and domain. + +export type CoachFollowUpKind = + | "widen_window" + | "previous_period" + | "year_ago" + | "as_chart" + | "as_table" + | "related_metric" + | "continue"; + +export interface CoachFollowUp { + /** `f1`..`f3`. */ + id: string; + kind: CoachFollowUpKind; + labelKey: string; + label: string; + anchor?: { + ref: string; + domain: CoachStepDomain; + window?: CoachScopeWindow; + granularity?: CoachResultGranularity; + period?: CoachResultPeriod; + }; + /** True when the chip is answered from a stored table, without a model. */ + reuse: boolean; + origin: "server" | "model"; +} + +// ── Clarification (v1.39.4) ───────────────────────────────────────────── +// A question with choices. The question text is the assistant message +// itself, encrypted like any reply; only the choices ride here. + +export interface CoachClarificationChoice { + id: string; + labelKey: string; + label: string; + value: { metric?: CoachScopeSource; window?: CoachScopeWindow }; +} + +export interface CoachClarification { + kind: "metric" | "window" | "context"; + /** At most 4; metric choices are always ones the record holds. */ + choices: CoachClarificationChoice[]; + /** Whether a typed answer is welcome beside the choices. */ + freeText: boolean; +} + /** * v1.4.22 — Zod schema for one entry inside the Coach's evidence * block. The model emits these as lines between the @@ -399,6 +637,29 @@ export interface CoachProvenance { * turn withheld nothing. */ unverifiedFigures?: number; + /** + * v1.39.4 — what the Coach read on this turn, one entry per tool call (or + * one `snapshot` step on the no-tools path). Catalog labels, domains, + * windows and counts only. + */ + steps?: CoachStep[]; + /** v1.39.4 — how the answer was reached, as a server-rendered line. */ + method?: CoachMethod; + /** + * v1.39.4 — the tables this turn produced, metadata only. The values are + * in `results_encrypted` and served by + * `GET /api/insights/chat/{id}/messages/{messageId}/results`. + */ + results?: CoachResultMeta[]; + /** v1.39.4 — the chips offered under this reply. */ + followUps?: CoachFollowUp[]; + /** v1.39.4 — the choices, when this reply is a clarifying question. */ + clarification?: CoachClarification; + /** + * v1.39.4 — the tool loop hit its round cap while the model still wanted + * to read, so the answer was forced. Absent otherwise. + */ + forcedFinal?: true; } /** diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 56c1b5c55..bb280d110 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -222,6 +222,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // ───── Coach (Bytes columns) ───── { model: "CoachMessage", field: "encryptedContent", kind: "bytes" }, + // v1.39.4 — the tables of values a turn read, JSON under the same codec. + { model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" }, { model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" }, // v1.39.3 — the title: the opening words of the first message, or a rename. { model: "CoachConversation", field: "titleEncrypted", kind: "bytes" }, diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index a373843e6..f5c64ffc2 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -79,6 +79,17 @@ export interface CoachMessageBackupEntry { promptVersion: string | null; tokensUsed: number | null; model: string | null; + /** + * v1.39.4 — the tables of values the turn read, as ciphertext in base64. + * Present on a disaster-recovery payload only; null on a turn without one. + */ + resultsEncrypted?: string | null; + /** + * v1.39.4 — the same tables as readable JSON (a `CoachResultTable[]` + * array), on a portable payload only; null on a turn without one. The + * unreadable-row marker stands in for tables that did not decrypt. + */ + resultsJson?: string | null; createdAt: string; } @@ -155,6 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = { promptVersion: true, tokensUsed: true, model: true, + resultsEncrypted: true, createdAt: true, }, }, @@ -248,6 +260,14 @@ export async function buildCoachBackupSection( promptVersion: message.promptVersion, tokensUsed: message.tokensUsed, model: message.model, + // v1.39.4 — the tables follow the same contract as the prose. + ...(disasterRecovery + ? { + resultsEncrypted: message.resultsEncrypted + ? Buffer.from(message.resultsEncrypted).toString("base64") + : null, + } + : { resultsJson: decryptTurnSoft(message.resultsEncrypted) }), createdAt: message.createdAt.toISOString(), })), attachments: row.attachments.map((attachment) => ({ @@ -318,6 +338,8 @@ export type RestoredCoachMessage = Pick< | "promptVersion" | "tokensUsed" | "model" + | "resultsEncrypted" + | "resultsJson" > >; @@ -401,6 +423,7 @@ export async function restoreCoachData( promptVersion: message.promptVersion ?? null, tokensUsed: message.tokensUsed ?? null, model: message.model ?? null, + resultsEncrypted: resolveResultsBytes(message), createdAt: new Date(message.createdAt), })), }, @@ -457,6 +480,23 @@ function resolveTurnBytes( return encryptToBytes(message.content ?? ""); } +/** + * v1.39.4 — a turn's stored tables, from either end of the contract. A + * disaster-recovery file carries the ciphertext; a portable file carries the + * JSON, sealed here under the target instance's key. Neither end writes a + * zero-byte ciphertext for a turn without tables. + */ +function resolveResultsBytes( + message: RestoredCoachMessage, +): Uint8Array | null { + if (message.resultsEncrypted !== undefined) { + return message.resultsEncrypted === null + ? null + : decodeBase64(message.resultsEncrypted); + } + return message.resultsJson ? encryptToBytes(message.resultsJson) : null; +} + function resolveTitleBytes( conversation: RestoredCoachConversation, ): Uint8Array { 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"], diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index cf336d738..5e6ba1138 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -48,6 +48,20 @@ export const coachKeys = { */ coachAttachmentMutation: (conversationId: string) => ["coachConversation", conversationId, "attachments"] as const, + /** + * v1.39.4 — the stored tables of one assistant message + * (`GET /api/insights/chat/{id}/messages/{messageId}/results`). Nested under + * the conversation key so deleting or invalidating the conversation reaches + * every message's tables with the existing prefix match. + */ + coachMessageResults: (conversationId: string, messageId: string) => + [ + "coachConversation", + conversationId, + "messages", + messageId, + "results", + ] as const, /** * v1.21.2 (A3) — today's most notable derived signal, resolved into the * Coach hero's pre-seeded relevance opener diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 40fbd9397..76bdb532a 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1357,6 +1357,10 @@ const coachMessageBackupSchema = z promptVersion: z.string().nullable().optional(), tokensUsed: z.number().int().nullable().optional(), model: z.string().nullable().optional(), + // v1.39.4 — the turn's tables: ciphertext on a disaster-recovery file, + // readable JSON on a portable one, absent on every file written before. + resultsEncrypted: base64BytesSchema.nullable().optional(), + resultsJson: z.string().nullable().optional(), createdAt: isoDateTime, }) .passthrough(); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index a9cae4e6b..be6962acc 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -117,6 +117,46 @@ const METRIC_SEALED_NOTE = "right hand, rested"; const COACH_USER_TURN = "my readings look higher this week, is that real?"; const COACH_ASSISTANT_TURN = "the last seven mornings average 4 mmHg above the fortnight before"; +/** The table of values the assistant turn read: ciphertext at rest. */ +const COACH_RESULTS_JSON = JSON.stringify([ + { + ref: "r1", + source: { + tool: "get_metric_series", + domain: "bp", + window: "last7days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.metricByPeriod", + title: "Blood pressure by day", + rowCount: 2, + chartKind: "line", + displayed: true, + columns: [ + { + key: "period", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "sys", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + }, + ], + rows: [ + ["2026-07-19", 131], + ["2026-07-20", null], + ], + truncated: false, + chart: { kind: "line", x: "period", series: ["sys"] }, + }, +]); const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; @@ -926,6 +966,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { { role: "assistant", encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), + resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON), providerType: "anthropic", model: "claude-opus-5", tokensUsed: 812, @@ -1580,6 +1621,9 @@ describe("every model the plan claims two-ended survives a real restore", () => content: decryptFromBytes(message.encryptedContent), model: message.model, tokensUsed: message.tokensUsed, + results: message.resultsEncrypted + ? decryptFromBytes(message.resultsEncrypted) + : null, })), attachments: thread.attachments.map((a) => a.documentId), })), @@ -1597,12 +1641,14 @@ describe("every model the plan claims two-ended survives a real restore", () => content: COACH_USER_TURN, model: null, tokensUsed: null, + results: null, }, { role: "assistant", content: COACH_ASSISTANT_TURN, model: "claude-opus-5", tokensUsed: 812, + results: COACH_RESULTS_JSON, }, ], attachments: [], @@ -1619,6 +1665,7 @@ describe("every model the plan claims two-ended survives a real restore", () => content: "What does the ferritin mean?", model: null, tokensUsed: null, + results: null, }, ], attachments: [vaultDocument.id], @@ -2699,6 +2746,75 @@ describe("every model the plan claims two-ended survives a real restore", () => * side by side: a dropped row and a nulled pointer, each named in the report, * and a restore that still answers 200. */ + /** + * The Coach's stored tables through a portable file. + * + * A disaster-recovery file carries them as ciphertext (the round trip at + * the top of this file). A portable file has to carry them readable, like + * the prose they sit beside, and the restore has to seal them again under + * the target's key. A restore that dropped the readable JSON would bring + * the thread back with every answer's table missing and nothing to say so. + */ + it("carries a turn's tables readable in a portable file and seals them again on restore", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + await prisma.coachConversation.create({ + data: { + userId: OWNER_ID, + titleEncrypted: encryptToBytes("How is my blood pressure trending?"), + documentScoped: false, + messages: { + create: [ + { + role: "assistant", + encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), + resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON), + createdAt: AT("2026-07-20T09:59:00.000Z"), + }, + ], + }, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + const { coachConversations } = payload as { + coachConversations: Array<{ messages: Array> }>; + }; + const [turn] = coachConversations[0].messages; + expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON }); + expect(turn).not.toHaveProperty("resultsEncrypted"); + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + + const restored = await prisma.coachMessage.findFirstOrThrow({ + where: { conversation: { userId: OWNER_ID } }, + }); + expect(restored.resultsEncrypted).not.toBeNull(); + expect(decryptFromBytes(restored.resultsEncrypted!)).toBe( + COACH_RESULTS_JSON, + ); + }); + it("drops and names a filing and a commitment a truncated file cannot resolve", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma);