From 210becb28bdac9821d71d2479abefb7d6cd1c912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 18:39:50 +0200 Subject: [PATCH] feat(coach): lay down the reasoning, trail and memory contract Add the shared contract the reasoning release builds on, with no change to how a turn runs today. - Schema: operator reasoning controls on app_settings (0376), the encrypted trail text on coach_messages (0377), and source, last_used_at and source_message_id on coach_facts (0378). - Provider contract: CompletionParams.reasoning and onReasoning, CompletionResult.reasoning and providerState, AiMessage.providerState. - Reasoning levels, thinking budgets and the background-job table in src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent meaning medium, salvaged field by field like every other pref. - Stream: activity, memoryNote and planProposal frames, done.stop and done.withheldResults, result.interim, a compare chart kind, eight table refs, three more clarification kinds with an assumed choice, the change_assumption chip, and assumptions, activity, stop, memoryNote and planProposal on the provenance. Zod mirror, type parity and the native-client key rule cover every new frame. - Chat request: memoryDecision and planDecision, parsed and not yet acted on. - Stub contracts for the memory functions and the activity recorder, inert until their implementations land. - Backup, restore and rotation carry the new columns; a fact's message reference is resolved against the restored messages like its conversation reference. - Message keys for the trail, memory, plans, assumptions, replies, interim tables, comparison charts and reasoning settings in all seven locales, each reachable from a catalog and checked both ways. --- docs/api/openapi.yaml | 785 +++++++++++++++++- messages/de.json | 90 +- messages/en.json | 90 +- messages/es.json | 90 +- messages/fr.json | 90 +- messages/it.json | 90 +- messages/ko.json | 90 +- messages/pl.json | 90 +- .../0376_ai_reasoning_controls/migration.sql | 6 + .../0377_coach_message_trail/migration.sql | 4 + .../0378_coach_fact_source/migration.sql | 8 + prisma/schema.prisma | 30 + scripts/rotate-encryption-key.ts | 13 +- src/__tests__/coach-stream-ios-compat.test.ts | 6 +- .../insights/coach-panel/result-chart.tsx | 3 + .../ai/coach/__tests__/contract-keys.test.ts | 152 ++++ .../coach/__tests__/stream-events-ids.test.ts | 87 ++ .../stream-events-type-parity.test.ts | 46 + .../coach/activity/__tests__/contract.test.ts | 52 ++ src/lib/ai/coach/activity/contract.ts | 143 ++++ src/lib/ai/coach/dialog-keys.ts | 24 + src/lib/ai/coach/follow-ups/resolve.ts | 3 + .../coach/memory/__tests__/contract.test.ts | 91 ++ src/lib/ai/coach/memory/contract.ts | 205 +++++ src/lib/ai/coach/stream-events.ts | 320 ++++++- src/lib/ai/coach/types.ts | 281 ++++++- src/lib/ai/reasoning/__tests__/levels.test.ts | 61 ++ src/lib/ai/reasoning/levels.ts | 131 +++ src/lib/ai/types.ts | 62 ++ src/lib/crypto/encrypted-columns.ts | 2 + src/lib/export/coach-backup.ts | 69 ++ src/lib/export/restore-backup.ts | 11 + src/lib/validations/__tests__/backup.test.ts | 17 + .../validations/__tests__/coach-prefs.test.ts | 37 + src/lib/validations/backup.ts | 28 + src/lib/validations/coach-prefs.ts | 21 + .../admin-backups-canonical-roundtrip.test.ts | 2 + tests/integration/backup-round-trip.test.ts | 35 +- 38 files changed, 3308 insertions(+), 57 deletions(-) create mode 100644 prisma/migrations/0376_ai_reasoning_controls/migration.sql create mode 100644 prisma/migrations/0377_coach_message_trail/migration.sql create mode 100644 prisma/migrations/0378_coach_fact_source/migration.sql create mode 100644 src/lib/ai/coach/__tests__/contract-keys.test.ts create mode 100644 src/lib/ai/coach/activity/__tests__/contract.test.ts create mode 100644 src/lib/ai/coach/activity/contract.ts create mode 100644 src/lib/ai/coach/memory/__tests__/contract.test.ts create mode 100644 src/lib/ai/coach/memory/contract.ts create mode 100644 src/lib/ai/reasoning/__tests__/levels.test.ts create mode 100644 src/lib/ai/reasoning/levels.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 41a985a94..09bea413c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25979,6 +25979,16 @@ components: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean + reasoning: + description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means + `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET + /api/auth/me` as `coachReasoning`." + type: string + enum: + - off + - low + - medium + - high baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -26666,6 +26676,40 @@ components: maxLength: 8 required: - messageId + memoryDecision: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + proposalId: + type: string + minLength: 1 + maxLength: 64 + accept: + type: boolean + required: + - messageId + - proposalId + - accept + planDecision: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + planId: + type: string + minLength: 1 + maxLength: 64 + accept: + type: boolean + required: + - messageId + - planId + - accept required: - message description: "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a @@ -34972,6 +35016,14 @@ components: type: boolean assistantDocumentAiEnabled: type: boolean + aiReasoningEnabled: + type: boolean + aiReasoningMaxEffort: + type: string + enum: + - low + - medium + - high moduleAvailabilityJson: anyOf: - {} @@ -38866,6 +38918,15 @@ components: anyOf: - type: string - type: "null" + trailEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + trailJson: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -39032,6 +39093,19 @@ components: anyOf: - type: string - type: "null" + source: + type: string + minLength: 1 + maxLength: 32 + lastUsedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + sourceMessageId: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -48884,6 +48958,16 @@ components: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean + reasoning: + description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means + `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET + /api/auth/me` as `coachReasoning`." + type: string + enum: + - off + - low + - medium + - high updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or @@ -52165,6 +52249,12 @@ components: type: string usage: $ref: "#/components/schemas/CoachUsage" + stop: + $ref: "#/components/schemas/CoachStop" + withheldResults: + description: v1.41 — the turn was blocked after interim tables went out; remove them. + type: boolean + const: true required: - type - conversationId @@ -52212,6 +52302,10 @@ components: const: result result: $ref: "#/components/schemas/CoachResultTable" + interim: + description: v1.41 — sent while the turn still runs. + type: boolean + const: true required: - type - result @@ -52240,9 +52334,43 @@ components: - type - clarification additionalProperties: false + - type: object + properties: + type: + type: string + const: activity + activity: + $ref: "#/components/schemas/CoachActivity" + required: + - type + - activity + additionalProperties: false + - type: object + properties: + type: + type: string + const: memoryNote + note: + $ref: "#/components/schemas/CoachMemoryNote" + required: + - type + - note + additionalProperties: false + - type: object + properties: + type: + type: string + const: planProposal + proposal: + $ref: "#/components/schemas/CoachPlanProposal" + required: + - type + - proposal + 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." + Order: `(activity | step)*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → + `memoryNote?` → `planProposal?` → `clarification?` → `followUps?` → `done`, or a single `error`. `result` frames + with `interim: true` may also arrive among the `activity` frames. Clients ignore a `type` they do not know." type: object CoachProvenance: type: object @@ -52451,6 +52579,25 @@ components: that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise." type: string maxLength: 64 + activity: + description: v1.41 — the turn's trail, metadata only. + type: array + items: + $ref: "#/components/schemas/CoachActivityMeta" + stop: + description: v1.41 — why the answer was forced, when it was. + $ref: "#/components/schemas/CoachStop" + assumptions: + description: v1.41 — what the answer assumed instead of asking, at most two. + type: array + items: + $ref: "#/components/schemas/CoachAssumption" + memoryNote: + description: v1.41 — the fact this reply saved or proposes, without its text. + $ref: "#/components/schemas/CoachMemoryNoteMeta" + planProposal: + description: v1.41 — the plan this reply proposes, without its text. + $ref: "#/components/schemas/CoachPlanProposalMeta" required: - windows - metrics @@ -52530,7 +52677,7 @@ components: id: type: string pattern: ^s[1-9]\d{0,2}$ - description: "`s1`..`s12`, unique within a turn." + description: "`s1`..`s48`, unique within a turn." tool: type: string enum: @@ -52598,7 +52745,7 @@ components: resultRef: description: The table this step produced (`r1`..), when it produced one. type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ required: - id - tool @@ -52724,8 +52871,8 @@ components: properties: ref: type: string - pattern: ^r[1-6]$ - description: "`r1`..`r6`, unique within a message." + pattern: ^r[1-8]$ + description: "`r1`..`r8`, unique within a message." source: type: object properties: @@ -52791,6 +52938,7 @@ components: - type: string enum: - line + - compare - bar - histogram - type: "null" @@ -52807,7 +52955,7 @@ components: pattern: ^[A-Za-z0-9_-]{1,64}$ ref: type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ required: - messageId - ref @@ -52846,6 +52994,7 @@ components: - as_table - related_metric - continue + - change_assumption labelKey: type: string label: @@ -52856,7 +53005,7 @@ components: properties: ref: type: string - pattern: ^(?:r[1-6])?$ + pattern: ^(?:r[1-8])?$ domain: $ref: "#/components/schemas/CoachStepDomain" window: @@ -52891,6 +53040,91 @@ components: enum: - server - model + assumption: + description: "v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks." + type: object + properties: + kind: + type: string + enum: + - metric + - window + - comparison + 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 + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." + required: + - kind + - value + additionalProperties: false required: - id - kind @@ -52909,7 +53143,11 @@ components: enum: - metric - window + - comparison + - goal + - anchor - context + description: "`comparison`, `goal` and `anchor` since v1.41." choices: type: array items: @@ -52976,7 +53214,23 @@ components: - last90days - lastYear - allTime + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." required: - id - labelKey @@ -52987,6 +53241,11 @@ components: freeText: type: boolean description: Whether a typed answer is welcome. + assumption: + description: v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first + choice. + type: string + pattern: ^c[1-4]$ required: - kind - choices @@ -52994,6 +53253,328 @@ components: additionalProperties: false description: "The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`." + CoachActivityMeta: + type: object + properties: + id: + type: string + pattern: ^a[1-9]\d?$ + description: "`a1`..`a99`, unique within a turn." + phase: + type: string + enum: + - thinking + - memory + - fetch + - digest + - checkpoint + - remember + - plan + - asking + - stop + - answer + status: + type: string + enum: + - running + - done + - empty + - failed + round: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The tool round, from 1. + labelKey: + type: string + description: Closed catalog key (`insights.coach.activity.*`). + label: + type: string + description: Rendered on the server in the request locale from the catalog; never model text. + stepRef: + description: The `step` a `fetch` entry stands for. + type: string + pattern: ^s[1-9]\d{0,2}$ + count: + description: Readings, lookups or entries the server counted. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + durationMs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stop: + description: Set on the `stop` entry. + type: string + enum: + - budget + - time + - cap + - no_progress + required: + - id + - phase + - status + - round + - labelKey + - label + additionalProperties: false + description: "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog + label, counts. The model-written title and text are behind `GET + /api/insights/chat/{id}/messages/{messageId}/trail`." + CoachStop: + type: object + properties: + reason: + type: string + enum: + - budget + - time + - cap + - no_progress + rounds: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The rounds the turn ran, the final one included. + required: + - reason + - rounds + additionalProperties: false + description: "v1.41 — why the Coach answered before it was done reading: the token budget, the time budget, the round + cap, or no new data." + CoachAssumption: + type: object + properties: + kind: + type: string + enum: + - metric + - window + - comparison + value: + type: object + properties: + 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 + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." + required: + - labelKey + - label + - value + additionalProperties: false + alternatives: + type: array + items: + type: object + properties: + 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 + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." + required: + - labelKey + - label + - value + additionalProperties: false + description: At most 3. + required: + - kind + - value + - alternatives + additionalProperties: false + description: v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a + `change_assumption` follow-up. + CoachMemoryNoteMeta: + type: object + properties: + proposal: + type: boolean + description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes + `factId`." + proposalId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + factId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + required: + - proposal + - category + additionalProperties: false + description: v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text. + CoachPlanProposalMeta: + type: object + properties: + planId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + metric: + type: string + maxLength: 64 + reviewInDays: + type: integer + minimum: 7 + maximum: 56 + required: + - planId + - metric + - reviewInDays + additionalProperties: false + description: v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text. CoachUsage: type: object properties: @@ -53028,8 +53609,8 @@ components: properties: ref: type: string - pattern: ^r[1-6]$ - description: "`r1`..`r6`, unique within a message." + pattern: ^r[1-8]$ + description: "`r1`..`r8`, unique within a message." source: type: object properties: @@ -53095,6 +53676,7 @@ components: - type: string enum: - line + - compare - bar - histogram - type: "null" @@ -53111,7 +53693,7 @@ components: pattern: ^[A-Za-z0-9_-]{1,64}$ ref: type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ required: - messageId - ref @@ -53184,6 +53766,38 @@ components: - x - series additionalProperties: false + - type: object + properties: + kind: + type: string + const: compare + mode: + type: string + enum: + - periods + - metrics + x: + type: string + a: + type: string + b: + type: string + axes: + anyOf: + - type: number + const: 1 + - type: number + const: 2 + required: + - kind + - mode + - x + - a + - b + - axes + additionalProperties: false + description: "v1.41 — two series on one chart: two periods overlaid (`periods`, one axis) or two metrics (`metrics`, one + or two axes). `a` and `b` name the value columns, `x` the shared column." - type: object properties: kind: @@ -53257,6 +53871,143 @@ components: 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." + CoachActivity: + type: object + properties: + id: + type: string + pattern: ^a[1-9]\d?$ + description: "`a1`..`a99`, unique within a turn." + phase: + type: string + enum: + - thinking + - memory + - fetch + - digest + - checkpoint + - remember + - plan + - asking + - stop + - answer + status: + type: string + enum: + - running + - done + - empty + - failed + round: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The tool round, from 1. + labelKey: + type: string + description: Closed catalog key (`insights.coach.activity.*`). + label: + type: string + description: Rendered on the server in the request locale from the catalog; never model text. + stepRef: + description: The `step` a `fetch` entry stands for. + type: string + pattern: ^s[1-9]\d{0,2}$ + count: + description: Readings, lookups or entries the server counted. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + durationMs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stop: + description: Set on the `stop` entry. + type: string + enum: + - budget + - time + - cap + - no_progress + title: + description: A screened reasoning title or checkpoint sentence. + type: string + maxLength: 80 + text: + description: A screened reasoning summary for the round. + type: string + maxLength: 400 + required: + - id + - phase + - status + - round + - labelKey + - label + additionalProperties: false + description: v1.41 — one entry of the live trail, sent as `activity` frames that upsert by `id`. `title` and `text` are + model text, screened, and sent only to the account that owns the conversation. + CoachMemoryNote: + type: object + properties: + proposal: + type: boolean + description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes + `factId`." + proposalId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + factId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + fact: + type: string + maxLength: 160 + required: + - proposal + - category + - fact + additionalProperties: false + description: v1.41 — a fact the Coach saved during the turn, or proposes to save (health facts are never saved without a + tap). Owner only. + CoachPlanProposal: + type: object + properties: + planId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + metric: + type: string + maxLength: 64 + reviewInDays: + type: integer + minimum: 7 + maximum: 56 + ifCue: + type: string + thenAction: + type: string + target: + type: string + required: + - planId + - metric + - reviewInDays + - ifCue + - thenAction + additionalProperties: false + description: "v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId, + accept }`. Owner only." CoachMessageResultsResponse: type: object properties: @@ -53289,7 +54040,7 @@ components: properties: ref: type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ withheld: type: string enum: @@ -78175,6 +78926,16 @@ components: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean + reasoning: + description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means + `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET + /api/auth/me` as `coachReasoning`." + type: string + enum: + - off + - low + - medium + - high required: - tone - verbosity diff --git a/messages/de.json b/messages/de.json index cc16bbd08..741542e11 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "{count} Bereiche angesehen", "info": "Details zur Antwort", "infoModel": "Modell: {model}" - } + }, + "activity": { + "thinking": "Denkt nach…", + "fetching": "Holt {domain}, {window}…", + "digestOne": "Fasst {count} Wert zusammen…", + "digestFew": "Fasst {count} Werte zusammen…", + "digestOther": "Fasst {count} Werte zusammen…", + "digestDoneOne": "{count} Wert aus {areas}", + "digestDoneFew": "{count} Werte aus {areas}", + "digestDoneOther": "{count} Werte aus {areas}", + "areasOne": "{count} Bereich", + "areasFew": "{count} Bereichen", + "areasOther": "{count} Bereichen", + "memoryOne": "Erinnert sich an {count} Eintrag", + "memoryFew": "Erinnert sich an {count} Einträge", + "memoryOther": "Erinnert sich an {count} Einträge", + "remember": "Merkt sich etwas…", + "plan": "Schlägt einen Plan vor…", + "asking": "Fragt nach…", + "answer": "Schreibt die Antwort…", + "summaryOne": "Nachgedacht, {lookups} Abfrage, {seconds} s", + "summaryFew": "Nachgedacht, {lookups} Abfragen, {seconds} s", + "summaryOther": "Nachgedacht, {lookups} Abfragen, {seconds} s", + "summaryNoLookups": "Nachgedacht, {seconds} s", + "stop": { + "budget": "Budget erreicht, antwortet mit dem, was da ist", + "time": "Zeit erreicht, antwortet mit dem, was da ist", + "cap": "Viele Schritte, fasst jetzt zusammen", + "noProgress": "Keine neuen Daten mehr, fasst zusammen" + } + }, + "memory": { + "saved": "Gemerkt: {fact}", + "undo": "Rückgängig", + "accept": "Ja, merken", + "decline": "Nein", + "confirmed": "Gemerkt." + }, + "plan": { + "accept": "Plan übernehmen", + "decline": "Nicht jetzt", + "confirmed": "Ist notiert. Ich schaue in {days} Tagen mit dir drauf." + }, + "assumption": { + "line": "Angenommen: {value}", + "change": "Ändern" + }, + "suggestedReplies": { + "groupLabel": "Antwortvorschläge" + }, + "interim": { + "labelOne": "{title}, {window}, {count} Wert", + "labelFew": "{title}, {window}, {count} Werte", + "labelOther": "{title}, {window}, {count} Werte" + }, + "chart": { + "compare": { + "periods": "{a} gegen {b}" + } + }, + "reasoning": { + "label": "Denktiefe", + "hint": "Gründlicher denken heißt genauere Antworten und etwas mehr Wartezeit.", + "off": "Aus", + "minimal": "Minimal", + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "capped": "(vom Admin begrenzt)", + "disabled": "Der Admin hat gründliches Denken abgeschaltet." + }, + "memoryLink": "Gedächtnis ({count})" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -6008,7 +6079,8 @@ "forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.", "clearedToast": "{count} gespeicherte Angaben vergessen.", "clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen.", - "detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte." + "detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.", + "categoryMedication": "Medikamente" }, "coachReminders": { "title": "Deine Erinnerungen", @@ -6663,6 +6735,13 @@ "savedOn": "Coach ist aktiviert.", "savedOff": "Coach ist ausgeblendet.", "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." + }, + "memory": { + "edit": "Bearbeiten", + "source": { + "user": "von dir", + "coach": "vom Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7237,10 @@ "documentAi": { "title": "Dokumente lesen", "description": "KI-Lesen gespeicherter Dokumente, Scans von Laborbefunden und das Erkennen von Medikamenten aus Text. Der größte Anteil der Daten, die den Server verlassen." + }, + "reasoning": { + "enabled": "Gründliches Denken erlauben", + "max": "Höchste Denktiefe" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "Siehe auch: {metric}", "continue": "Weitersuchen", "settingLabel": "Anschlussfragen vorschlagen", - "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage." + "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage.", + "changeAssumption": "Stattdessen: {value}" }, "clarify": { "cardLabel": "Der Coach hat eine Rückfrage", diff --git a/messages/en.json b/messages/en.json index 9fd033567..931af8495 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "Looked at {count} areas", "info": "Answer details", "infoModel": "Model: {model}" - } + }, + "activity": { + "thinking": "Thinking…", + "fetching": "Fetching {domain}, {window}…", + "digestOne": "Summarising {count} reading…", + "digestFew": "Summarising {count} readings…", + "digestOther": "Summarising {count} readings…", + "digestDoneOne": "{count} reading from {areas}", + "digestDoneFew": "{count} readings from {areas}", + "digestDoneOther": "{count} readings from {areas}", + "areasOne": "{count} area", + "areasFew": "{count} areas", + "areasOther": "{count} areas", + "memoryOne": "Recalls {count} thing you told it", + "memoryFew": "Recalls {count} things you told it", + "memoryOther": "Recalls {count} things you told it", + "remember": "Remembering something…", + "plan": "Drafting a plan…", + "asking": "Asking you…", + "answer": "Writing the answer…", + "summaryOne": "Thought it through, {lookups} lookup, {seconds} s", + "summaryFew": "Thought it through, {lookups} lookups, {seconds} s", + "summaryOther": "Thought it through, {lookups} lookups, {seconds} s", + "summaryNoLookups": "Thought it through, {seconds} s", + "stop": { + "budget": "Budget reached, answering with what it has", + "time": "Time limit reached, answering with what it has", + "cap": "Many steps, wrapping up", + "noProgress": "No new data, wrapping up" + } + }, + "memory": { + "saved": "Remembered: {fact}", + "undo": "Undo", + "accept": "Yes, remember it", + "decline": "No", + "confirmed": "Got it, I'll remember." + }, + "plan": { + "accept": "Take on this plan", + "decline": "Not now", + "confirmed": "Noted. I'll check in with you in {days} days." + }, + "assumption": { + "line": "Assumed: {value}", + "change": "Change" + }, + "suggestedReplies": { + "groupLabel": "Suggested replies" + }, + "interim": { + "labelOne": "{title}, {window}, {count} reading", + "labelFew": "{title}, {window}, {count} readings", + "labelOther": "{title}, {window}, {count} readings" + }, + "chart": { + "compare": { + "periods": "{a} vs {b}" + } + }, + "reasoning": { + "label": "Thinking depth", + "hint": "Deeper thinking means more precise answers and a little more waiting.", + "off": "Off", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "capped": "(limited by the admin)", + "disabled": "The admin has turned deeper thinking off." + }, + "memoryLink": "Memory ({count})" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -6008,7 +6079,8 @@ "forgotError": "Could not forget that fact. Please try again.", "clearedToast": "Forgot {count} stored facts.", "clearError": "Could not clear the stored facts. Please try again.", - "detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot." + "detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot.", + "categoryMedication": "Medications" }, "coachReminders": { "title": "Your reminders", @@ -6663,6 +6735,13 @@ "savedOn": "Coach activated.", "savedOff": "Coach hidden.", "saveError": "Could not save the change. Please try again." + }, + "memory": { + "edit": "Edit", + "source": { + "user": "from you", + "coach": "from the Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7237,10 @@ "documentAi": { "title": "Reading documents", "description": "AI reading of stored documents, lab report scans and medication extraction from text. The largest share of data leaving the server." + }, + "reasoning": { + "enabled": "Allow deeper thinking", + "max": "Highest thinking depth" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "See also: {metric}", "continue": "Keep looking", "settingLabel": "Suggest follow-up questions", - "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer." + "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer.", + "changeAssumption": "Instead: {value}" }, "clarify": { "cardLabel": "The Coach has a question", diff --git a/messages/es.json b/messages/es.json index 47605ac34..18069350a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "{count} áreas consultadas", "info": "Detalles de la respuesta", "infoModel": "Modelo: {model}" - } + }, + "activity": { + "thinking": "Pensando…", + "fetching": "Consultando {domain}, {window}…", + "digestOne": "Resumiendo {count} valor…", + "digestFew": "Resumiendo {count} valores…", + "digestOther": "Resumiendo {count} valores…", + "digestDoneOne": "{count} valor de {areas}", + "digestDoneFew": "{count} valores de {areas}", + "digestDoneOther": "{count} valores de {areas}", + "areasOne": "{count} área", + "areasFew": "{count} áreas", + "areasOther": "{count} áreas", + "memoryOne": "Recuerda {count} cosa que le contaste", + "memoryFew": "Recuerda {count} cosas que le contaste", + "memoryOther": "Recuerda {count} cosas que le contaste", + "remember": "Recordando algo…", + "plan": "Preparando un plan…", + "asking": "Te pregunta…", + "answer": "Escribiendo la respuesta…", + "summaryOne": "Lo ha pensado, {lookups} consulta, {seconds} s", + "summaryFew": "Lo ha pensado, {lookups} consultas, {seconds} s", + "summaryOther": "Lo ha pensado, {lookups} consultas, {seconds} s", + "summaryNoLookups": "Lo ha pensado, {seconds} s", + "stop": { + "budget": "Presupuesto alcanzado, responde con lo que tiene", + "time": "Tiempo agotado, responde con lo que tiene", + "cap": "Muchos pasos, ahora lo resume", + "noProgress": "No hay datos nuevos, lo resume" + } + }, + "memory": { + "saved": "Recordado: {fact}", + "undo": "Deshacer", + "accept": "Sí, recuérdalo", + "decline": "No", + "confirmed": "Entendido, lo recordaré." + }, + "plan": { + "accept": "Adoptar este plan", + "decline": "Ahora no", + "confirmed": "Anotado. Lo revisaremos juntos dentro de {days} días." + }, + "assumption": { + "line": "Supuesto: {value}", + "change": "Cambiar" + }, + "suggestedReplies": { + "groupLabel": "Respuestas sugeridas" + }, + "interim": { + "labelOne": "{title}, {window}, {count} valor", + "labelFew": "{title}, {window}, {count} valores", + "labelOther": "{title}, {window}, {count} valores" + }, + "chart": { + "compare": { + "periods": "{a} frente a {b}" + } + }, + "reasoning": { + "label": "Profundidad de razonamiento", + "hint": "Pensar más a fondo significa respuestas más precisas y un poco más de espera.", + "off": "Desactivado", + "minimal": "Mínimo", + "low": "Bajo", + "medium": "Medio", + "high": "Alto", + "capped": "(limitado por el administrador)", + "disabled": "El administrador ha desactivado el razonamiento profundo." + }, + "memoryLink": "Memoria ({count})" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -6008,7 +6079,8 @@ "forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.", "clearedToast": "Se olvidaron {count} datos guardados.", "clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo.", - "detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide." + "detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.", + "categoryMedication": "Medicamentos" }, "coachReminders": { "title": "Tus recordatorios", @@ -6663,6 +6735,13 @@ "savedOn": "Coach activado.", "savedOff": "Coach oculto.", "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + }, + "memory": { + "edit": "Editar", + "source": { + "user": "de ti", + "coach": "del Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7237,10 @@ "documentAi": { "title": "Lectura de documentos", "description": "Lectura con IA de documentos guardados, escaneo de informes de laboratorio y extracción de medicamentos a partir de texto. La mayor parte de los datos que salen del servidor." + }, + "reasoning": { + "enabled": "Permitir razonamiento profundo", + "max": "Profundidad máxima de razonamiento" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "Ver también: {metric}", "continue": "Seguir buscando", "settingLabel": "Sugerir preguntas de seguimiento", - "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach." + "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach.", + "changeAssumption": "En su lugar: {value}" }, "clarify": { "cardLabel": "El Coach tiene una pregunta", diff --git a/messages/fr.json b/messages/fr.json index 87a42569d..35008a2ac 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "{count} domaines consultés", "info": "Détails de la réponse", "infoModel": "Modèle : {model}" - } + }, + "activity": { + "thinking": "Réfléchit…", + "fetching": "Récupère {domain}, {window}…", + "digestOne": "Résume {count} mesure…", + "digestFew": "Résume {count} mesures…", + "digestOther": "Résume {count} mesures…", + "digestDoneOne": "{count} mesure sur {areas}", + "digestDoneFew": "{count} mesures sur {areas}", + "digestDoneOther": "{count} mesures sur {areas}", + "areasOne": "{count} domaine", + "areasFew": "{count} domaines", + "areasOther": "{count} domaines", + "memoryOne": "Se souvient de {count} chose que tu lui as dite", + "memoryFew": "Se souvient de {count} choses que tu lui as dites", + "memoryOther": "Se souvient de {count} choses que tu lui as dites", + "remember": "Retient quelque chose…", + "plan": "Propose un plan…", + "asking": "Te pose une question…", + "answer": "Rédige la réponse…", + "summaryOne": "Réflexion faite, {lookups} recherche, {seconds} s", + "summaryFew": "Réflexion faite, {lookups} recherches, {seconds} s", + "summaryOther": "Réflexion faite, {lookups} recherches, {seconds} s", + "summaryNoLookups": "Réflexion faite, {seconds} s", + "stop": { + "budget": "Budget atteint, répond avec ce qu'il a", + "time": "Temps écoulé, répond avec ce qu'il a", + "cap": "Beaucoup d'étapes, conclut maintenant", + "noProgress": "Plus de nouvelles données, conclut" + } + }, + "memory": { + "saved": "Retenu : {fact}", + "undo": "Annuler", + "accept": "Oui, retiens-le", + "decline": "Non", + "confirmed": "C'est noté, je m'en souviendrai." + }, + "plan": { + "accept": "Adopter ce plan", + "decline": "Pas maintenant", + "confirmed": "C'est noté. On fait le point ensemble dans {days} jours." + }, + "assumption": { + "line": "Hypothèse : {value}", + "change": "Modifier" + }, + "suggestedReplies": { + "groupLabel": "Réponses suggérées" + }, + "interim": { + "labelOne": "{title}, {window}, {count} mesure", + "labelFew": "{title}, {window}, {count} mesures", + "labelOther": "{title}, {window}, {count} mesures" + }, + "chart": { + "compare": { + "periods": "{a} contre {b}" + } + }, + "reasoning": { + "label": "Profondeur de réflexion", + "hint": "Réfléchir plus en profondeur donne des réponses plus précises, avec un peu plus d'attente.", + "off": "Désactivé", + "minimal": "Minimale", + "low": "Faible", + "medium": "Moyenne", + "high": "Élevée", + "capped": "(limité par l'administrateur)", + "disabled": "L'administrateur a désactivé la réflexion approfondie." + }, + "memoryLink": "Mémoire ({count})" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -6008,7 +6079,8 @@ "forgotError": "Impossible d'oublier cette information. Veuillez réessayer.", "clearedToast": "{count} informations enregistrées oubliées.", "clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer.", - "detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie." + "detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.", + "categoryMedication": "Médicaments" }, "coachReminders": { "title": "Tes rappels", @@ -6663,6 +6735,13 @@ "savedOn": "Coach activé.", "savedOff": "Coach masqué.", "saveError": "Impossible d enregistrer la modification. Veuillez réessayer." + }, + "memory": { + "edit": "Modifier", + "source": { + "user": "de toi", + "coach": "du Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7237,10 @@ "documentAi": { "title": "Lecture de documents", "description": "Lecture par IA des documents enregistrés, scan des comptes rendus de laboratoire et extraction de médicaments à partir de texte. La plus grande part des données qui quittent le serveur." + }, + "reasoning": { + "enabled": "Autoriser la réflexion approfondie", + "max": "Profondeur de réflexion maximale" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "Voir 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." + "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach.", + "changeAssumption": "À la place : {value}" }, "clarify": { "cardLabel": "Le Coach a une question", diff --git a/messages/it.json b/messages/it.json index 8a150e8a1..b4ec05570 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "{count} aree consultate", "info": "Dettagli della risposta", "infoModel": "Modello: {model}" - } + }, + "activity": { + "thinking": "Sta pensando…", + "fetching": "Recupera {domain}, {window}…", + "digestOne": "Riassume {count} valore…", + "digestFew": "Riassume {count} valori…", + "digestOther": "Riassume {count} valori…", + "digestDoneOne": "{count} valore da {areas}", + "digestDoneFew": "{count} valori da {areas}", + "digestDoneOther": "{count} valori da {areas}", + "areasOne": "{count} area", + "areasFew": "{count} aree", + "areasOther": "{count} aree", + "memoryOne": "Ricorda {count} cosa che gli hai detto", + "memoryFew": "Ricorda {count} cose che gli hai detto", + "memoryOther": "Ricorda {count} cose che gli hai detto", + "remember": "Memorizza qualcosa…", + "plan": "Prepara un piano…", + "asking": "Ti fa una domanda…", + "answer": "Scrive la risposta…", + "summaryOne": "Ci ha pensato, {lookups} ricerca, {seconds} s", + "summaryFew": "Ci ha pensato, {lookups} ricerche, {seconds} s", + "summaryOther": "Ci ha pensato, {lookups} ricerche, {seconds} s", + "summaryNoLookups": "Ci ha pensato, {seconds} s", + "stop": { + "budget": "Budget raggiunto, risponde con ciò che ha", + "time": "Tempo scaduto, risponde con ciò che ha", + "cap": "Molti passaggi, ora riassume", + "noProgress": "Nessun dato nuovo, riassume" + } + }, + "memory": { + "saved": "Memorizzato: {fact}", + "undo": "Annulla", + "accept": "Sì, ricordalo", + "decline": "No", + "confirmed": "Ricevuto, me lo ricorderò." + }, + "plan": { + "accept": "Adotta questo piano", + "decline": "Non ora", + "confirmed": "Annotato. Ne riparliamo insieme tra {days} giorni." + }, + "assumption": { + "line": "Ipotesi: {value}", + "change": "Cambia" + }, + "suggestedReplies": { + "groupLabel": "Risposte suggerite" + }, + "interim": { + "labelOne": "{title}, {window}, {count} valore", + "labelFew": "{title}, {window}, {count} valori", + "labelOther": "{title}, {window}, {count} valori" + }, + "chart": { + "compare": { + "periods": "{a} rispetto a {b}" + } + }, + "reasoning": { + "label": "Profondità di ragionamento", + "hint": "Pensare più a fondo significa risposte più precise e un po' più di attesa.", + "off": "Disattivato", + "minimal": "Minima", + "low": "Bassa", + "medium": "Media", + "high": "Alta", + "capped": "(limitato dall'amministratore)", + "disabled": "L'amministratore ha disattivato il ragionamento approfondito." + }, + "memoryLink": "Memoria ({count})" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -6008,7 +6079,8 @@ "forgotError": "Impossibile dimenticare questa informazione. Riprova.", "clearedToast": "Dimenticate {count} informazioni memorizzate.", "clearError": "Impossibile cancellare le informazioni memorizzate. Riprova.", - "detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi." + "detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.", + "categoryMedication": "Farmaci" }, "coachReminders": { "title": "I tuoi promemoria", @@ -6663,6 +6735,13 @@ "savedOn": "Coach attivato.", "savedOff": "Coach nascosto.", "saveError": "Impossibile salvare la modifica. Riprova." + }, + "memory": { + "edit": "Modifica", + "source": { + "user": "da te", + "coach": "dal Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7237,10 @@ "documentAi": { "title": "Lettura dei documenti", "description": "Lettura con IA dei documenti salvati, scansione dei referti di laboratorio ed estrazione dei farmaci dal testo. La quota maggiore dei dati che lasciano il server." + }, + "reasoning": { + "enabled": "Consenti ragionamento approfondito", + "max": "Profondità massima di ragionamento" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "Vedi anche: {metric}", "continue": "Continua a cercare", "settingLabel": "Suggerisci domande di approfondimento", - "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach." + "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach.", + "changeAssumption": "Invece: {value}" }, "clarify": { "cardLabel": "Il Coach ha una domanda", diff --git a/messages/ko.json b/messages/ko.json index 7c6d3661f..be44f2329 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "영역 {count}개 확인", "info": "답변 정보", "infoModel": "모델: {model}" - } + }, + "activity": { + "thinking": "생각하는 중…", + "fetching": "{domain} 가져오는 중, {window}…", + "digestOne": "값 {count}개 요약 중…", + "digestFew": "값 {count}개 요약 중…", + "digestOther": "값 {count}개 요약 중…", + "digestDoneOne": "{areas}에서 값 {count}개", + "digestDoneFew": "{areas}에서 값 {count}개", + "digestDoneOther": "{areas}에서 값 {count}개", + "areasOne": "{count}개 영역", + "areasFew": "{count}개 영역", + "areasOther": "{count}개 영역", + "memoryOne": "알려 준 내용 {count}개를 떠올리는 중", + "memoryFew": "알려 준 내용 {count}개를 떠올리는 중", + "memoryOther": "알려 준 내용 {count}개를 떠올리는 중", + "remember": "기억해 두는 중…", + "plan": "계획을 제안하는 중…", + "asking": "질문하는 중…", + "answer": "답변을 쓰는 중…", + "summaryOne": "생각 완료, 조회 {lookups}회, {seconds}초", + "summaryFew": "생각 완료, 조회 {lookups}회, {seconds}초", + "summaryOther": "생각 완료, 조회 {lookups}회, {seconds}초", + "summaryNoLookups": "생각 완료, {seconds}초", + "stop": { + "budget": "예산 도달, 지금까지의 내용으로 답변", + "time": "시간 제한 도달, 지금까지의 내용으로 답변", + "cap": "단계가 많아 이제 정리하는 중", + "noProgress": "새 데이터가 없어 정리하는 중" + } + }, + "memory": { + "saved": "기억함: {fact}", + "undo": "실행 취소", + "accept": "네, 기억해 줘", + "decline": "아니요", + "confirmed": "알겠어요, 기억해 둘게요." + }, + "plan": { + "accept": "이 계획 시작하기", + "decline": "나중에", + "confirmed": "기록했어요. {days}일 뒤에 함께 살펴볼게요." + }, + "assumption": { + "line": "가정: {value}", + "change": "변경" + }, + "suggestedReplies": { + "groupLabel": "추천 답변" + }, + "interim": { + "labelOne": "{title}, {window}, 값 {count}개", + "labelFew": "{title}, {window}, 값 {count}개", + "labelOther": "{title}, {window}, 값 {count}개" + }, + "chart": { + "compare": { + "periods": "{a} 대 {b}" + } + }, + "reasoning": { + "label": "생각 깊이", + "hint": "더 깊이 생각할수록 답변이 정확해지고 조금 더 기다리게 돼요.", + "off": "끄기", + "minimal": "최소", + "low": "낮음", + "medium": "중간", + "high": "높음", + "capped": "(관리자가 제한함)", + "disabled": "관리자가 깊은 생각을 꺼 두었어요." + }, + "memoryLink": "기억 ({count})" }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", @@ -6008,7 +6079,8 @@ "forgotError": "그 정보를 잊지 못했어요. 다시 시도해 주세요.", "clearedToast": "저장된 정보 {count}개를 잊었어요.", "clearError": "저장된 정보를 지우지 못했어요. 다시 시도해 주세요.", - "detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요." + "detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요.", + "categoryMedication": "약" }, "coachReminders": { "title": "내 알림", @@ -6663,6 +6735,13 @@ "savedOn": "코치를 켰어요.", "savedOff": "코치를 숨겼어요.", "saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요." + }, + "memory": { + "edit": "편집", + "source": { + "user": "내가 알려 줌", + "coach": "코치가 기억함" + } } }, "integrationRedirectGuide": { @@ -7137,6 +7216,10 @@ "documentAi": { "title": "문서 읽기", "description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다." + }, + "reasoning": { + "enabled": "깊은 생각 허용", + "max": "최대 생각 깊이" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "함께 보기: {metric}", "continue": "계속 찾아보기", "settingLabel": "후속 질문 추천", - "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요." + "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요.", + "changeAssumption": "대신: {value}" }, "clarify": { "cardLabel": "코치가 질문이 있어요", diff --git a/messages/pl.json b/messages/pl.json index c96054e1a..dfc1646b7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3146,7 +3146,78 @@ "areasDoneOther": "Sprawdzono {count} obszarów", "info": "Szczegóły odpowiedzi", "infoModel": "Model: {model}" - } + }, + "activity": { + "thinking": "Myśli…", + "fetching": "Pobiera {domain}, {window}…", + "digestOne": "Podsumowuje {count} pomiar…", + "digestFew": "Podsumowuje {count} pomiary…", + "digestOther": "Podsumowuje {count} pomiarów…", + "digestDoneOne": "{count} pomiar z: {areas}", + "digestDoneFew": "{count} pomiary z: {areas}", + "digestDoneOther": "{count} pomiarów z: {areas}", + "areasOne": "{count} obszar", + "areasFew": "{count} obszary", + "areasOther": "{count} obszarów", + "memoryOne": "Pamięta {count} rzecz, którą mu powiedziałeś", + "memoryFew": "Pamięta {count} rzeczy, które mu powiedziałeś", + "memoryOther": "Pamięta {count} rzeczy, które mu powiedziałeś", + "remember": "Zapamiętuje coś…", + "plan": "Przygotowuje plan…", + "asking": "Dopytuje…", + "answer": "Pisze odpowiedź…", + "summaryOne": "Przemyślane, {lookups} zapytanie, {seconds} s", + "summaryFew": "Przemyślane, {lookups} zapytania, {seconds} s", + "summaryOther": "Przemyślane, {lookups} zapytań, {seconds} s", + "summaryNoLookups": "Przemyślane, {seconds} s", + "stop": { + "budget": "Budżet wyczerpany, odpowiada na podstawie tego, co ma", + "time": "Limit czasu osiągnięty, odpowiada na podstawie tego, co ma", + "cap": "Wiele kroków, teraz podsumowuje", + "noProgress": "Brak nowych danych, podsumowuje" + } + }, + "memory": { + "saved": "Zapamiętane: {fact}", + "undo": "Cofnij", + "accept": "Tak, zapamiętaj", + "decline": "Nie", + "confirmed": "Jasne, zapamiętam." + }, + "plan": { + "accept": "Przyjmij ten plan", + "decline": "Nie teraz", + "confirmed": "Zanotowane. Wrócimy do tego razem za {days} dni." + }, + "assumption": { + "line": "Założenie: {value}", + "change": "Zmień" + }, + "suggestedReplies": { + "groupLabel": "Proponowane odpowiedzi" + }, + "interim": { + "labelOne": "{title}, {window}, {count} pomiar", + "labelFew": "{title}, {window}, {count} pomiary", + "labelOther": "{title}, {window}, {count} pomiarów" + }, + "chart": { + "compare": { + "periods": "{a} wobec {b}" + } + }, + "reasoning": { + "label": "Głębokość namysłu", + "hint": "Głębszy namysł oznacza dokładniejsze odpowiedzi i trochę dłuższe czekanie.", + "off": "Wyłączone", + "minimal": "Minimalna", + "low": "Niska", + "medium": "Średnia", + "high": "Wysoka", + "capped": "(ograniczone przez administratora)", + "disabled": "Administrator wyłączył głębszy namysł." + }, + "memoryLink": "Pamięć ({count})" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -6008,7 +6079,8 @@ "forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.", "clearedToast": "Zapomniano {count} zapisanych informacji.", "clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie.", - "detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał." + "detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.", + "categoryMedication": "Leki" }, "coachReminders": { "title": "Twoje przypomnienia", @@ -6663,6 +6735,13 @@ "savedOn": "Coach włączony.", "savedOff": "Coach ukryty.", "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." + }, + "memory": { + "edit": "Edytuj", + "source": { + "user": "od ciebie", + "coach": "od Coacha" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7237,10 @@ "documentAi": { "title": "Czytanie dokumentów", "description": "Czytanie zapisanych dokumentów przez AI, skanowanie wyników badań laboratoryjnych i odczytywanie leków z tekstu. Największa część danych opuszczających serwer." + }, + "reasoning": { + "enabled": "Zezwalaj na głębszy namysł", + "max": "Największa głębokość namysłu" } }, "modules": { @@ -9933,7 +10016,8 @@ "relatedMetric": "Sprawdź też: {metric}", "continue": "Szukaj dalej", "settingLabel": "Proponuj kolejne pytania", - "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha." + "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha.", + "changeAssumption": "Zamiast tego: {value}" }, "clarify": { "cardLabel": "Coach ma pytanie", diff --git a/prisma/migrations/0376_ai_reasoning_controls/migration.sql b/prisma/migrations/0376_ai_reasoning_controls/migration.sql new file mode 100644 index 000000000..54c6f36cc --- /dev/null +++ b/prisma/migrations/0376_ai_reasoning_controls/migration.sql @@ -0,0 +1,6 @@ +-- The operator's reasoning controls on the instance row. Both columns carry a +-- default, so the singleton row reads "reasoning allowed, no cap" the moment +-- the migration lands and an upgrade needs no admin action. +ALTER TABLE "app_settings" + ADD COLUMN "ai_reasoning_enabled" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "ai_reasoning_max_effort" TEXT NOT NULL DEFAULT 'high'; diff --git a/prisma/migrations/0377_coach_message_trail/migration.sql b/prisma/migrations/0377_coach_message_trail/migration.sql new file mode 100644 index 000000000..a3fe696d9 --- /dev/null +++ b/prisma/migrations/0377_coach_message_trail/migration.sql @@ -0,0 +1,4 @@ +-- The model-written text of a Coach turn's live trail, encrypted like the +-- message itself. Nullable and without a default: every existing turn has no +-- trail, and adding a nullable column rewrites no rows. +ALTER TABLE "coach_messages" ADD COLUMN "trail_encrypted" BYTEA; diff --git a/prisma/migrations/0378_coach_fact_source/migration.sql b/prisma/migrations/0378_coach_fact_source/migration.sql new file mode 100644 index 000000000..f79dab9b8 --- /dev/null +++ b/prisma/migrations/0378_coach_fact_source/migration.sql @@ -0,0 +1,8 @@ +-- Where a Coach fact came from, when it was last used in a turn, and the +-- message it came out of. Every row written before this migration came from +-- the background extraction or the deterministic matcher, which could not be +-- told apart, so they all read as `extracted`. +ALTER TABLE "coach_facts" + ADD COLUMN "source" TEXT NOT NULL DEFAULT 'extracted', + ADD COLUMN "last_used_at" TIMESTAMP(3), + ADD COLUMN "source_message_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5b691e19..dcd081188 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4950,6 +4950,17 @@ model AppSettings { assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled") + // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches + // provider-side reasoning off for every surface (the Coach and the three + // background jobs that use it); `aiReasoningMaxEffort` caps the level a + // person may pick (`low` | `medium` | `high`, an app-side closed enum in + // `src/lib/ai/reasoning/levels.ts`). Resolved once per turn or job by + // `src/lib/ai/reasoning/resolve.ts` and published per record on + // `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no + // cap, so an upgrade needs no admin action. + aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled") + aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort") + // v1.18.0 — operator-level server-wide module availability. A DISABLED // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / // empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY @@ -6435,6 +6446,13 @@ model CoachMessage { // 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") + // v1.41 — the model-written text of the turn's live trail (JSON + // `CoachTrail`, at most 16 KB): reasoning titles and texts per round, the + // facts recalled into the turn, the text of a fact proposal. AES-256-GCM + // like `encryptedContent`. The trail's plaintext metadata (phase, status, + // round, catalog label, duration, count, stop reason) rides + // `metricSourceJson.activity`. Owner-only; NULL on every turn without one. + trailEncrypted Bytes? @map("trail_encrypted") createdAt DateTime @default(now()) @map("created_at") conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@ -6477,6 +6495,18 @@ model CoachFact { updatedAt DateTime @updatedAt @map("updated_at") // Soft-delete: hides the fact from injection but keeps the row for audit. deletedAt DateTime? @map("deleted_at") + // v1.41 — who put the fact here, an app-side closed enum + // (`COACH_FACT_SOURCES` in `src/lib/ai/coach/memory/contract.ts`): `user` + // (the remember button, or a proposal the person confirmed), `coach` (saved + // during a turn), `extracted` (the background extraction, and every row + // written before the column existed), `pattern` (the deterministic matcher). + source String @default("extracted") + // v1.41 — when the fact last went into a Coach turn; NULL until it has. + lastUsedAt DateTime? @map("last_used_at") + // v1.41 — the message the fact came out of, for "undo" and "from this + // conversation". A bare id like `sourceConversationId`, not a relation, so + // deleting a conversation does not take the fact with it. + sourceMessageId String? @map("source_message_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index ffc137f30..6e28c1d91 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -462,8 +462,8 @@ async function main() { } // ───── Coach (Bytes columns) ───── - // "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted" - // "factEncrypted" + // "encryptedContent" "resultsEncrypted" "trailEncrypted" "summaryEncrypted" + // "titleEncrypted" "factEncrypted" results.push( await rotateBytesColumn( "CoachMessage", @@ -480,6 +480,15 @@ async function main() { prisma.coachMessage, ), ); + // v1.41 — the model text of the turn's trail. NULL on every turn without + // one, which `rotateBytesColumn` skips. + results.push( + await rotateBytesColumn( + "CoachMessage", + "trailEncrypted", + 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 index 8235fb064..2276ef55b 100644 --- a/src/__tests__/coach-stream-ios-compat.test.ts +++ b/src/__tests__/coach-stream-ios-compat.test.ts @@ -10,7 +10,8 @@ * 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`). + * (`step`, `result`, `followUps`, `clarification`, and since v1.41 + * `activity`, `memoryNote` with `note`, `planProposal` with `proposal`). * * 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 @@ -81,6 +82,9 @@ describe("Coach stream frames stay decodable by the shipped native client", () = "result", "followUps", "clarification", + "activity", + "memoryNote", + "planProposal", ]), ); expect( diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx index 9c9064f7c..f8ce173dc 100644 --- a/src/components/insights/coach-panel/result-chart.tsx +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -122,6 +122,9 @@ export function CoachResultChart({ if (spec.kind === "histogram") { return ; } + // v1.41 — the contract is in place; no table carries this kind yet, and the + // table view stays available until the chart lands. + if (spec.kind === "compare") return null; if (spec.kind === "bar" && spec.orientation === "horizontal") { return ( { + const out = new Map(); + const walk = (node: unknown, prefix: string) => { + if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + walk(value, prefix ? `${prefix}.${key}` : key); + } + } else { + out.set(prefix, node); + } + }; + walk( + JSON.parse( + readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), + ) as unknown, + "", + ); + return out; +} + +/** 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_ACTIVITY_KEYS), + ...Object.values(COACH_ACTIVITY_STOP_KEYS), + ...Object.values(COACH_MEMORY_KEYS), + ...Object.values(COACH_PLAN_KEYS), + ...Object.values(COACH_MEMORY_LIST_KEYS), + ...Object.values(COACH_ASSUMPTION_KEYS), + ...Object.values(COACH_SUGGESTED_REPLIES_KEYS), + ...Object.values(COACH_CHART_COMPARE_KEYS), + ...Object.values(REASONING_LEVEL_LABEL_KEYS), + ...Object.values(REASONING_SETTING_KEYS), + ...Object.values(REASONING_ADMIN_KEYS), + ]); + for (const locale of locales) { + for (const count of COUNTS) { + keys.add(activityDigestKey(count, locale)); + keys.add(activityDigestDoneKey(count, locale)); + keys.add(activityAreasKey(count, locale)); + keys.add(activityMemoryKey(count, locale)); + keys.add(activitySummaryKey(count, locale)); + keys.add(interimLabelKey(count, locale)); + } + } + return keys; +} + +const OWNED = [ + "insights.coach.activity", + "insights.coach.memory", + "insights.coach.plan", + "insights.coach.assumption", + "insights.coach.suggestedReplies", + "insights.coach.interim", + "insights.coach.chart", + "insights.coach.reasoning", + "settings.coach.memory", + "admin.assistant.reasoning", +]; + +const placeholders = (text: string) => + [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort(); + +describe("v1.41 contract keys", () => { + const keys = catalogKeys(); + const en = strings("en"); + + it("produces the whole catalog", () => { + // Every key laid down for the release but the `change_assumption` chip + // label, which the dialog catalog's own test covers. + expect(keys.size).toBe(57); + }); + + it.each(locales)("every catalog key resolves in %s", (locale) => { + const bundle = strings(locale); + const missing = [...keys].filter( + (key) => typeof bundle.get(key) !== "string" || bundle.get(key) === "", + ); + expect(missing).toEqual([]); + }); + + it.each(locales)("%s keeps the English placeholders", (locale) => { + const bundle = strings(locale); + const drifted = [...keys].filter( + (key) => + placeholders(String(bundle.get(key))).join() !== + placeholders(String(en.get(key))).join(), + ); + expect(drifted).toEqual([]); + }); + + it("every leaf under the owned namespaces is reachable from a catalog", () => { + const owned = [...en.keys()].filter((key) => + OWNED.some((ns) => key.startsWith(`${ns}.`)), + ); + expect(owned.length).toBeGreaterThan(40); + expect(owned.filter((key) => !keys.has(key))).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/stream-events-ids.test.ts b/src/lib/ai/coach/__tests__/stream-events-ids.test.ts index d2a98ff6e..f164881d4 100644 --- a/src/lib/ai/coach/__tests__/stream-events-ids.test.ts +++ b/src/lib/ai/coach/__tests__/stream-events-ids.test.ts @@ -5,11 +5,15 @@ */ import { describe, expect, it } from "vitest"; +import { ACTIVITY_MAX_ENTRIES } from "../activity/contract"; import { + coachActivityMetaSchema, coachClarificationSchema, coachFollowUpSchema, + coachMemoryNoteMetaSchema, coachResultMetaSchema, coachStepSchema, + coachTrailSchema, } from "../stream-events"; const STEP = { @@ -107,3 +111,86 @@ describe("stored dialog ids", () => { ).toBe(false); }); }); + +describe("v1.41 ids", () => { + const ACTIVITY = { + id: "a1", + phase: "fetch", + status: "done", + round: 1, + labelKey: "insights.coach.activity.fetching", + label: "Fetching blood pressure, last 30 days…", + stepRef: "s7", + }; + + it("takes eight tables a message and no ninth", () => { + expect( + coachResultMetaSchema.safeParse({ ...META, ref: "r8" }).success, + ).toBe(true); + expect( + coachResultMetaSchema.safeParse({ ...META, ref: "r9" }).success, + ).toBe(false); + expect( + coachFollowUpSchema.safeParse({ + ...CHIP, + anchor: { ref: "r8", domain: "bp" }, + }).success, + ).toBe(true); + }); + + it("numbers trail entries a1 up to the cap", () => { + expect(coachActivityMetaSchema.safeParse(ACTIVITY).success).toBe(true); + expect( + coachActivityMetaSchema.safeParse({ + ...ACTIVITY, + id: `a${ACTIVITY_MAX_ENTRIES}`, + }).success, + ).toBe(true); + expect( + coachActivityMetaSchema.safeParse({ + ...ACTIVITY, + id: `a${ACTIVITY_MAX_ENTRIES + 1}`, + }).success, + ).toBe(false); + }); + + it("refuses anything that could carry text into a prompt", () => { + expect( + coachActivityMetaSchema.safeParse({ ...ACTIVITY, id: PLANT }).success, + ).toBe(false); + expect( + coachActivityMetaSchema.safeParse({ ...ACTIVITY, stepRef: PLANT }) + .success, + ).toBe(false); + expect( + coachTrailSchema.safeParse({ entries: [{ id: PLANT, title: "t" }] }) + .success, + ).toBe(false); + expect( + coachMemoryNoteMetaSchema.safeParse({ + proposal: false, + factId: PLANT, + category: "goal", + }).success, + ).toBe(false); + expect( + coachClarificationSchema.safeParse({ + kind: "goal", + freeText: true, + assumption: PLANT, + choices: [ + { id: "c1", labelKey: "k", label: "l", value: { goal: "plan1" } }, + ], + }).success, + ).toBe(false); + expect( + coachClarificationSchema.safeParse({ + kind: "goal", + freeText: true, + choices: [ + { id: "c1", labelKey: "k", label: "l", value: { goal: PLANT } }, + ], + }).success, + ).toBe(false); + }); +}); 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 index bec015176..7305bccf9 100644 --- a/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts +++ b/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts @@ -15,26 +15,44 @@ import { describe, expectTypeOf, it } from "vitest"; import type { z } from "zod/v4"; import type { + CoachActivity, + CoachActivityMeta, + CoachAssumption, CoachClarification, CoachFollowUp, + CoachMemoryNote, + CoachMemoryNoteMeta, + CoachPlanProposal, + CoachPlanProposalMeta, CoachMethod, CoachProvenance, CoachResultEntry, CoachResultMeta, CoachResultTable, CoachStep, + CoachStop, CoachStreamEvent, + CoachTrail, } from "@/lib/ai/coach/types"; import { + coachActivityMetaSchema, + coachActivitySchema, + coachAssumptionSchema, coachClarificationSchema, coachFollowUpSchema, + coachMemoryNoteMetaSchema, + coachMemoryNoteSchema, + coachPlanProposalMetaSchema, + coachPlanProposalSchema, coachMethodSchema, coachProvenanceSchema, coachResultEntrySchema, coachResultMetaSchema, coachResultTableSchema, coachStepSchema, + coachStopSchema, coachStreamEventSchema, + coachTrailSchema, } from "@/lib/ai/coach/stream-events"; describe("Coach wire: Zod mirror equals the TypeScript contract", () => { @@ -68,4 +86,32 @@ describe("Coach wire: Zod mirror equals the TypeScript contract", () => { z.infer >().toEqualTypeOf(); }); + + it("the v1.41 trail, memory and plan shapes", () => { + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + 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(); + }); }); diff --git a/src/lib/ai/coach/activity/__tests__/contract.test.ts b/src/lib/ai/coach/activity/__tests__/contract.test.ts new file mode 100644 index 000000000..12e69aab9 --- /dev/null +++ b/src/lib/ai/coach/activity/__tests__/contract.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + ACTIVITY_TEXT_MAX_CHARS, + ACTIVITY_TITLE_MAX_CHARS, + TRAIL_MAX_BYTES, + activitySummaryKey, + createNoopActivityRecorder, +} from "../contract"; + +describe("activity contract", () => { + it("the no-op recorder sends and stores nothing", () => { + const recorder = createNoopActivityRecorder(); + const id = recorder.start({ + phase: "thinking", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + }); + recorder.update(id, { title: "**Checking the trend**" }); + recorder.finish(id, "done", { text: "Looked at the last month." }); + expect(recorder.meta()).toEqual([]); + expect(recorder.trail()).toBeNull(); + }); + + it("a full trail of model text fits its stored bound", () => { + // 99 entries can never all carry text: the bound is what a turn stores, + // and the recorder trims to it. A dozen rounds of full text must fit. + const twelveRounds = JSON.stringify({ + entries: Array.from({ length: 12 }, (_, i) => ({ + id: `a${i + 1}`, + title: "t".repeat(ACTIVITY_TITLE_MAX_CHARS), + text: "x".repeat(ACTIVITY_TEXT_MAX_CHARS), + })), + }); + expect(new TextEncoder().encode(twelveRounds).byteLength).toBeLessThan( + TRAIL_MAX_BYTES, + ); + }); + + it("says nothing about lookups on a turn that made none", () => { + expect(activitySummaryKey(0, "en")).toBe( + "insights.coach.activity.summaryNoLookups", + ); + expect(activitySummaryKey(1, "en")).toBe( + "insights.coach.activity.summaryOne", + ); + expect(activitySummaryKey(3, "pl")).toBe( + "insights.coach.activity.summaryFew", + ); + }); +}); diff --git a/src/lib/ai/coach/activity/contract.ts b/src/lib/ai/coach/activity/contract.ts new file mode 100644 index 000000000..c7eb983f1 --- /dev/null +++ b/src/lib/ai/coach/activity/contract.ts @@ -0,0 +1,143 @@ +/** + * v1.41 — the contract of a turn's live trail. + * + * The loop reports what it is doing through an {@link ActivityRecorder}: it + * opens an entry before each phase starts (the thinking round before the + * provider call, each fetch before its tool runs), updates it while it runs + * (a reasoning title arriving, a count) and closes it. The recorder sends the + * `activity` frames, screens model text before it goes anywhere, and at the + * end hands back the plaintext metadata for `metricSource.activity` and the + * model text for `coach_messages.trail_encrypted`. + * + * Labels are always catalog text rendered on the server; model text only ever + * rides `title` and `text`. A `checkpoint` entry (assistant text that came + * beside tool calls) is labelled with the thinking key and carries the + * screened sentence as its title. + * + * The no-op recorder is what every caller holds until the real one is wired: + * it sends nothing and stores nothing, so a turn behaves exactly as before. + * + * Client-safe: no server import. + */ +import type { + CoachActivityMeta, + CoachActivityPhase, + CoachStepStatus, + CoachStopReason, + CoachTrail, +} from "@/lib/ai/coach/types"; +import type { Locale } from "@/lib/i18n/config"; +import { pluralKey } from "@/lib/i18n/plural"; + +/** The stored trail text of one message, as UTF-8 JSON, before encryption. */ +export const TRAIL_MAX_BYTES = 16 * 1024; + +/** A reasoning title or checkpoint sentence, after screening. */ +export const ACTIVITY_TITLE_MAX_CHARS = 80; + +/** A reasoning summary per round, after screening. */ +export const ACTIVITY_TEXT_MAX_CHARS = 400; + +/** `a1`..`a99`: the 100th entry of a turn is not recorded. */ +export const ACTIVITY_MAX_ENTRIES = 99; + +export interface ActivityStart { + phase: CoachActivityPhase; + round: number; + labelKey: string; + label: string; + stepRef?: string; + count?: number; + stop?: CoachStopReason; +} + +export interface ActivityPatch { + /** Model text; screened by the recorder, dropped when it fails. */ + title?: string; + /** Model text; screened by the recorder, dropped when it fails. */ + text?: string; + /** A new catalog label (the digest count once the round has settled). */ + labelKey?: string; + label?: string; + count?: number; +} + +export interface ActivityRecorder { + /** Opens an entry, sends its `running` frame and returns its id. */ + start(entry: ActivityStart): string; + /** Updates an open entry and sends the frame again. */ + update(id: string, patch: ActivityPatch): void; + /** Closes an entry with its final status and duration. */ + finish( + id: string, + status: Exclude, + patch?: ActivityPatch, + ): void; + /** The entries so far, for `metricSource.activity`. */ + meta(): CoachActivityMeta[]; + /** The screened model text so far, or null when there is none. */ + trail(): CoachTrail | null; +} + +/** Sends nothing, stores nothing: a turn runs exactly as it did before. */ +export function createNoopActivityRecorder(): ActivityRecorder { + return { + start: () => "a1", + update: () => undefined, + finish: () => undefined, + meta: () => [], + trail: () => null, + }; +} + +// ── Message keys ──────────────────────────────────────────────────────────── + +/** The labels with no count. */ +export const COACH_ACTIVITY_KEYS = { + thinking: "insights.coach.activity.thinking", + fetching: "insights.coach.activity.fetching", + remember: "insights.coach.activity.remember", + plan: "insights.coach.activity.plan", + asking: "insights.coach.activity.asking", + answer: "insights.coach.activity.answer", +} as const; + +/** The `stop` entry's label, by reason. */ +export const COACH_ACTIVITY_STOP_KEYS: Readonly< + Record +> = { + budget: "insights.coach.activity.stop.budget", + time: "insights.coach.activity.stop.time", + cap: "insights.coach.activity.stop.cap", + no_progress: "insights.coach.activity.stop.noProgress", +}; + +/** "Summarising N readings…", while a round's results are folded in. */ +export function activityDigestKey(count: number, locale: Locale): string { + return pluralKey("insights.coach.activity.digest", count, locale); +} + +/** "N readings from {areas}", once the round has settled. */ +export function activityDigestDoneKey(count: number, locale: Locale): string { + return pluralKey("insights.coach.activity.digestDone", count, locale); +} + +/** "N areas", the `{areas}` of the settled digest. */ +export function activityAreasKey(count: number, locale: Locale): string { + return pluralKey("insights.coach.activity.areas", count, locale); +} + +/** "Recalls N things you told it". */ +export function activityMemoryKey(count: number, locale: Locale): string { + return pluralKey("insights.coach.activity.memory", count, locale); +} + +/** + * The one-line summary once the turn is done: "Thought it through, N lookups, + * S s", or the seconds alone on a turn that looked nothing up. + */ +export function activitySummaryKey(lookups: number, locale: Locale): string { + return lookups === 0 + ? "insights.coach.activity.summaryNoLookups" + : pluralKey("insights.coach.activity.summary", lookups, locale); +} diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts index e08694154..6265772e2 100644 --- a/src/lib/ai/coach/dialog-keys.ts +++ b/src/lib/ai/coach/dialog-keys.ts @@ -231,6 +231,7 @@ export const COACH_FOLLOW_UP_KEYS: Readonly> = as_table: "coach.followUp.asTable", related_metric: "coach.followUp.relatedMetric", continue: "coach.followUp.continue", + change_assumption: "coach.followUp.changeAssumption", }; export const COACH_FOLLOW_UP_UI_KEYS = { @@ -253,6 +254,29 @@ export function clarifyWindowLabelKey(window: CoachScopeWindow): string { return `coach.clarify.window.${window}`; } +// ── Replies, assumptions, interim tables (v1.41) ────────────────────────── + +/** The group of reply pills under the latest answer. */ +export const COACH_SUGGESTED_REPLIES_KEYS = { + groupLabel: "insights.coach.suggestedReplies.groupLabel", +} as const; + +/** "Assumed: last 30 days. Change". */ +export const COACH_ASSUMPTION_KEYS = { + line: "insights.coach.assumption.line", + change: "insights.coach.assumption.change", +} as const; + +/** The one-line preview of a table read while the turn still runs. */ +export function interimLabelKey(count: number, locale: Locale): string { + return pluralKey("insights.coach.interim.label", count, locale); +} + +/** The legend of a chart comparing two periods ("{a} vs {b}"). */ +export const COACH_CHART_COMPARE_KEYS = { + periods: "insights.coach.chart.compare.periods", +} as const; + // ── Reuse ───────────────────────────────────────────────────────────────── /** The prose of a turn answered from a stored table without a model call. */ diff --git a/src/lib/ai/coach/follow-ups/resolve.ts b/src/lib/ai/coach/follow-ups/resolve.ts index 44c4988f4..a65956ecc 100644 --- a/src/lib/ai/coach/follow-ups/resolve.ts +++ b/src/lib/ai/coach/follow-ups/resolve.ts @@ -129,6 +129,9 @@ export function followUpContextHint( case "continue": // Answered with its own context lines (`continue.ts`). return null; + case "change_assumption": + // v1.41 — the contract is in place; no chip of this kind is offered yet. + return null; } } diff --git a/src/lib/ai/coach/memory/__tests__/contract.test.ts b/src/lib/ai/coach/memory/__tests__/contract.test.ts new file mode 100644 index 000000000..f470c0116 --- /dev/null +++ b/src/lib/ai/coach/memory/__tests__/contract.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { COACH_MEMORY_CATEGORIES } from "@/lib/ai/coach/types"; + +import { + COACH_FACT_SOURCES, + HEALTH_MEMORY_CATEGORIES, + MEMORY_BLOCK_MAX_CHARS, + buildMemoryContextBlock, + buildPlanProgressLines, + decideFactProposal, + decidePlanProposal, + proposePlanFromTool, + rememberFactFromTool, +} from "../contract"; + +const IDS = { userId: "u1", conversationId: "c1" }; + +describe("memory contract stubs", () => { + it("build no block, write nothing and decide nothing", async () => { + await expect( + buildMemoryContextBlock({ ...IDS, locale: "en" }), + ).resolves.toBeNull(); + await expect( + rememberFactFromTool({ + ...IDS, + userMessage: "I want to reach 75 kg by December", + call: { category: "goal", fact: "Wants 75 kg by December", why: "w" }, + }), + ).resolves.toEqual({ kind: "declined", reason: "unavailable" }); + await expect( + decideFactProposal({ + ...IDS, + messageId: "m1", + proposalId: "p1", + accept: true, + }), + ).resolves.toEqual({ kind: "stale" }); + await expect( + proposePlanFromTool({ + ...IDS, + call: { + metric: "WEIGHT", + ifCue: "after dinner", + thenAction: "walk", + reviewInDays: 14, + }, + }), + ).resolves.toEqual({ kind: "declined", reason: "unavailable" }); + await expect( + decidePlanProposal({ + ...IDS, + messageId: "m1", + planId: "p1", + accept: true, + }), + ).resolves.toEqual({ kind: "stale" }); + await expect(buildPlanProgressLines("u1")).resolves.toEqual([]); + }); +}); + +describe("memory contract constants", () => { + it("health categories are a subset of the memory categories", () => { + for (const category of HEALTH_MEMORY_CATEGORIES) { + expect(COACH_MEMORY_CATEGORIES).toContain(category); + } + expect([...HEALTH_MEMORY_CATEGORIES].sort()).toEqual([ + "condition", + "constraint", + "medication", + ]); + }); + + it("the column default is one of the sources", () => { + const schema = readFileSync( + join(__dirname, "../../../../../../prisma/schema.prisma"), + "utf8", + ); + const model = schema.slice(schema.indexOf("model CoachFact {")); + const match = /source\s+String\s+@default\("(\w+)"\)/.exec(model); + expect(match?.[1]).toBeDefined(); + expect(COACH_FACT_SOURCES).toContain(match?.[1]); + }); + + it("keeps the memory block small next to the inventory", () => { + expect(MEMORY_BLOCK_MAX_CHARS).toBeLessThanOrEqual(1_500); + }); +}); diff --git a/src/lib/ai/coach/memory/contract.ts b/src/lib/ai/coach/memory/contract.ts new file mode 100644 index 000000000..0bf210022 --- /dev/null +++ b/src/lib/ai/coach/memory/contract.ts @@ -0,0 +1,205 @@ +/** + * v1.41 — the contract of the Coach's memory inside a turn. + * + * The loop and the chat route reach facts and plans only through these + * functions: the memory block that goes into every turn, the `remember_fact` + * and `propose_plan` tools, the two decisions a person answers with a tap + * (`memoryDecision`, `planDecision` on the chat request), and the progress + * lines the daily briefing quotes. + * + * Until the implementations land, every function is a stub that answers "not + * available" and touches nothing: no block is built, no fact is written, no + * plan changes state. A turn behaves exactly as it did before. + * + * Rules every implementation keeps, so a caller can rely on them: + * - health facts (`condition`, `constraint`, `medication`) are only ever + * proposed, never saved without the person's tap; + * - a fact comes from the person's current message, never from a document or + * a quote; + * - the block is built only after the turn has passed its egress check, and + * never for a read-only sharing viewer; + * - fact and plan text never reaches a log or `annotate()`; counts and ids do. + */ +import type { + CoachMemoryCategory, + CoachMemoryNote, + CoachPlanProposal, +} from "@/lib/ai/coach/types"; + +/** Who put a fact into `coach_facts.source`. */ +export const COACH_FACT_SOURCES = [ + "user", + "coach", + "extracted", + "pattern", +] as const; + +/** The categories that are proposed and never saved without a tap. */ +export const HEALTH_MEMORY_CATEGORIES: ReadonlySet = + new Set(["condition", "constraint", "medication"]); + +/** A remembered fact, as `remember_fact` may write it. */ +export const REMEMBER_FACT_MAX_CHARS = 160; + +/** The memory block in a turn's context, never trimmed before the inventory. */ +export const MEMORY_BLOCK_MAX_CHARS = 1_500; + +/** A proposed plan's review window, in days. */ +export const PLAN_REVIEW_DAYS = { min: 7, max: 56 } as const; + +// ── The memory block ─────────────────────────────────────────────────────── + +export interface MemoryContextBlock { + /** The fenced `WHAT YOU KNOW ABOUT THIS PERSON` section; data, not orders. */ + text: string; + /** The facts in the block, for `last_used_at` and the `memory` entry. */ + factIds: string[]; + planIds: string[]; +} + +/** + * The facts and active plans a turn starts with, or null when there are none + * or memory is unavailable. The caller has already passed the egress check. + */ +export async function buildMemoryContextBlock(_args: { + userId: string; + conversationId: string | undefined; + locale: string; +}): Promise { + return null; +} + +// ── remember_fact ────────────────────────────────────────────────────────── + +export interface RememberFactCall { + category: CoachMemoryCategory; + /** At most {@link REMEMBER_FACT_MAX_CHARS}. */ + fact: string; + why: string; +} + +export type RememberFactOutcome = + | { kind: "saved"; note: CoachMemoryNote } + | { kind: "proposed"; note: CoachMemoryNote } + | { + kind: "declined"; + reason: + | "duplicate" + | "not_from_message" + | "one_per_answer" + | "invalid" + | "unavailable"; + }; + +/** + * Runs a `remember_fact` call. `userMessage` is the person's current message, + * the only text a fact may come from. + */ +export async function rememberFactFromTool(_args: { + userId: string; + conversationId: string; + userMessage: string; + call: RememberFactCall; +}): Promise { + return { kind: "declined", reason: "unavailable" }; +} + +/** + * Answers a fact proposal. The fact is read from the trail stored on + * `messageId`, never from the request. + */ +export async function decideFactProposal(_args: { + userId: string; + conversationId: string; + messageId: string; + proposalId: string; + accept: boolean; +}): Promise< + { kind: "saved"; factId: string } | { kind: "declined" } | { kind: "stale" } +> { + return { kind: "stale" }; +} + +// ── propose_plan ─────────────────────────────────────────────────────────── + +export interface ProposePlanCall { + metric: string; + target?: string; + ifCue: string; + thenAction: string; + /** {@link PLAN_REVIEW_DAYS}. */ + reviewInDays: number; +} + +export type ProposePlanOutcome = + | { kind: "proposed"; proposal: CoachPlanProposal } + | { + kind: "declined"; + reason: "one_per_answer" | "too_many_open" | "invalid" | "unavailable"; + }; + +/** Runs a `propose_plan` call: writes the plan as `proposed`. */ +export async function proposePlanFromTool(_args: { + userId: string; + conversationId: string; + call: ProposePlanCall; +}): Promise { + return { kind: "declined", reason: "unavailable" }; +} + +/** + * Answers a plan proposal: `active` on accept, `abandoned` on decline. Only a + * plan the assistant message proposed, and only the person's own. + */ +export async function decidePlanProposal(_args: { + userId: string; + conversationId: string; + messageId: string; + planId: string; + accept: boolean; +}): Promise< + | { kind: "activated"; reviewInDays: number } + | { kind: "abandoned" } + | { kind: "stale" } +> { + return { kind: "stale" }; +} + +// ── The briefing ─────────────────────────────────────────────────────────── + +/** + * One server-computed progress sentence per active plan, at most two, for + * the daily briefing prompt. The model may quote one; it may not invent one. + */ +export async function buildPlanProgressLines( + _userId: string, +): Promise { + return []; +} + +// ── Message keys ──────────────────────────────────────────────────────────── + +/** The memory note under an answer, and its two replies. */ +export const COACH_MEMORY_KEYS = { + saved: "insights.coach.memory.saved", + undo: "insights.coach.memory.undo", + accept: "insights.coach.memory.accept", + decline: "insights.coach.memory.decline", + confirmed: "insights.coach.memory.confirmed", +} as const; + +/** A plan proposal's two replies, and the line a decision turn answers with. */ +export const COACH_PLAN_KEYS = { + accept: "insights.coach.plan.accept", + decline: "insights.coach.plan.decline", + confirmed: "insights.coach.plan.confirmed", +} as const; + +/** The memory list in settings, and the link to it from the Coach. */ +export const COACH_MEMORY_LIST_KEYS = { + link: "insights.coach.memoryLink", + edit: "settings.coach.memory.edit", + sourceUser: "settings.coach.memory.source.user", + sourceCoach: "settings.coach.memory.source.coach", + categoryMedication: "settings.ai.coachMemory.categoryMedication", +} as const; diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index 20bdc60fc..dd8749ccc 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -14,12 +14,21 @@ import { z } from "zod/v4"; import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types"; +import { + ACTIVITY_TEXT_MAX_CHARS, + ACTIVITY_TITLE_MAX_CHARS, +} from "@/lib/ai/coach/activity/contract"; +import { + PLAN_REVIEW_DAYS, + REMEMBER_FACT_MAX_CHARS, +} from "@/lib/ai/coach/memory/contract"; import { SUGGESTED_ACTION_TYPES, type CheckupIntervalId, } from "@/lib/ai/coach/suggest-action"; import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions"; import { + COACH_MEMORY_CATEGORIES, coachKeyValueSchema, coachScopeSourceSchema, coachScopeWindowSchema, @@ -55,8 +64,8 @@ const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]); // them are written back into the model's context (`m.r` lines, the // "keep looking" fetched list). Each is held to its exact server-minted // shape, so a stored row edited by hand cannot carry text into a prompt. -/** A table of a message: `r1`..`r6`. */ -const resultRefSchema = z.string().regex(/^r[1-6]$/); +/** A table of a message: `r1`..`r8`. */ +const resultRefSchema = z.string().regex(/^r[1-8]$/); /** A server-minted row id (cuid). */ const messageIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); @@ -67,7 +76,7 @@ export const coachStepSchema = z id: z .string() .regex(/^s[1-9]\d{0,2}$/) - .describe("`s1`..`s12`, unique within a turn."), + .describe("`s1`..`s48`, unique within a turn."), tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]), labelKey: z .string() @@ -126,6 +135,18 @@ const coachChartSpecSchema = z.discriminatedUnion("kind", [ x: z.string(), series: z.array(z.string()), }), + z + .object({ + kind: z.literal("compare"), + mode: z.enum(["periods", "metrics"]), + x: z.string(), + a: z.string(), + b: z.string(), + axes: z.union([z.literal(1), z.literal(2)]), + }) + .describe( + "v1.41 — two series on one chart: two periods overlaid (`periods`, one axis) or two metrics (`metrics`, one or two axes). `a` and `b` name the value columns, `x` the shared column.", + ), z.object({ kind: z.literal("bar"), x: z.string(), @@ -151,13 +172,13 @@ const coachResultSourceSchema = z.object({ }); const coachResultMetaShape = { - ref: resultRefSchema.describe("`r1`..`r6`, unique within a message."), + ref: resultRefSchema.describe("`r1`..`r8`, 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(), + chartKind: z.enum(["line", "compare", "bar", "histogram"]).nullable(), displayed: z .boolean() .describe( @@ -245,6 +266,50 @@ export const coachMethodSchema = z "How an answer was reached: which sources, windows, counts and aggregation. Never a health value.", }); +// ── Choices and assumptions ─────────────────────────────────────────────── + +/** An id the server minted for a plan, a fact or a record event. */ +const choiceIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); + +const coachChoiceValueSchema = z + .object({ + metric: coachScopeSourceSchema.optional(), + window: coachWindowEnum.optional(), + comparison: z + .enum(["previous_period", "year_ago", "baseline_90d"]) + .optional() + .describe("v1.41 — what a comparison is drawn against."), + goal: choiceIdSchema + .optional() + .describe("v1.41 — an active plan or a goal fact, by id."), + anchor: choiceIdSchema + .optional() + .describe( + "v1.41 — a medication start, an illness episode or a cycle phase, by id.", + ), + }) + .describe("What a choice stands for: catalog tokens and ids, never text."); + +const coachAssumptionKindSchema = z.enum(["metric", "window", "comparison"]); + +const coachAssumptionOptionSchema = z.object({ + labelKey: z.string(), + label: z.string(), + value: coachChoiceValueSchema, +}); + +export const coachAssumptionSchema = z + .object({ + kind: coachAssumptionKindSchema, + value: coachAssumptionOptionSchema, + alternatives: z.array(coachAssumptionOptionSchema).describe("At most 3."), + }) + .meta({ + id: "CoachAssumption", + description: + "v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a `change_assumption` follow-up.", + }); + // ── Follow-ups ──────────────────────────────────────────────────────────── export const coachFollowUpSchema = z @@ -261,6 +326,7 @@ export const coachFollowUpSchema = z "as_table", "related_metric", "continue", + "change_assumption", ]), labelKey: z.string(), label: z @@ -269,7 +335,7 @@ export const coachFollowUpSchema = z anchor: z .object({ // Empty for a related metric read without a table of its own. - ref: z.string().regex(/^(?:r[1-6])?$/), + ref: z.string().regex(/^(?:r[1-8])?$/), domain: coachStepDomainSchema, window: coachWindowEnum.optional(), granularity: coachResultGranularitySchema.optional(), @@ -282,6 +348,15 @@ export const coachFollowUpSchema = z "True when the chip is answered from a stored table without a model call.", ), origin: z.enum(["server", "model"]), + assumption: z + .object({ + kind: coachAssumptionKindSchema, + value: coachChoiceValueSchema, + }) + .optional() + .describe( + "v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks.", + ), }) .meta({ id: "CoachFollowUp", @@ -293,21 +368,27 @@ export const coachFollowUpSchema = z export const coachClarificationSchema = z .object({ - kind: z.enum(["metric", "window", "context"]), + kind: z + .enum(["metric", "window", "comparison", "goal", "anchor", "context"]) + .describe("`comparison`, `goal` and `anchor` since v1.41."), choices: z .array( z.object({ id: z.string().regex(/^c[1-4]$/), labelKey: z.string(), label: z.string(), - value: z.object({ - metric: coachScopeSourceSchema.optional(), - window: coachWindowEnum.optional(), - }), + value: coachChoiceValueSchema, }), ) .describe("At most 4. Metric choices are always ones the record holds."), freeText: z.boolean().describe("Whether a typed answer is welcome."), + assumption: z + .string() + .regex(/^c[1-4]$/) + .optional() + .describe( + "v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first choice.", + ), }) .meta({ id: "CoachClarification", @@ -315,6 +396,178 @@ export const coachClarificationSchema = z "The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.", }); +// ── Activity, memory and plans (v1.41) ──────────────────────────────────── + +const coachStopReasonSchema = z.enum(["budget", "time", "cap", "no_progress"]); + +export const coachStopSchema = z + .object({ + reason: coachStopReasonSchema, + rounds: z + .number() + .int() + .describe("The rounds the turn ran, the final one included."), + }) + .meta({ + id: "CoachStop", + description: + "v1.41 — why the Coach answered before it was done reading: the token budget, the time budget, the round cap, or no new data.", + }); + +const coachActivityMetaShape = { + id: z + .string() + .regex(/^a[1-9]\d?$/) + .describe("`a1`..`a99`, unique within a turn."), + phase: z.enum([ + "thinking", + "memory", + "fetch", + "digest", + "checkpoint", + "remember", + "plan", + "asking", + "stop", + "answer", + ]), + status: z.enum(["running", "done", "empty", "failed"]), + round: z.number().int().describe("The tool round, from 1."), + labelKey: z + .string() + .describe("Closed catalog key (`insights.coach.activity.*`)."), + label: z + .string() + .describe( + "Rendered on the server in the request locale from the catalog; never model text.", + ), + stepRef: z + .string() + .regex(/^s[1-9]\d{0,2}$/) + .optional() + .describe("The `step` a `fetch` entry stands for."), + count: z + .number() + .int() + .optional() + .describe("Readings, lookups or entries the server counted."), + durationMs: z.number().int().optional(), + stop: coachStopReasonSchema.optional().describe("Set on the `stop` entry."), +}; + +export const coachActivityMetaSchema = z.object(coachActivityMetaShape).meta({ + id: "CoachActivityMeta", + description: + "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`.", +}); + +export const coachActivitySchema = z + .object({ + ...coachActivityMetaShape, + title: z + .string() + .max(ACTIVITY_TITLE_MAX_CHARS) + .optional() + .describe("A screened reasoning title or checkpoint sentence."), + text: z + .string() + .max(ACTIVITY_TEXT_MAX_CHARS) + .optional() + .describe("A screened reasoning summary for the round."), + }) + .meta({ + id: "CoachActivity", + description: + "v1.41 — one entry of the live trail, sent as `activity` frames that upsert by `id`. `title` and `text` are model text, screened, and sent only to the account that owns the conversation.", + }); + +const coachFactCategorySchema = z.enum(COACH_MEMORY_CATEGORIES); + +const coachMemoryNoteMetaShape = { + proposal: z + .boolean() + .describe( + "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes `factId`.", + ), + proposalId: choiceIdSchema.optional(), + factId: choiceIdSchema.optional(), + category: coachFactCategorySchema, +}; + +export const coachMemoryNoteMetaSchema = z + .object(coachMemoryNoteMetaShape) + .meta({ + id: "CoachMemoryNoteMeta", + description: + "v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text.", + }); + +export const coachMemoryNoteSchema = z + .object({ + ...coachMemoryNoteMetaShape, + fact: z.string().max(REMEMBER_FACT_MAX_CHARS), + }) + .meta({ + id: "CoachMemoryNote", + description: + "v1.41 — a fact the Coach saved during the turn, or proposes to save (health facts are never saved without a tap). Owner only.", + }); + +const coachPlanProposalMetaShape = { + planId: choiceIdSchema, + metric: z.string().max(64), + reviewInDays: z + .number() + .int() + .min(PLAN_REVIEW_DAYS.min) + .max(PLAN_REVIEW_DAYS.max), +}; + +export const coachPlanProposalMetaSchema = z + .object(coachPlanProposalMetaShape) + .meta({ + id: "CoachPlanProposalMeta", + description: + "v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text.", + }); + +export const coachPlanProposalSchema = z + .object({ + ...coachPlanProposalMetaShape, + ifCue: z.string(), + thenAction: z.string(), + target: z.string().optional(), + }) + .meta({ + id: "CoachPlanProposal", + description: + "v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId, accept }`. Owner only.", + }); + +export const coachTrailSchema = z + .object({ + entries: z.array( + z.object({ + id: z.string().regex(/^a[1-9]\d?$/), + title: z.string().max(ACTIVITY_TITLE_MAX_CHARS).optional(), + text: z.string().max(ACTIVITY_TEXT_MAX_CHARS).optional(), + }), + ), + recalled: z.array(z.string().max(REMEMBER_FACT_MAX_CHARS)).optional(), + proposal: z + .object({ + proposalId: choiceIdSchema, + category: coachFactCategorySchema, + fact: z.string().max(REMEMBER_FACT_MAX_CHARS), + }) + .optional(), + }) + .meta({ + id: "CoachTrail", + description: + "v1.41 — the model-written text of a turn's trail and the fact texts it touched. Encrypted at rest and served only to the account that owns the conversation.", + }); + // ── The cards and the usage envelope ────────────────────────────────────── const coachSuggestionSchema = z @@ -457,6 +710,27 @@ export const coachProvenanceSchema = z .describe( "v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise.", ), + activity: z + .array(coachActivityMetaSchema) + .optional() + .describe("v1.41 — the turn's trail, metadata only."), + stop: coachStopSchema + .optional() + .describe("v1.41 — why the answer was forced, when it was."), + assumptions: z + .array(coachAssumptionSchema) + .optional() + .describe( + "v1.41 — what the answer assumed instead of asking, at most two.", + ), + memoryNote: coachMemoryNoteMetaSchema + .optional() + .describe( + "v1.41 — the fact this reply saved or proposes, without its text.", + ), + planProposal: coachPlanProposalMetaSchema + .optional() + .describe("v1.41 — the plan this reply proposes, without its text."), }) .meta({ id: "CoachProvenance", @@ -487,6 +761,13 @@ export const coachStreamEventSchema = z conversationId: z.string(), messageId: z.string(), usage: coachUsageSchema.optional(), + stop: coachStopSchema.optional(), + withheldResults: z + .literal(true) + .optional() + .describe( + "v1.41 — the turn was blocked after interim tables went out; remove them.", + ), }), z.object({ type: z.literal("error"), @@ -495,7 +776,14 @@ export const coachStreamEventSchema = z 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("result"), + result: coachResultTableSchema, + interim: z + .literal(true) + .optional() + .describe("v1.41 — sent while the turn still runs."), + }), z.object({ type: z.literal("followUps"), followUps: z.array(coachFollowUpSchema), @@ -504,9 +792,15 @@ export const coachStreamEventSchema = z type: z.literal("clarification"), clarification: coachClarificationSchema, }), + z.object({ type: z.literal("activity"), activity: coachActivitySchema }), + z.object({ type: z.literal("memoryNote"), note: coachMemoryNoteSchema }), + z.object({ + type: z.literal("planProposal"), + proposal: coachPlanProposalSchema, + }), ]) .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.", + "One Server-Sent Events frame of a Coach turn, sent as `data: \\n\\n` and dispatched on `type`. Order: `(activity | step)*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `memoryNote?` → `planProposal?` → `clarification?` → `followUps?` → `done`, or a single `error`. `result` frames with `interim: true` may also arrive among the `activity` frames. Clients ignore a `type` they do not know.", }); diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index fa488034f..2db401956 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -183,6 +183,32 @@ export const coachChatRequestSchema = z.object({ choiceId: z.string().min(1).max(8).optional(), }) .optional(), + /** + * v1.41 — the person answered a fact proposal ("Yes, remember it" / "No"). + * `messageId` is the assistant message that offered it, `proposalId` the + * proposal on that message. The client never sends the fact: the server + * reads it from what it stored on that message, so a tap can only accept + * what the Coach actually proposed. + */ + memoryDecision: z + .object({ + messageId: z.string().min(1).max(64), + proposalId: z.string().min(1).max(64), + accept: z.boolean(), + }) + .optional(), + /** + * v1.41 — the person answered a plan proposal ("Take on this plan" / + * "Not now"). `planId` is the proposed plan the assistant message carried; + * the server holds it to that message and to the person's own plans. + */ + planDecision: z + .object({ + messageId: z.string().min(1).max(64), + planId: z.string().min(1).max(64), + accept: z.boolean(), + }) + .optional(), }); /** @@ -247,6 +273,18 @@ export interface CoachUsage { * step* → token* → provenance → result* → suggestion? → * suggestedAction? → clarification? → followUps? → done * + * v1.41 — three more additive frames: `activity` (the live trail: one + * entry per phase of the turn, upserted by id), `memoryNote` (a fact the + * Coach saved or proposes to save) and `planProposal` (a plan it proposes). + * `result` may carry `interim: true` while the turn still runs, and `done` + * carries `stop` when the answer was forced and `withheldResults` when the + * interim tables must be taken down again. The `reasoning` frame stays + * accepted and is never sent. Frame order: + * + * (activity | step)* → token* → provenance → result* → suggestion? → + * suggestedAction? → memoryNote? → planProposal? → 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. @@ -268,6 +306,13 @@ export type CoachStreamEvent = conversationId: string; messageId: string; usage?: CoachUsage; + /** v1.41 — why the answer was forced, when it was. */ + stop?: CoachStop; + /** + * v1.41 — the turn was blocked after interim tables went out; the + * client removes them. + */ + withheldResults?: true; } | { type: "error"; @@ -281,9 +326,155 @@ export type CoachStreamEvent = reason?: AiUnavailableReason; } | { type: "step"; step: CoachStep } - | { type: "result"; result: CoachResultTable } + | { + type: "result"; + result: CoachResultTable; + /** + * v1.41 — sent while the turn still runs, right after the call that + * read it. The answer decides later whether it is shown or sits under + * "Data used". + */ + interim?: true; + } | { type: "followUps"; followUps: CoachFollowUp[] } - | { type: "clarification"; clarification: CoachClarification }; + | { type: "clarification"; clarification: CoachClarification } + | { type: "activity"; activity: CoachActivity } + | { type: "memoryNote"; note: CoachMemoryNote } + | { type: "planProposal"; proposal: CoachPlanProposal }; + +// ── Activity (v1.41) ──────────────────────────────────────────────────── +// The live trail of a turn: what the Coach is doing right now, one entry per +// phase. The metadata is plaintext and persisted on `metricSource.activity`; +// the model-written `title` and `text` reach only the owner and are stored +// encrypted in `coach_messages.trail_encrypted`. + +export type CoachActivityPhase = + | "thinking" + | "memory" + | "fetch" + | "digest" + | "checkpoint" + | "remember" + | "plan" + | "asking" + | "stop" + | "answer"; + +/** Why the loop forced its final answer. */ +export type CoachStopReason = "budget" | "time" | "cap" | "no_progress"; + +export interface CoachStop { + reason: CoachStopReason; + /** The rounds the turn ran, the final one included. */ + rounds: number; +} + +/** Plaintext; rides `metricSource.activity`. */ +export interface CoachActivityMeta { + /** `a1`..`a99`, unique within a turn; frames upsert by id. */ + id: string; + phase: CoachActivityPhase; + status: CoachStepStatus; + /** The tool round the entry belongs to, from 1. */ + round: number; + /** Closed catalog key (`insights.coach.activity.*`). */ + labelKey: string; + /** + * The label rendered on the server in the request locale. Always catalog + * text with server-chosen values, never model text. + */ + label: string; + /** The `step` a `fetch` entry stands for (`s1`..). */ + stepRef?: string; + /** Readings, lookups or remembered entries the server counted. */ + count?: number; + durationMs?: number; + /** Set on the `stop` entry. */ + stop?: CoachStopReason; +} + +/** One trail entry on the wire: the metadata plus the owner-only model text. */ +export interface CoachActivity extends CoachActivityMeta { + /** A screened reasoning title or checkpoint sentence, at most 80 chars. */ + title?: string; + /** A screened reasoning summary for the round, at most 400 chars. */ + text?: string; +} + +// ── Memory and plans (v1.41) ──────────────────────────────────────────── + +/** + * The categories a Coach fact is filed under. The extraction's list in + * `facts.ts` plus `medication`, which only the in-turn `remember_fact` tool + * and the remember button write. `condition`, `constraint` and `medication` + * are health facts: never saved without the person's tap. + */ +export const COACH_MEMORY_CATEGORIES = [ + "preference", + "goal", + "context", + "condition", + "constraint", + "medication", +] as const; + +export type CoachMemoryCategory = (typeof COACH_MEMORY_CATEGORIES)[number]; + +/** Plaintext; rides `metricSource.memoryNote`. No fact text. */ +export interface CoachMemoryNoteMeta { + /** + * `true`: a proposal waiting for the person ("Yes, remember it" / "No"), + * answered with `memoryDecision`. `false`: already saved, undone by + * deleting `factId`. + */ + proposal: boolean; + /** The proposal on this message (`proposal: true`). */ + proposalId?: string; + /** The saved fact (`proposal: false`). */ + factId?: string; + category: CoachMemoryCategory; +} + +/** On the wire, owner only: the metadata plus the fact itself. */ +export interface CoachMemoryNote extends CoachMemoryNoteMeta { + /** The fact as it will be stored, at most 160 chars. */ + fact: string; +} + +/** Plaintext; rides `metricSource.planProposal`. No plan text. */ +export interface CoachPlanProposalMeta { + /** The `CoachPlan` row, written as `proposed`. */ + planId: string; + /** The metric the plan moves, e.g. `WEIGHT`. */ + metric: string; + /** 7..56. */ + reviewInDays: number; +} + +/** On the wire, owner only: the metadata plus the plan's own words. */ +export interface CoachPlanProposal extends CoachPlanProposalMeta { + ifCue: string; + thenAction: string; + target?: string; +} + +/** + * v1.41 — the decrypted `coach_messages.trail_encrypted` of one message, as + * `GET …/messages/{messageId}/trail` serves it to the owner. Model text and + * fact text only; the structure lives in `metricSource`. + */ +export interface CoachTrail { + /** Title and text per activity entry, by `CoachActivityMeta.id`. */ + entries: Array<{ id: string; title?: string; text?: string }>; + /** The facts recalled into the turn (the `memory` entry). */ + recalled?: string[]; + /** The fact proposal's text, which a `memoryDecision` reads back. */ + proposal?: { + proposalId: string; + category: CoachMemoryCategory; + fact: string; + }; +} // ── Steps (v1.39.4) ───────────────────────────────────────────────────── // What the Coach is reading while a turn runs. Labels come from a closed @@ -307,7 +498,7 @@ export type CoachStepReason = | "invalid_arguments"; export interface CoachStep { - /** `s1`..`s12`, unique within a turn; frames upsert by id. */ + /** `s1`..`s48`, unique within a turn; frames upsert by id. */ id: string; tool: CoachToolName | "show_result" | "snapshot"; /** Closed catalog key, e.g. `coach.step.read`. */ @@ -351,6 +542,19 @@ export type CoachResultCell = string | number | null; export type CoachChartSpec = | { kind: "line"; x: string; series: string[] } + | { + /** + * v1.41 — two series on one chart: two periods overlaid (`periods`, + * one axis) or two metrics side by side (`metrics`, one or two axes). + * `a` and `b` name the value columns, `x` the shared column. + */ + kind: "compare"; + mode: "periods" | "metrics"; + x: string; + a: string; + b: string; + axes: 1 | 2; + } | { kind: "bar"; x: string; @@ -374,7 +578,7 @@ export interface CoachResultSource { /** Plaintext; rides `metricSource.results`. */ export interface CoachResultMeta { - /** `r1`..`r6`, per message. */ + /** `r1`..`r8`, per message. */ ref: string; source: CoachResultSource; shape: CoachResultShape; @@ -445,7 +649,8 @@ export type CoachFollowUpKind = | "as_chart" | "as_table" | "related_metric" - | "continue"; + | "continue" + | "change_assumption"; export interface CoachFollowUp { /** `f1`..`f3`. */ @@ -463,25 +668,77 @@ export interface CoachFollowUp { /** True when the chip is answered from a stored table, without a model. */ reuse: boolean; origin: "server" | "model"; + /** + * v1.41 — on a `change_assumption` chip: the assumption it replaces and + * the alternative it picks. + */ + assumption?: { kind: CoachAssumptionKind; value: CoachChoiceValue }; } // ── 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. +/** v1.41 — what a comparison is drawn against. */ +export type CoachComparisonBasis = + "previous_period" | "year_ago" | "baseline_90d"; + +/** + * What a choice stands for. One field per kind; `goal` and `anchor` are ids + * of the person's own plans, facts or record events, never text. + */ +export interface CoachChoiceValue { + metric?: CoachScopeSource; + window?: CoachScopeWindow; + /** v1.41 */ + comparison?: CoachComparisonBasis; + /** v1.41 — an active plan or a goal fact. */ + goal?: string; + /** v1.41 — a medication start, an illness episode, a cycle phase. */ + anchor?: string; +} + export interface CoachClarificationChoice { id: string; labelKey: string; label: string; - value: { metric?: CoachScopeSource; window?: CoachScopeWindow }; + value: CoachChoiceValue; } +export type CoachClarificationKind = + "metric" | "window" | "comparison" | "goal" | "anchor" | "context"; + export interface CoachClarification { - kind: "metric" | "window" | "context"; + /** `comparison`, `goal` and `anchor` since v1.41. */ + kind: CoachClarificationKind; /** 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.41 — the choice that applies when the person does not answer (`c1`..); + * the question names it, and it is the first choice. + */ + assumption?: string; +} + +// ── Assumptions (v1.41) ───────────────────────────────────────────────── +// What an answer assumed instead of asking. Catalog values only, at most two +// per answer; each offers its alternatives as `change_assumption` chips. + +export type CoachAssumptionKind = "metric" | "window" | "comparison"; + +export interface CoachAssumptionOption { + labelKey: string; + label: string; + value: CoachChoiceValue; +} + +export interface CoachAssumption { + kind: CoachAssumptionKind; + value: CoachAssumptionOption; + /** At most 3. */ + alternatives: CoachAssumptionOption[]; } /** @@ -672,6 +929,16 @@ export interface CoachProvenance { * further "keep looking" chip, so an answer is continued at most once. */ continuationOf?: string; + /** v1.41 — the turn's trail, metadata only; the text is behind `…/trail`. */ + activity?: CoachActivityMeta[]; + /** v1.41 — why the answer was forced, when it was. */ + stop?: CoachStop; + /** v1.41 — what the answer assumed instead of asking, at most two. */ + assumptions?: CoachAssumption[]; + /** v1.41 — the fact this reply saved or proposes, without its text. */ + memoryNote?: CoachMemoryNoteMeta; + /** v1.41 — the plan this reply proposes, without its text. */ + planProposal?: CoachPlanProposalMeta; } /** diff --git a/src/lib/ai/reasoning/__tests__/levels.test.ts b/src/lib/ai/reasoning/__tests__/levels.test.ts new file mode 100644 index 000000000..f21a6a840 --- /dev/null +++ b/src/lib/ai/reasoning/__tests__/levels.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + BACKGROUND_REASONING_JOBS, + BACKGROUND_REASONING_LEVEL, + DEFAULT_REASONING_LEVEL, + DEFAULT_REASONING_MAX_EFFORT, + REASONING_LEVELS, + REASONING_MAX_EFFORTS, + REASONING_THINKING_BUDGET, + capReasoningLevel, + isReasoningLevel, +} from "../levels"; + +describe("reasoning levels", () => { + it("defaults to medium for a person and to no cap for an instance", () => { + expect(DEFAULT_REASONING_LEVEL).toBe("medium"); + expect(DEFAULT_REASONING_MAX_EFFORT).toBe("high"); + expect(REASONING_MAX_EFFORTS).toEqual( + REASONING_LEVELS.filter((level) => level !== "off"), + ); + }); + + it("recognises exactly the four levels", () => { + for (const level of REASONING_LEVELS) { + expect(isReasoningLevel(level)).toBe(true); + } + for (const other of ["none", "minimal", "xhigh", "", null, 2]) { + expect(isReasoningLevel(other)).toBe(false); + } + }); + + it("caps a level at the operator's maximum and never raises one", () => { + expect(capReasoningLevel("high", "medium")).toBe("medium"); + expect(capReasoningLevel("high", "low")).toBe("low"); + expect(capReasoningLevel("medium", "high")).toBe("medium"); + expect(capReasoningLevel("low", "medium")).toBe("low"); + expect(capReasoningLevel("off", "low")).toBe("off"); + }); + + it("gives every level above off a growing thinking budget of at least 1024", () => { + const budgets = REASONING_LEVELS.filter((level) => level !== "off").map( + (level) => REASONING_THINKING_BUDGET[level as "low"], + ); + expect(budgets[0]).toBeGreaterThanOrEqual(1_024); + expect([...budgets].sort((a, b) => a - b)).toEqual(budgets); + }); + + it("has a level for every background job, operator-paid never above its own", () => { + expect(Object.keys(BACKGROUND_REASONING_LEVEL).sort()).toEqual( + [...BACKGROUND_REASONING_JOBS].sort(), + ); + for (const job of BACKGROUND_REASONING_JOBS) { + const { level, operatorLevel } = BACKGROUND_REASONING_LEVEL[job]; + expect(level).not.toBe("off"); + expect(REASONING_LEVELS.indexOf(operatorLevel)).toBeLessThanOrEqual( + REASONING_LEVELS.indexOf(level), + ); + } + }); +}); diff --git a/src/lib/ai/reasoning/levels.ts b/src/lib/ai/reasoning/levels.ts new file mode 100644 index 000000000..28573c634 --- /dev/null +++ b/src/lib/ai/reasoning/levels.ts @@ -0,0 +1,131 @@ +/** + * v1.41 — the reasoning levels, the thinking budget behind each, and the + * background jobs that may reason. + * + * One vocabulary for every layer: the person's Coach preference + * (`coachPrefsJson.reasoning`), the operator's cap + * (`app_settings.ai_reasoning_max_effort`), the resolved value a call carries + * (`CompletionParams.reasoning.effort`) and the level `/api/auth/me` publishes. + * Each provider client maps it onto its own wire; nothing outside a client + * speaks a provider's dialect. + * + * A leaf on purpose, like `../reasoning-effort.ts`: `types.ts` imports it, and + * `types.ts` is reachable from workers that must not reach the provider + * machinery. No import from `provider-chain.ts`, and none from a client. + * + * Client-safe: no server import. + */ + +/** + * `off` is the person's choice, not a promise about the wire: where a provider + * cannot switch reasoning off, the client sends its lowest setting instead and + * the settings say "Minimal" rather than "Off" (`offIsReal` in + * `support.ts`). + */ +export const REASONING_LEVELS = ["off", "low", "medium", "high"] as const; + +export type ReasoningLevel = (typeof REASONING_LEVELS)[number]; + +/** The level an account that never chose one gets. */ +export const DEFAULT_REASONING_LEVEL: ReasoningLevel = "medium"; + +/** The values the operator's cap may take; switching off is the other field. */ +export const REASONING_MAX_EFFORTS = ["low", "medium", "high"] as const; + +export type ReasoningMaxEffort = (typeof REASONING_MAX_EFFORTS)[number]; + +/** The cap an instance that never set one has: no cap. */ +export const DEFAULT_REASONING_MAX_EFFORT: ReasoningMaxEffort = "high"; + +export function isReasoningLevel(value: unknown): value is ReasoningLevel { + return ( + typeof value === "string" && + (REASONING_LEVELS as readonly string[]).includes(value) + ); +} + +/** `level`, lowered to `max` when it is above it. */ +export function capReasoningLevel( + level: ReasoningLevel, + max: ReasoningMaxEffort, +): ReasoningLevel { + return REASONING_LEVELS.indexOf(level) > REASONING_LEVELS.indexOf(max) + ? max + : level; +} + +/** + * Thinking tokens per level, for the providers that take a token budget + * rather than a named effort (Anthropic's manual mode, gateways that accept + * `max_tokens`). A round's output allowance is its answer budget plus this. + */ +export const REASONING_THINKING_BUDGET: Readonly< + Record, number> +> = { + low: 1_024, + medium: 4_096, + high: 12_000, +}; + +/** + * The background jobs that may reason. Every other unattended surface (status + * cards, batch notes, archetypes, workout and reaction lines, nudges, document + * reading, OCR) never does, and the inventory test freezes this list. + */ +export const BACKGROUND_REASONING_JOBS = [ + "daily_briefing", + "period_narrative_month", + "period_narrative_week", + "memory_summary", + "memory_facts", + "memory_plans", +] as const; + +export type BackgroundReasoningJob = (typeof BACKGROUND_REASONING_JOBS)[number]; + +/** + * The level each job asks for: `level` on the person's own plan, key or local + * model, `operatorLevel` when the operator pays for the call. Still capped by + * the operator's settings like every other call. + */ +export const BACKGROUND_REASONING_LEVEL: Readonly< + Record< + BackgroundReasoningJob, + { level: ReasoningLevel; operatorLevel: ReasoningLevel } + > +> = { + daily_briefing: { level: "medium", operatorLevel: "low" }, + period_narrative_month: { level: "medium", operatorLevel: "low" }, + period_narrative_week: { level: "low", operatorLevel: "low" }, + memory_summary: { level: "low", operatorLevel: "low" }, + memory_facts: { level: "low", operatorLevel: "low" }, + memory_plans: { level: "medium", operatorLevel: "low" }, +}; + +// ── Message keys ──────────────────────────────────────────────────────────── + +/** + * One option label per level. `minimal` stands in for `off` where the active + * provider cannot switch reasoning off. + */ +export const REASONING_LEVEL_LABEL_KEYS = { + off: "insights.coach.reasoning.off", + minimal: "insights.coach.reasoning.minimal", + low: "insights.coach.reasoning.low", + medium: "insights.coach.reasoning.medium", + high: "insights.coach.reasoning.high", +} as const; + +/** The Coach settings field around the options. */ +export const REASONING_SETTING_KEYS = { + label: "insights.coach.reasoning.label", + hint: "insights.coach.reasoning.hint", + capped: "insights.coach.reasoning.capped", + disabled: "insights.coach.reasoning.disabled", +} as const; + +/** The operator's two controls in the admin assistant section. */ +export const REASONING_ADMIN_KEYS = { + enabled: "admin.assistant.reasoning.enabled", + max: "admin.assistant.reasoning.max", +} as const; diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 7ad8be174..c33949131 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -1,4 +1,5 @@ import type { ReasoningEffort } from "./reasoning-effort"; +import type { ReasoningLevel } from "./reasoning/levels"; import { z } from "zod/v4"; // ─── Insight Result Schema ───────────────────────────────── @@ -252,6 +253,37 @@ export interface AiMessage { * A no-op on every other provider (their prefix caching is automatic). */ cacheBreakpoint?: boolean; + /** + * v1.41 — opaque provider state carried from one tool round to the next on + * an assistant turn: the reasoning items a provider requires back verbatim + * (encrypted reasoning items, thinking blocks with their signatures, + * reasoning details). Only the client whose `providerType` and `model` match + * re-inserts it; every other client drops it. Lives in memory for one turn: + * never persisted, never logged, never passed to `annotate()`. + */ + providerState?: AiProviderState; +} + +/** + * v1.41 — provider state threaded between the rounds of one turn. `items` is + * whatever the producing client needs back, unchanged; nothing outside that + * client reads it. + */ +export interface AiProviderState { + providerType: ProviderType; + model: string; + items: unknown[]; +} + +/** + * v1.41 — one live reasoning event a client reports while a call runs. + * `title` is the first bold line of a summary once it has closed, `text` a + * finished summary, `done` the end of the reasoning for this call. Model + * text: the receiver screens it before anything reaches a person. + */ +export interface AiReasoningEvent { + kind: "title" | "text" | "done"; + text: string; } export interface CompletionParams { @@ -334,6 +366,20 @@ export interface CompletionParams { * `ai-call-timeout-guard.test.ts`. */ timeoutPolicy?: "surface-ceiling"; + /** + * v1.41 — the reasoning this call asks for, already resolved against the + * person's level, the operator's cap and what the provider supports + * (`src/lib/ai/reasoning/resolve.ts`). `summaries` asks for reasoning + * summaries where the provider offers them. Absent → today's behaviour: the + * client sends exactly what it sent before the field existed. + */ + reasoning?: { effort: ReasoningLevel; summaries: boolean }; + /** + * v1.41 — live reasoning events, called as they arrive on clients that read + * the provider's stream, or once after the reply on clients that do not. + * Never called when `reasoning` is absent. + */ + onReasoning?: (event: AiReasoningEvent) => void; } export interface CompletionResult { @@ -358,6 +404,22 @@ export interface CompletionResult { * `"tool_calls"`. Absent when the provider does not surface it. */ finishReason?: "stop" | "tool_calls" | "length"; + /** + * v1.41 — what the call reasoned, when it reasoned. `summary` holds the + * finished summaries (model text, unscreened), `tokens` the reasoning tokens + * the provider billed where it reports them, `downgradedTo` the level the + * client fell back to after the provider refused the requested one. + */ + reasoning?: { + summary: string[]; + tokens: number | null; + downgradedTo?: ReasoningLevel; + }; + /** + * v1.41 — the state the next round of the same turn hands back on its + * assistant message (see {@link AiMessage.providerState}). + */ + providerState?: AiProviderState; } /** diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index a1f399db3..31ae6c950 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -232,6 +232,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { 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" }, + // v1.41 — the model text of the turn's trail, JSON under the same codec. + { model: "CoachMessage", field: "trailEncrypted", 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 f5c64ffc2..0f7f0345d 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -90,6 +90,16 @@ export interface CoachMessageBackupEntry { * unreadable-row marker stands in for tables that did not decrypt. */ resultsJson?: string | null; + /** + * v1.41 — the model text of the turn's trail, as ciphertext in base64. + * Present on a disaster-recovery payload only; null on a turn without one. + */ + trailEncrypted?: string | null; + /** + * v1.41 — the same trail as readable JSON (a `CoachTrail`), on a portable + * payload only; null on a turn without one. + */ + trailJson?: string | null; createdAt: string; } @@ -167,6 +177,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = { tokensUsed: true, model: true, resultsEncrypted: true, + trailEncrypted: true, createdAt: true, }, }, @@ -268,6 +279,14 @@ export async function buildCoachBackupSection( : null, } : { resultsJson: decryptTurnSoft(message.resultsEncrypted) }), + // v1.41 — and so does the trail. + ...(disasterRecovery + ? { + trailEncrypted: message.trailEncrypted + ? Buffer.from(message.trailEncrypted).toString("base64") + : null, + } + : { trailJson: decryptTurnSoft(message.trailEncrypted) }), createdAt: message.createdAt.toISOString(), })), attachments: row.attachments.map((attachment) => ({ @@ -340,6 +359,8 @@ export type RestoredCoachMessage = Pick< | "model" | "resultsEncrypted" | "resultsJson" + | "trailEncrypted" + | "trailJson" > >; @@ -424,6 +445,7 @@ export async function restoreCoachData( tokensUsed: message.tokensUsed ?? null, model: message.model ?? null, resultsEncrypted: resolveResultsBytes(message), + trailEncrypted: resolveTrailBytes(message), createdAt: new Date(message.createdAt), })), }, @@ -497,6 +519,18 @@ function resolveResultsBytes( return message.resultsJson ? encryptToBytes(message.resultsJson) : null; } +/** v1.41 — a turn's stored trail, from either end of the contract. */ +function resolveTrailBytes( + message: RestoredCoachMessage, +): Uint8Array | null { + if (message.trailEncrypted !== undefined) { + return message.trailEncrypted === null + ? null + : decodeBase64(message.trailEncrypted); + } + return message.trailJson ? encryptToBytes(message.trailJson) : null; +} + function resolveTitleBytes( conversation: RestoredCoachConversation, ): Uint8Array { @@ -571,6 +605,15 @@ export interface CoachFactBackupEntry { category: string; confidence: number; sourceConversationId: string | null; + /** v1.41 — `user`, `coach`, `extracted` or `pattern`. */ + source: string; + /** v1.41 */ + lastUsedAt: string | null; + /** + * v1.41 — the message the fact came out of. A bare id like + * `sourceConversationId`, resolved the same way on restore. + */ + sourceMessageId: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -670,6 +713,9 @@ export async function buildCoachMemoryBackupSection( category: row.category, confidence: row.confidence, sourceConversationId: row.sourceConversationId, + source: row.source, + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + sourceMessageId: row.sourceMessageId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), })), @@ -726,6 +772,9 @@ const COACH_FACT_BACKUP_SELECT = { category: true, confidence: true, sourceConversationId: true, + source: true, + lastUsedAt: true, + sourceMessageId: true, createdAt: true, updatedAt: true, deletedAt: true, @@ -795,6 +844,9 @@ export type RestoredCoachFact = Pick< | "fact" | "confidence" | "sourceConversationId" + | "source" + | "lastUsedAt" + | "sourceMessageId" | "deletedAt" > >; @@ -859,12 +911,17 @@ export interface CoachMemoryRestoreInput { * * Plans go in before reminders for the same reason one step down: a reminder's * `relatedPlanId` is resolved against the plans this function has just written. + * + * A fact's `sourceMessageId` (v1.41) is resolved against the messages the + * conversation restore wrote, and nulled and reported the same way when it + * names one the file did not carry. */ export async function restoreCoachMemoryData( tx: Prisma.TransactionClient, ownerId: string, payload: CoachMemoryRestoreInput, conversationIds: ReadonlySet, + messageIds: ReadonlySet, skips: RestoreSkipLog, ): Promise { const [clearedFacts, clearedPlans, clearedReminders] = await Promise.all([ @@ -884,6 +941,12 @@ export async function restoreCoachMemoryData( danglingRefs.push(id); return null; }; + const resolveMessage = (id: string | null | undefined) => { + if (!id) return null; + if (messageIds.has(id)) return id; + danglingRefs.push(id); + return null; + }; if (payload.coachFacts.length > 0) { await tx.coachFact.createMany({ @@ -894,6 +957,12 @@ export async function restoreCoachMemoryData( category: fact.category, confidence: fact.confidence ?? 50, sourceConversationId: resolveConversation(fact.sourceConversationId), + // A file written before v1.41 states no source; every fact then came + // from the extraction or the matcher, which is what the column's + // default says too. + source: fact.source ?? "extracted", + lastUsedAt: fact.lastUsedAt ? new Date(fact.lastUsedAt) : null, + sourceMessageId: resolveMessage(fact.sourceMessageId), createdAt: new Date(fact.createdAt), updatedAt: new Date(fact.updatedAt), deletedAt: fact.deletedAt ? new Date(fact.deletedAt) : null, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 21c702f21..dbf305380 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -35,6 +35,7 @@ import { } from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; +import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels"; import { prisma, toJson } from "@/lib/db"; import { normaliseSourceInstance } from "@/lib/validations/inbound-documents"; import { auditLog } from "@/lib/auth/audit"; @@ -865,6 +866,11 @@ export async function restoreBackup( settings.assistantInsightStatusEnabled, assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled ?? true, + // v1.41 — absent on a file written before the controls existed, + // which restores them at their defaults: allowed, no cap. + aiReasoningEnabled: settings.aiReasoningEnabled ?? true, + aiReasoningMaxEffort: + settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT, moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never, documentMaxFileBytes: settings.documentMaxFileBytes, documentQuotaBytes: BigInt(settings.documentQuotaBytes), @@ -2475,6 +2481,11 @@ export async function restoreBackup( ownerId, payload, new Set(payload.coachConversations.map((c) => c.id)), + new Set( + payload.coachConversations.flatMap((c) => + c.messages.map((m) => m.id), + ), + ), skips, ); diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index fd037786f..42d16f7af 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -698,4 +698,21 @@ describe("a Coach turn's stored tables are bounded on restore", () => { const sealed = "A".repeat(300 * 1024); expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(false); }); + + it("bounds the trail's text the same way, at its own 16 KB", () => { + const fits = JSON.stringify({ + entries: [{ id: "a1", text: "x".repeat(15 * 1024) }], + }); + expect(parseWithResults({ trailJson: fits }).success).toBe(true); + expect( + parseWithResults({ trailEncrypted: "A".repeat(30 * 1024) }).success, + ).toBe(true); + const tooLong = JSON.stringify({ + entries: [{ id: "a1", text: "x".repeat(17 * 1024) }], + }); + expect(parseWithResults({ trailJson: tooLong }).success).toBe(false); + expect( + parseWithResults({ trailEncrypted: "A".repeat(40 * 1024) }).success, + ).toBe(false); + }); }); diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts index 801a347f4..4353a6c10 100644 --- a/src/lib/validations/__tests__/coach-prefs.test.ts +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_COACH_CLUSTERS, DEFAULT_COACH_PREFS, coachPrefsSchema, + coachReasoningLevel, parseCoachPrefs, } from "../coach-prefs"; @@ -220,3 +221,39 @@ describe("parseCoachPrefs", () => { ); }); }); + +describe("reasoning (v1.41)", () => { + it("is absent on a legacy blob and reads as medium", () => { + const prefs = parseCoachPrefs({ tone: "neutral" }); + expect(prefs).not.toHaveProperty("reasoning"); + expect(coachReasoningLevel(prefs)).toBe("medium"); + expect(coachReasoningLevel(DEFAULT_COACH_PREFS)).toBe("medium"); + }); + + it.each(["off", "low", "medium", "high"] as const)( + "round-trips %s", + (level) => { + const prefs = parseCoachPrefs({ reasoning: level }); + expect(prefs.reasoning).toBe(level); + expect(coachReasoningLevel(prefs)).toBe(level); + }, + ); + + it("rejects an unknown level on write", () => { + expect(coachPrefsSchema.safeParse({ reasoning: "max" }).success).toBe( + false, + ); + }); + + it("drops an unknown stored level alone and keeps every other field", () => { + const prefs = parseCoachPrefs({ + reasoning: "xhigh", + tone: "concise", + excludeMetrics: ["weight"], + }); + expect(prefs.reasoning).toBeUndefined(); + expect(coachReasoningLevel(prefs)).toBe("medium"); + expect(prefs.tone).toBe("concise"); + expect(prefs.excludeMetrics).toEqual(["weight"]); + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index e47df648e..d842b4605 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -21,6 +21,8 @@ */ import { z } from "zod/v4"; import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs"; +import { TRAIL_MAX_BYTES } from "@/lib/ai/coach/activity/contract"; +import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; import { AllergyCategory, AllergySeverity, @@ -1056,6 +1058,9 @@ const appSettingsBackupSchema = z // was retired still carries that key; `passthrough` keeps it harmless and // the restore writes nothing for it. assistantDocumentAiEnabled: z.boolean().optional(), + // v1.41 — optional for the same reason. + aiReasoningEnabled: z.boolean().optional(), + aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), moduleAvailabilityJson: z.unknown().nullable(), documentMaxFileBytes: z.number().int(), documentQuotaBytes: z.string().regex(/^\d+$/), @@ -1478,6 +1483,21 @@ const coachResultsSealedSchema = base64BytesSchema.refine( { message: "Coach result tables exceed the stored size" }, ); +/** The trail's model text, bounded at restore like the tables above. */ +const COACH_TRAIL_SEALED_MAX_CHARS = 2 * TRAIL_MAX_BYTES; + +const coachTrailJsonSchema = z + .string() + .refine( + (value) => new TextEncoder().encode(value).byteLength <= TRAIL_MAX_BYTES, + { message: `Coach trail exceeds ${TRAIL_MAX_BYTES} bytes` }, + ); + +const coachTrailSealedSchema = base64BytesSchema.refine( + (value) => value.length <= COACH_TRAIL_SEALED_MAX_CHARS, + { message: "Coach trail exceeds the stored size" }, +); + /** * One Coach turn. * @@ -1502,6 +1522,9 @@ const coachMessageBackupSchema = z // readable JSON on a portable one, absent on every file written before. resultsEncrypted: coachResultsSealedSchema.nullable().optional(), resultsJson: coachResultsJsonSchema.nullable().optional(), + // v1.41 — the trail's model text, the same two ends as the tables. + trailEncrypted: coachTrailSealedSchema.nullable().optional(), + trailJson: coachTrailJsonSchema.nullable().optional(), createdAt: isoDateTime, }) .passthrough(); @@ -1549,6 +1572,11 @@ const coachFactBackupSchema = z category: z.string().min(1), confidence: z.number().int().min(0).max(100).optional(), sourceConversationId: z.string().nullable().optional(), + // v1.41 — absent on every file written before; restored at the column + // defaults then. + source: z.string().min(1).max(32).optional(), + lastUsedAt: isoDateTime.nullable().optional(), + sourceMessageId: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index b1ea83a90..2df3b963b 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -14,6 +14,12 @@ */ import { z } from "zod/v4"; +import { + DEFAULT_REASONING_LEVEL, + REASONING_LEVELS, + type ReasoningLevel, +} from "@/lib/ai/reasoning/levels"; + /** * Tone presets the Coach system-prompt prefix toggles between. v1.4.22 * landed `warm` as the default; `neutral` strips the warmth language @@ -196,6 +202,16 @@ export const coachPrefsSchema = z.object({ .describe( "v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off.", ), + // v1.41 — how hard the Coach thinks before it answers. No schema default, + // like the two keys above, so a legacy blob and `parse({})` stay + // byte-identical; an absent key reads as `medium` (`coachReasoningLevel`). + // The operator's switch and cap apply on top, server-side. + reasoning: z + .enum(REASONING_LEVELS) + .optional() + .describe( + "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET /api/auth/me` as `coachReasoning`.", + ), }); export type CoachPrefs = z.infer; @@ -214,6 +230,11 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = { defaultWindow: "allTime", }; +/** The person's reasoning level; an absent key is the default, `medium`. */ +export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel { + return prefs.reasoning ?? DEFAULT_REASONING_LEVEL; +} + /** * Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`, * falling back to defaults when the row is null. Keeps the call sites at diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index bb20699a6..334f0507e 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -165,6 +165,8 @@ describe("canonical disaster-recovery backup round-trip", () => { assistantBriefingEnabled: false, assistantInsightStatusEnabled: false, assistantDocumentAiEnabled: false, + aiReasoningEnabled: false, + aiReasoningMaxEffort: "medium", moduleAvailabilityJson: { insights: true, nutrients: false }, documentMaxFileBytes: 12_345_678, documentQuotaBytes: BigInt("9876543210"), diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 28e9b83bc..703e57ae0 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -143,6 +143,10 @@ const PHQ9_RESPONSES = JSON.stringify({ const CONSENT_ARTEFACT = "JVBERi0xLjQKJWZpeHR1cmU="; const INVENTORY_NOTE = "second pack, kept in the kitchen drawer"; const COACH_FACT = "does not tolerate ACE inhibitors"; +// v1.41 — the model text of a turn's trail, stored encrypted beside the turn. +const COACH_TRAIL_JSON = JSON.stringify({ + entries: [{ id: "a1", title: "Checking the trend", text: "Read 30 days." }], +}); const COACH_PLAN_CUE = "if the evening reading is over 140"; const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow"; const COACH_REMINDER_NOTE = "ask how the evening walks are going"; @@ -1092,6 +1096,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { role: "assistant", encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON), + trailEncrypted: encryptToBytes(COACH_TRAIL_JSON), providerType: "anthropic", model: "claude-opus-5", tokensUsed: 812, @@ -1131,6 +1136,9 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { const healthThread = await prisma.coachConversation.findFirstOrThrow({ where: { userId: OWNER_ID, documentScoped: false }, }); + const healthAnswer = await prisma.coachMessage.findFirstOrThrow({ + where: { conversationId: healthThread.id, role: "assistant" }, + }); await prisma.coachFact.create({ data: { userId: OWNER_ID, @@ -1138,6 +1146,9 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { category: "constraint", confidence: 80, sourceConversationId: healthThread.id, + source: "user", + lastUsedAt: AT("2026-07-20T10:00:00.000Z"), + sourceMessageId: healthAnswer.id, }, }); const plan = await prisma.coachPlan.create({ @@ -1768,6 +1779,9 @@ describe("every model the plan claims two-ended survives a real restore", () => results: message.resultsEncrypted ? decryptFromBytes(message.resultsEncrypted) : null, + trail: message.trailEncrypted + ? decryptFromBytes(message.trailEncrypted) + : null, })), attachments: thread.attachments.map((a) => a.documentId), })), @@ -1786,6 +1800,7 @@ describe("every model the plan claims two-ended survives a real restore", () => model: null, tokensUsed: null, results: null, + trail: null, }, { role: "assistant", @@ -1793,6 +1808,7 @@ describe("every model the plan claims two-ended survives a real restore", () => model: "claude-opus-5", tokensUsed: 812, results: COACH_RESULTS_JSON, + trail: COACH_TRAIL_JSON, }, ], attachments: [], @@ -1810,6 +1826,7 @@ describe("every model the plan claims two-ended survives a real restore", () => model: null, tokensUsed: null, results: null, + trail: null, }, ], attachments: [vaultDocument.id], @@ -1830,17 +1847,26 @@ describe("every model the plan claims two-ended survives a real restore", () => const fact = await prisma.coachFact.findFirstOrThrow({ where: { userId: OWNER_ID }, }); + const restoredAnswer = await prisma.coachMessage.findFirstOrThrow({ + where: { conversationId: restoredThread.id, role: "assistant" }, + }); expect({ fact: decryptFromBytes(fact.factEncrypted), category: fact.category, confidence: fact.confidence, source: fact.sourceConversationId, + origin: fact.source, + lastUsedAt: fact.lastUsedAt?.toISOString(), + sourceMessageId: fact.sourceMessageId, deletedAt: fact.deletedAt, }).toEqual({ fact: COACH_FACT, category: "constraint", confidence: 80, source: restoredThread.id, + origin: "user", + lastUsedAt: "2026-07-20T10:00:00.000Z", + sourceMessageId: restoredAnswer.id, deletedAt: null, }); expect({ @@ -3077,6 +3103,7 @@ describe("every model the plan claims two-ended survives a real restore", () => role: "assistant", encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON), + trailEncrypted: encryptToBytes(COACH_TRAIL_JSON), createdAt: AT("2026-07-20T09:59:00.000Z"), }, ], @@ -3091,8 +3118,12 @@ describe("every model the plan claims two-ended survives a real restore", () => coachConversations: Array<{ messages: Array> }>; }; const [turn] = coachConversations[0].messages; - expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON }); + expect(turn).toMatchObject({ + resultsJson: COACH_RESULTS_JSON, + trailJson: COACH_TRAIL_JSON, + }); expect(turn).not.toHaveProperty("resultsEncrypted"); + expect(turn).not.toHaveProperty("trailEncrypted"); await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); @@ -3120,6 +3151,8 @@ describe("every model the plan claims two-ended survives a real restore", () => expect(decryptFromBytes(restored.resultsEncrypted!)).toBe( COACH_RESULTS_JSON, ); + expect(restored.trailEncrypted).not.toBeNull(); + expect(decryptFromBytes(restored.trailEncrypted!)).toBe(COACH_TRAIL_JSON); }); it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {