diff --git a/CHANGELOG.md b/CHANGELOG.md index 7024dee0e..d44465030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [1.22.0] — 2026-06-27 — A Coach that speaks, remembers, and charts; richer narratives across the app + +A feature release. The Coach reads its replies aloud, can draw the metric it is discussing, remembers what you ask it to and resurfaces those reminders, and proposes checkups or measurement reminders you confirm inline. Insights and Coach assessments are rewritten for connected, motivating narratives instead of restated figures, and the daily read now folds in labs, preventive care, workouts, and glucose. Four additive migrations (`0195` hidden biomarkers, `0196` coach reminders, `0197` coach plan outcomes, `0198` AI settings); applied automatically on start — no operator action required. + +### Added + +- The Coach can read its replies aloud, and a reply can include a chart of the metric under discussion so the number has its trend alongside it. +- The Coach remembers things you ask it to keep and resurfaces a reminder when it comes due; a reminders list in settings shows what it is holding and lets you clear any of it. +- The Coach can propose a checkup or a measurement reminder, which you confirm inline before anything is saved. +- Hover a message to see the date and time it was sent. +- A configurable AI response timeout, and a separate optional provider dedicated to scanning lab documents. +- Steps now appear in Insights, and selecting a measurement type opens its insight. + +### Changed + +- Insight and Coach assessments are rewritten to connect signals into a motivating narrative rather than restate figures, with substantially expanded score summaries; generated text now renders in paragraphs and carries clearer "updated" timestamps — today with the time, yesterday, and a date for anything older. +- The daily read folds in labs, preventive care, workouts, and glucose, and correlations surface emerging signals over a recent window alongside more cross-signal links. +- The preventive-care area is now "Checkups", reached at `/checkups`; the German interface keeps "Vorsorge". +- Settings pages share one consistent layout, and the settings and admin sidebar stays pinned while the content scrolls. +- Lab biomarkers are a compact, editable list with hide and restore. +- Local AI servers stream their replies and honour the configurable timeout, so slower self-hosted models finish cleanly. + +### Fixed + +- The daily briefing and the insight trend no longer go blank when generation runs long. +- The blood-pressure "usual range" no longer shows a nonsensical band. +- A Coach notification now opens straight into the conversation it began. +- Insight headings drop the trailing info icon in favour of a short description. +- Lab values no longer link out to an article. + ## [1.21.4] — 2026-06-27 — Coach surface refinements A patch release. No migrations, no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17cfd4dd7..7036a58a8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.21.4 + version: 1.22.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -4676,6 +4676,8 @@ paths: - active - met - abandoned + - review_due + - reviewed description: Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active). responses: "200": @@ -4724,6 +4726,8 @@ paths: - active - met - abandoned + - review_due + - reviewed reviewDate: anyOf: - type: string @@ -4836,6 +4840,189 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/coach/reminders: + get: + tags: + - Insights + summary: List the caller's Coach reminders + description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, + soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted + returns the non-terminal set (proposed + active + due + surfaced). Coach-gated + (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. + Undecryptable rows are omitted. + parameters: + - name: status + in: query + required: false + schema: + type: string + description: Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit + for the non-terminal set. + responses: + "200": + description: The caller's reminders. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachRemindersList" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Insights + summary: Create a Coach reminder manually + description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed + grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The + per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is + narrowed from the session. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderCreate" + responses: + "201": + description: The created reminder. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderCreated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The per-user reminder cap is full. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/coach/reminders/{id}: + patch: + tags: + - Insights + summary: Confirm or update a Coach reminder's lifecycle + description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the + closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / + already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie + or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reminder id. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderPatch" + responses: + "200": + description: The reminder after the lifecycle update. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderUpdated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: Reminder not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Insights + summary: Soft-delete one Coach reminder + description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted + id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reminder id. + responses: + "200": + description: "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: + false`)." + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderDeleted" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/coach/suggested-actions: + post: + tags: + - Insights + summary: Confirm a Coach action card + description: v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED + allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a + preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE + server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. + Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner + is narrowed from the session, built field-by-field — no mass assignment, no IDOR. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSuggestedAction" + responses: + "201": + description: The action was applied (entity created server-side). + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSuggestedActionResultCreated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The per-user reminder cap is full (reminder.note). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/admin/invites: get: tags: @@ -6741,6 +6928,8 @@ paths: - type: string const: "" - type: "null" + hidden: + type: boolean responses: "200": description: Updated biomarker. @@ -8536,6 +8725,99 @@ components: compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version." + CoachReminderCreate: + type: object + properties: + note: + type: string + minLength: 1 + maxLength: 280 + when: + type: string + minLength: 1 + maxLength: 32 + metric: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z][A-Za-z0-9_]*$ + required: + - note + additionalProperties: false + description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional + closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue + (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. + Omit `when` for a recall-only note. Strict: unknown keys 422." + CoachReminderPatch: + type: object + properties: + status: + type: string + enum: + - proposed + - active + - due + - surfaced + - done + - dismissed + when: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + additionalProperties: false + description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. + `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: + unknown keys 422." + CoachSuggestedAction: + oneOf: + - type: object + properties: + actionType: + type: string + const: checkup.create + label: + type: string + minLength: 1 + maxLength: 120 + interval: + type: string + enum: + - yearly + - halfYearly + - quarterly + - monthly + required: + - actionType + - label + - interval + - type: object + properties: + actionType: + type: string + const: reminder.note + note: + type: string + minLength: 1 + maxLength: 280 + when: + type: string + minLength: 1 + maxLength: 32 + metric: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z][A-Za-z0-9_]*$ + required: + - actionType + - note + description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); + NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text + label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm. + type: object CoachMessageFeedbackRequest: type: object properties: @@ -14092,6 +14374,43 @@ components: - type: string - type: "null" description: "`...` + last 4 chars of the stored OpenAI key, or null." + responseTimeoutSeconds: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous + built-in default (~180 s). Mainly for slow local/self-hosted backends. + ocrEnabled: + type: boolean + description: v1.22 (#90) — whether a dedicated document-scan (Lab-OCR) provider is active. When false the OCR path uses + the main provider chain. + ocrProvider: + anyOf: + - type: string + enum: + - OPENAI + - ANTHROPIC + - LOCAL + - type: "null" + description: The dedicated document-scan provider, or null when unset. + ocrModel: + anyOf: + - type: string + - type: "null" + ocrBaseUrl: + anyOf: + - type: string + - type: "null" + description: Custom base URL (LOCAL OCR provider only); null otherwise. + hasOcrKey: + type: boolean + ocrKeyPreview: + anyOf: + - type: string + - type: "null" + description: "`...` + last 4 chars of the stored document-scan provider key, or null." required: - provider - model @@ -14103,6 +14422,13 @@ components: - hasLocalKey - hasOpenaiKey - openaiKeyPreview + - responseTimeoutSeconds + - ocrEnabled + - ocrProvider + - ocrModel + - ocrBaseUrl + - hasOcrKey + - ocrKeyPreview additionalProperties: false description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key. @@ -16652,9 +16978,16 @@ components: unread: type: boolean description: True when that message is newer than the last time the caller opened the Coach. + conversationId: + anyOf: + - type: string + - type: "null" + description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it + (`/coach?c=`) on the unread path. required: - nudgedAt - unread + - conversationId additionalProperties: false error: type: "null" @@ -16923,6 +17256,8 @@ components: - active - met - abandoned + - review_due + - reviewed description: "App-side lifecycle: proposed | active | met | abandoned." reviewDate: anyOf: @@ -17062,6 +17397,208 @@ components: - data - error additionalProperties: false + CoachRemindersList: + type: object + properties: + data: + type: object + properties: + reminders: + type: array + items: + $ref: "#/components/schemas/CoachReminder" + description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the + non-terminal set (proposed + active + due + surfaced). + required: + - reminders + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminder: + type: object + properties: + id: + type: string + note: + anyOf: + - type: string + - type: "null" + description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map. + metric: + anyOf: + - type: string + - type: "null" + triggerKind: + type: string + enum: + - date + - context + dueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + contextCue: + anyOf: + - type: string + - type: "null" + status: + type: string + enum: + - proposed + - active + - due + - surfaced + - done + - dismissed + source: + type: string + enum: + - sentinel + - extractor + - manual + - action + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - note + - metric + - triggerKind + - dueAt + - contextCue + - status + - source + - createdAt + - updatedAt + additionalProperties: false + description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has + rotated out of the map. + CoachReminderCreated: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/CoachReminder" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderUpdated: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/CoachReminder" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderDeleted: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / + already-deleted id (idempotent no-op). + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSuggestedActionResultCreated: + type: object + properties: + data: + $ref: "#/components/schemas/CoachSuggestedActionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSuggestedActionResult: + type: object + properties: + ok: + type: boolean + const: true + actionType: + type: string + enum: + - checkup.create + - reminder.note + reminder: {} + reminderId: + type: string + required: + - ok + - actionType + additionalProperties: false + description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; + `reminder.note` returns the created CoachReminder id. AdminInviteList: type: object properties: @@ -18622,6 +19159,14 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Minimum paired-day count enforced per pair. + emerging: + description: v1.22 — rolling early-detection pass over the trailing window; emerging pairs not yet established + retrospectively (no double-count). + $ref: "#/components/schemas/EmergingCorrelationResult" + labCorrelations: + description: v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled; + absent-degrading on sparse draws. + $ref: "#/components/schemas/LabCorrelationResult" required: - discovered - pairsTested @@ -18661,6 +19206,16 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Lag in days applied (1). + window: + description: v1.22 — which window surfaced the pair. Absent on the 180-day scan (retrospective default); `recent` for an + emerging early-detection pair. + type: string + enum: + - retrospective + - recent + provisional: + description: "v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established." + type: boolean required: - behaviour - outcome @@ -18671,6 +19226,107 @@ components: - interpretation - lagDays additionalProperties: false + EmergingCorrelationResult: + type: object + properties: + emerging: + type: array + items: + $ref: "#/components/schemas/DiscoveredCorrelation" + description: Recent-window pairs NOT already established retrospectively — the emerging signals (provisional, hedged). + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing window (days) the early pass scanned. + minPairs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Paired-day floor enforced for the early pass. + fdrQ: + type: number + description: FDR target the early pass used (tighter than the main scan). + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Pairs tested in the early window. + required: + - emerging + - windowDays + - minPairs + - fdrQ + - pairsTested + additionalProperties: false + LabCorrelationResult: + type: object + properties: + discovered: + type: array + items: + $ref: "#/components/schemas/DiscoveredLabCorrelation" + description: Lab ↔ outcome associations surviving the per-pair floor + BH-FDR. + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Lab × outcome pairs assessed. + fdrQ: + type: number + description: The FDR target the pass used. + minDraws: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minimum paired-draw count enforced per pair. + required: + - discovered + - pairsTested + - fdrQ + - minDraws + additionalProperties: false + DiscoveredLabCorrelation: + type: object + properties: + lab: + type: string + description: "`LAB:` channel key (display strips the prefix)." + outcome: + type: string + description: Outcome channel the marker tracks with (WEIGHT, BLOOD_GLUCOSE, BLOOD_PRESSURE_SYS). + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Draws paired with a usable contemporaneous outcome window. + r: + type: number + description: Pearson r over (draw value, contemporaneous outcome window-mean). + pValue: + type: number + description: Two-sided exact Student-t p-value (< 0.05). + qValue: + type: number + description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing days each draw's outcome window spanned. + interpretation: + type: string + description: Conservative, descriptive interpretation — never causal. + required: + - lab + - outcome + - n + - r + - pValue + - qValue + - windowDays + - interpretation + additionalProperties: false MoodTagTreeEnvelope: type: object properties: @@ -20212,6 +20868,8 @@ components: anyOf: - type: string - type: "null" + hidden: + type: boolean createdAt: type: string updatedAt: @@ -20225,11 +20883,14 @@ components: - panel - hasContext - context + - hidden - createdAt - updatedAt additionalProperties: false description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. A `LabResult` linking this marker resolves its unit + reference bounds from here. + presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it + (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds + from here. CreateBiomarkerResponse: type: object properties: diff --git a/knip.json b/knip.json index baae9fd35..f5d9ec71d 100644 --- a/knip.json +++ b/knip.json @@ -20,6 +20,7 @@ "src/proxy.ts", "src/instrumentation.ts", "src/lib/jobs/**/*.ts", + "src/lib/ai/coach/eval/judge.ts", "scripts/**/*.ts", "scripts/**/*.mjs", "e2e/**/*.spec.ts", diff --git a/messages/de.json b/messages/de.json index c07f8cf1f..8a3a5fb4c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -511,6 +511,7 @@ "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", + "openMetricInsights": "{type}-Auswertung öffnen", "sleepNapCaption": "+{count} Nickerchen", "sleepAwakeningsCaption": "{count} Wachphasen", "expandDay": "Einzelwerte dieses Tages anzeigen", @@ -1945,7 +1946,6 @@ "confidenceMittel": "Mittel", "confidenceGering": "Gering", "loadError": "Auswertungen konnten nicht geladen werden", - "heroGenerated": "Erstellt {time}", "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", "heroRegenerate": "Neu generieren", "heroRegenerating": "Wird generiert", @@ -1957,7 +1957,6 @@ "narrativeMonth": "Dieser Monat", "narrativePreparing": "Deine Zusammenfassung wird erstellt…", "narrativeUpdating": "Wird aktualisiert…", - "narrativeUpdated": "Aktualisiert {time}", "narrativeProvenanceLabel": "Wie das zustande kam", "narrativeProvenanceMethod": "Eine Zusammenfassung des Zeitraums in einfacher Sprache, ausschließlich aus deinen erfassten Werten und den Zusammenhängen, die die statistische Mehrfachvergleichskorrektur überstanden haben. Beschreibend, nie eine Ursache.", "narrativeProvenanceMetrics": "Basierend auf: {metrics}", @@ -2098,6 +2097,9 @@ "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", "fabLabel": "Coach öffnen", + "readAloud": "Vorlesen", + "readAloudStop": "Vorlesen beenden", + "messageTimeLabel": "Nachricht gesendet {time}", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", @@ -2320,6 +2322,33 @@ "groupYesterday": "Gestern", "groupThisWeek": "Diese Woche", "groupEarlier": "Früher" + }, + "seed": { + "bloodPressure": "Geh mit mir meinen Blutdruckverlauf der letzten 30 Tage durch — gibt es etwas, das ich im Auge behalten sollte?", + "weight": "Wie hat sich mein Gewicht zuletzt entwickelt, und ist die Richtung etwas, worauf ich reagieren sollte?", + "bmi": "Was sagt mir mein BMI-Verlauf, und wie sollte ich ihn lesen?", + "pulse": "Geh mit mir meine Pulswerte durch — ist irgendetwas ungewöhnlich?", + "restingHr": "Wie hat sich mein Ruhepuls entwickelt, und was sagt er über meine Fitness?", + "hrv": "Was sagt mir meine Herzfrequenzvariabilität zuletzt über Erholung und Stress?", + "sleep": "Geh mit mir meinen letzten Schlaf durch — Dauer, Regelmäßigkeit und was sich zu ändern lohnt.", + "mood": "Welche Muster siehst du in meiner Stimmung über die letzten Wochen?", + "medications": "Wie sieht meine Medikamentenadhärenz aus, und passt sie dazu, wie ich mich gefühlt habe?", + "steps": "Wie aktiv war ich zuletzt gemessen an meinen Schritten, und wie ist das im Vergleich zu einem gesunden Richtwert?", + "activeEnergy": "Was sagt mein Aktivenergie-Verlauf über mein Aktivitätsniveau zuletzt?", + "cardioFitness": "Was bedeutet mein Cardio-Fitness-Verlauf (VO₂max), und wie bewege ich ihn in die richtige Richtung?", + "bloodGlucose": "Geh mit mir meine letzten Glukosewerte durch — liegen sie in einem gesunden Bereich?", + "oxygenSaturation": "Was sagt mir mein Sauerstoffsättigungs-Verlauf (SpO₂)?", + "respiratoryRate": "Was sagt mein Atemfrequenz-Verlauf über meine Gesundheit?", + "workouts": "Geh mit mir meine letzten Workouts durch — Belastung, Häufigkeit und wie ich mich dazwischen erhole.", + "recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?", + "briefing": "Geh mit mir das heutige Briefing durch — worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?", + "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?", + "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief — was sticht heraus?", + "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief — was sticht heraus?", + "correlationBpCompliance": "Wie hängt meine Medikamentenadhärenz mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", + "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", + "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", + "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" } }, "relativeJustNow": "gerade eben", @@ -3240,6 +3269,7 @@ }, "vitals": { "sectionTitle": "Deine Vitalwerte", + "sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline", "loadingLabel": "Vitalwerte werden geladen", "loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.", "building": "Typischer Bereich entsteht — {count} von {target} Tagen", @@ -3366,6 +3396,7 @@ }, "scores": { "sectionTitle": "Deine Gesundheitswerte", + "sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten", "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", "recovery": "Erholung", "stress": "Stress", @@ -3611,7 +3642,10 @@ "returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei.", "daysInside": "{count} Tage drinnen", "daysOutside": "{count} Tage draußen" - } + }, + "updatedTodayAt": "Heute aktualisiert, {time}", + "updatedYesterday": "Gestern aktualisiert", + "updatedOn": "Aktualisiert am {date}" }, "thresholds": { "defaultLabel": "Default", @@ -4413,6 +4447,28 @@ "clearedToast": "{count} gespeicherte Angaben vergessen.", "clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen." }, + "coachReminders": { + "title": "Deine Erinnerungen", + "description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen. Fällige erscheinen hier und beim Coach.", + "empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.", + "loadError": "Konnte deine Erinnerungen nicht laden.", + "dueHeading": "Jetzt fällig", + "allHeading": "Gespeichert", + "duePrefix": "fällig {when}", + "confirm": "Behalten", + "markDone": "Erledigt", + "dismiss": "Verwerfen", + "deleteAria": "Diese Erinnerung löschen", + "note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit — sie wird aus dem entfernt, was der Coach sehen kann.", + "status": { + "proposed": "Vorgeschlagen", + "active": "Gespeichert", + "due": "Fällig", + "surfaced": "Angezeigt", + "done": "Erledigt", + "dismissed": "Verworfen" + } + }, "aboutMe": { "title": "Über mich", "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.", @@ -4436,7 +4492,24 @@ "completeness": "{answered} von {total} beantwortet", "questionsTitle": "Der Coach hat Rückfragen", "questionsOpenCoach": "Im Coach beantworten" - } + }, + "responseTimeoutHeading": "Antwort-Timeout", + "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.", + "responseTimeoutLabel": "Antwort-Timeout (Sekunden)", + "responseTimeoutPlaceholder": "Standard (~180)", + "responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.", + "ocrHeading": "Dokumentenscan (Laborberichte)", + "ocrBody": "Verwende einen eigenen Anbieter zum Auslesen hochgeladener Laborberichte. Ist die Option aus, nutzt der Scan deinen Hauptanbieter.", + "ocrEnableLabel": "Eigenen Anbieter für den Dokumentenscan aktivieren", + "ocrProviderLabel": "Scan-Anbieter", + "ocrProviderOption": { + "OPENAI": "OpenAI (dein API-Key)", + "ANTHROPIC": "Anthropic (Claude)", + "LOCAL": "Lokales Modell (OpenAI-kompatibel)" + }, + "ocrModelPlaceholder": "z. B. gpt-4o", + "ocrVisionHint": "Dieses Modell kann möglicherweise keine Bilder lesen. Wähle für den Dokumentenscan ein Modell mit Bildverständnis.", + "ocrKeyLabel": "API-Key" }, "dashboard": { "units": { @@ -4663,6 +4736,8 @@ "changelog": "Changelog", "docs": "Dokumentation", "linksHeading": "Quellen & Dokumentation", + "identityDescription": "Version, Lizenz und Build-Details dieser Instanz.", + "linksDescription": "Quellcode, Änderungsprotokoll und Dokumentation.", "newerAvailable": "Neue Version: {tag}" }, "testConnection": { @@ -6720,7 +6795,14 @@ "savedToast": "Biomarker gespeichert", "updatedToast": "Biomarker aktualisiert", "saveError": "Biomarker konnte nicht gespeichert werden" - } + }, + "hide": "Ausblenden", + "unhide": "Wiederherstellen", + "hiddenToast": "Biomarker ausgeblendet", + "unhiddenToast": "Biomarker wiederhergestellt", + "hideError": "Biomarker konnte nicht aktualisiert werden", + "allHidden": "Alle Biomarker sind ausgeblendet.", + "hiddenSection": "Ausgeblendet" }, "catalog": { "total-cholesterol": "Gesamtcholesterin", @@ -7041,6 +7123,25 @@ "glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan" } }, + "suggestedAction": { + "apply": "Hinzufügen", + "dismiss": "Jetzt nicht", + "applied": "Hinzugefügt", + "dismissed": "Verworfen", + "failed": "Konnte das nicht hinzufügen — bitte erneut versuchen", + "checkup": { + "title": "Vorsorge-Erinnerung hinzufügen?" + }, + "note": { + "title": "Erinnerung speichern?" + }, + "interval": { + "yearly": "Jedes Jahr", + "halfYearly": "Alle sechs Monate", + "quarterly": "Alle drei Monate", + "monthly": "Jeden Monat" + } + }, "settings": { "reminderSuggestionsLabel": "Kadenz-Vorschläge", "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen." diff --git a/messages/en.json b/messages/en.json index 4e8e9c6f5..06b38c3e8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -238,7 +238,7 @@ }, "cycle": "Cycle", "labs": "Labs", - "vorsorge": "Preventive Care", + "vorsorge": "Checkups", "illness": "Illness" }, "auth": { @@ -511,6 +511,7 @@ "previousPage": "Previous page", "nextPage": "Next page", "dailyTotalCaption": "({count} samples)", + "openMetricInsights": "Open {type} insights", "sleepNapCaption": "+{count} nap(s)", "sleepAwakeningsCaption": "{count} awakening(s)", "expandDay": "Show samples for this day", @@ -1945,7 +1946,6 @@ "confidenceMittel": "Medium", "confidenceGering": "Low", "loadError": "Failed to load insights", - "heroGenerated": "Generated {time}", "heroPersonalBaseline": "Based on your last 90 days", "heroRegenerate": "Regenerate", "heroRegenerating": "Regenerating", @@ -1957,7 +1957,6 @@ "narrativeMonth": "This month", "narrativePreparing": "Preparing your summary…", "narrativeUpdating": "Updating…", - "narrativeUpdated": "Updated {time}", "narrativeProvenanceLabel": "How this was put together", "narrativeProvenanceMethod": "A plain-language summary of the period, drawn only from your logged numbers and the associations that survived statistical multiple-comparison control. Descriptive, never a cause.", "narrativeProvenanceMetrics": "Based on: {metrics}", @@ -2098,6 +2097,9 @@ "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", "fabLabel": "Open the Coach", + "readAloud": "Read aloud", + "readAloudStop": "Stop reading", + "messageTimeLabel": "Message sent {time}", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", @@ -2320,6 +2322,33 @@ "groupYesterday": "Yesterday", "groupThisWeek": "This week", "groupEarlier": "Earlier" + }, + "seed": { + "bloodPressure": "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?", + "weight": "How has my weight been trending lately, and is the direction something to act on?", + "bmi": "What does my BMI trend tell me, and how should I read it?", + "pulse": "Walk me through my pulse readings — is anything out of the ordinary?", + "restingHr": "How has my resting heart rate been trending, and what does it say about my fitness?", + "hrv": "What is my heart-rate variability telling me about recovery and stress lately?", + "sleep": "Walk me through my recent sleep — duration, consistency, and anything worth changing.", + "mood": "What patterns do you see in my mood over the last few weeks?", + "medications": "How is my medication adherence looking, and is it lined up with how I've been feeling?", + "steps": "How active have I been lately based on my steps, and how does it compare to a healthy baseline?", + "activeEnergy": "What does my active-energy trend say about my activity level lately?", + "cardioFitness": "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?", + "bloodGlucose": "Walk me through my recent glucose readings — are they in a healthy range?", + "oxygenSaturation": "What does my blood-oxygen (SpO₂) trend tell me?", + "respiratoryRate": "What does my respiratory-rate trend say about my health?", + "workouts": "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.", + "recoveryPage": "Why is my recovery where it is right now, and what's driving it?", + "briefing": "Walk me through today's briefing — what should I focus on, and is anything worth acting on?", + "healthScore": "Why is my health score {score} out of 100, and what would move it the most?", + "periodWeek": "Walk me through how my last week went across my health metrics — what stands out?", + "periodMonth": "Walk me through how my last month went across my health metrics — what stands out?", + "correlationBpCompliance": "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.", + "correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.", + "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", + "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?" } }, "relativeJustNow": "just now", @@ -3240,6 +3269,7 @@ }, "vitals": { "sectionTitle": "Your vitals", + "sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline", "loadingLabel": "Loading your vitals", "loadError": "Could not load your vitals right now.", "building": "Building your typical range — {count} of {target} days", @@ -3366,6 +3396,7 @@ }, "scores": { "sectionTitle": "Your health scores", + "sectionSubtitle": "Composite recovery, stress and strain read from your connected devices", "loadError": "Could not load your wellness scores right now.", "recovery": "Recovery", "stress": "Stress", @@ -3611,7 +3642,10 @@ "returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed.", "daysInside": "{count} days inside", "daysOutside": "{count} days outside" - } + }, + "updatedTodayAt": "Updated today, {time}", + "updatedYesterday": "Updated yesterday", + "updatedOn": "Updated on {date}" }, "thresholds": { "defaultLabel": "Default", @@ -3677,8 +3711,8 @@ "body": "The illness journal records acute episodes and chronic diagnoses with their flares. Hidden: correlate an episode with your readings, and Rest Mode that pauses reminders. That’s how the Coach understands why values drift for a while." }, "vorsorge": { - "title": "Preventive care", - "body": "Preventive care reminds you of check-ups and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report." + "title": "Checkups", + "body": "Checkups remind you of screenings and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report." }, "cycle": { "title": "Cycle", @@ -3702,7 +3736,7 @@ }, "export": { "title": "Doctor report", - "body": "The doctor report bundles readings, medications, lab values, and preventive care as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data." + "body": "The doctor report bundles readings, medications, lab values, and checkups as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data." }, "achievements": { "title": "Achievements", @@ -4132,8 +4166,8 @@ "subtitle": "Manage the view and order of episodes." }, "vorsorge": { - "title": "Preventive care", - "description": "View, order, and individual preventive-care reminders.", + "title": "Checkups", + "description": "View, order, and individual checkup reminders.", "subtitle": "Manage the view, order, and individual reminders." }, "modules": { @@ -4413,6 +4447,28 @@ "clearedToast": "Forgot {count} stored facts.", "clearError": "Could not clear the stored facts. Please try again." }, + "coachReminders": { + "title": "Your reminders", + "description": "Things you asked the Coach to bring back later. Due ones show up here and on the Coach.", + "empty": "No reminders yet. Ask the Coach to remind you about something and it will appear here.", + "loadError": "Could not load your reminders.", + "dueHeading": "Due now", + "allHeading": "Saved", + "duePrefix": "due {when}", + "confirm": "Keep", + "markDone": "Done", + "dismiss": "Dismiss", + "deleteAria": "Delete this reminder", + "note": "Reminders are stored encrypted. Delete one any time — it is removed from what the Coach can see.", + "status": { + "proposed": "Suggested", + "active": "Saved", + "due": "Due", + "surfaced": "Surfaced", + "done": "Done", + "dismissed": "Dismissed" + } + }, "aboutMe": { "title": "About me", "description": "Personal context the Coach and the daily briefing read alongside your data.", @@ -4436,7 +4492,24 @@ "completeness": "{answered} of {total} answered", "questionsTitle": "The Coach has follow-up questions", "questionsOpenCoach": "Answer them in the Coach" - } + }, + "responseTimeoutHeading": "Response timeout", + "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.", + "responseTimeoutLabel": "Response timeout (seconds)", + "responseTimeoutPlaceholder": "Default (~180)", + "responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.", + "ocrHeading": "Document scanning (lab reports)", + "ocrBody": "Use a dedicated provider for reading uploaded lab reports. When off, scanning uses your main provider.", + "ocrEnableLabel": "Enable a dedicated document-scanning provider", + "ocrProviderLabel": "Scanning provider", + "ocrProviderOption": { + "OPENAI": "OpenAI (your API key)", + "ANTHROPIC": "Anthropic (Claude)", + "LOCAL": "Local model (OpenAI-compatible)" + }, + "ocrModelPlaceholder": "e.g. gpt-4o", + "ocrVisionHint": "This model may not be able to read images. Choose a vision-capable model for document scanning.", + "ocrKeyLabel": "API key" }, "dashboard": { "units": { @@ -4663,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources & docs", + "identityDescription": "Version, license, and build details for this instance.", + "linksDescription": "Source code, changelog, and documentation.", "newerAvailable": "Newer version: {tag}" }, "testConnection": { @@ -6483,13 +6558,13 @@ "selectionBarLabel": "Selection actions" }, "measurementReminders": { - "sectionTitle": "Preventive care", + "sectionTitle": "Checkups", "sectionDescription": "Reminders for what to measure or check, when, and where.", "addButton": "Add reminder", "markDone": "Done", "delete": "Delete", "deleteConfirmTitle": "Delete reminder?", - "deleteConfirmDescription": "This preventive-care reminder will be permanently deleted.", + "deleteConfirmDescription": "This checkup reminder will be permanently deleted.", "disabledBadge": "Off", "nextDue": { "none": "Not scheduled", @@ -6551,7 +6626,7 @@ "rruleHint": "RFC-5545, e.g. FREQ=YEARLY for an annual check.", "firstDue": "First due (optional)" }, - "pushTitle": "Preventive-care reminder", + "pushTitle": "Checkup reminder", "pushBody": "Time for: {label}", "pushLocation": "(at {location})", "edit": "Edit", @@ -6562,7 +6637,7 @@ "measureNow": "Measure now", "nextDueLabel": "Next due:", "lastDoneLabel": "Last done:", - "customize": "Customize preventive care", + "customize": "Customize checkups", "adherence": { "label": "Within interval", "onTrack": "On track", @@ -6578,8 +6653,8 @@ "empty": "No reminders to customize yet." }, "dashboard": { - "empty": "No upcoming preventive-care reminders.", - "openLink": "Open preventive care" + "empty": "No upcoming checkup reminders.", + "openLink": "Open checkups" }, "showMeasurements": "Show measurements" }, @@ -6720,7 +6795,14 @@ "savedToast": "Biomarker saved", "updatedToast": "Biomarker updated", "saveError": "Could not save the biomarker" - } + }, + "hide": "Hide", + "unhide": "Restore", + "hiddenToast": "Biomarker hidden", + "unhiddenToast": "Biomarker restored", + "hideError": "Could not update the biomarker", + "allHidden": "Every biomarker is hidden.", + "hiddenSection": "Hidden" }, "catalog": { "total-cholesterol": "Total cholesterol", @@ -7041,6 +7123,25 @@ "glucoseStructured": "Test your glucose on a structured daily schedule" } }, + "suggestedAction": { + "apply": "Add", + "dismiss": "Not now", + "applied": "Added", + "dismissed": "Dismissed", + "failed": "Couldn't add that — please try again", + "checkup": { + "title": "Add a check-up reminder?" + }, + "note": { + "title": "Save a reminder?" + }, + "interval": { + "yearly": "Every year", + "halfYearly": "Every six months", + "quarterly": "Every three months", + "monthly": "Every month" + } + }, "settings": { "reminderSuggestionsLabel": "Cadence suggestions", "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly." diff --git a/messages/es.json b/messages/es.json index fe481268d..0b49cd985 100644 --- a/messages/es.json +++ b/messages/es.json @@ -238,7 +238,7 @@ }, "cycle": "Ciclo", "labs": "Análisis", - "vorsorge": "Prevención", + "vorsorge": "Revisiones", "illness": "Enfermedad" }, "auth": { @@ -511,6 +511,7 @@ "previousPage": "Página anterior", "nextPage": "Página siguiente", "dailyTotalCaption": "({count} muestras)", + "openMetricInsights": "Abrir análisis de {type}", "sleepNapCaption": "+{count} siesta(s)", "sleepAwakeningsCaption": "{count} despertar(es)", "expandDay": "Mostrar las muestras de este día", @@ -1945,7 +1946,6 @@ "confidenceMittel": "Media", "confidenceGering": "Baja", "loadError": "No se han podido cargar los análisis", - "heroGenerated": "Generado {time}", "heroPersonalBaseline": "Basado en tus últimos 90 días", "heroRegenerate": "Regenerar", "heroRegenerating": "Generando", @@ -1957,7 +1957,6 @@ "narrativeMonth": "Este mes", "narrativePreparing": "Preparando tu resumen…", "narrativeUpdating": "Actualizando…", - "narrativeUpdated": "Actualizado {time}", "narrativeProvenanceLabel": "Cómo se elaboró esto", "narrativeProvenanceMethod": "Un resumen del período en lenguaje sencillo, basado únicamente en tus valores registrados y en las asociaciones que superaron el control estadístico de comparaciones múltiples. Descriptivo, nunca una causa.", "narrativeProvenanceMetrics": "Basado en: {metrics}", @@ -2098,6 +2097,9 @@ "composerPlaceholder": "Pregúntame sobre tus datos…", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", "fabLabel": "Abrir el coach", + "readAloud": "Leer en voz alta", + "readAloudStop": "Detener lectura", + "messageTimeLabel": "Mensaje enviado {time}", "selfContextAdopt": { "offer": "¿Añadir esta respuesta a tu información personal?", "confirm": "Añadir", @@ -2211,6 +2213,8 @@ "settingsSourcesPointerLink": "Abrir el coach", "settingsExcludeLabel": "Excluir métricas", "settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.", + "settingsContextLabel": "Enviar contexto", + "settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta.", "settingsEvidenceLabel": "Mostrar evidencia por defecto", "settingsEvidenceHint": "Abre automáticamente el cuadro de evidencias bajo cada respuesta.", "settingsDefaultWindowLabel": "Periodo de análisis por defecto", @@ -2226,8 +2230,6 @@ "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", - "settingsContextLabel": "Enviar contexto", - "settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta.", "cluster": { "cardio": { "label": "Cardiovascular", @@ -2320,6 +2322,33 @@ "groupYesterday": "Ayer", "groupThisWeek": "Esta semana", "groupEarlier": "Anteriores" + }, + "seed": { + "bloodPressure": "Repasa conmigo la tendencia de mi presión arterial de los últimos 30 días — ¿hay algo que deba vigilar?", + "weight": "¿Cómo ha evolucionado mi peso últimamente y la dirección es algo sobre lo que actuar?", + "bmi": "¿Qué me dice mi tendencia de IMC y cómo debería interpretarla?", + "pulse": "Repasa conmigo mis lecturas de pulso — ¿hay algo fuera de lo normal?", + "restingHr": "¿Cómo ha evolucionado mi frecuencia cardíaca en reposo y qué dice sobre mi forma física?", + "hrv": "¿Qué me dice mi variabilidad de la frecuencia cardíaca sobre la recuperación y el estrés últimamente?", + "sleep": "Repasa conmigo mi sueño reciente — duración, regularidad y cualquier cosa que valga la pena cambiar.", + "mood": "¿Qué patrones ves en mi estado de ánimo durante las últimas semanas?", + "medications": "¿Cómo va mi adherencia a la medicación y coincide con cómo me he sentido?", + "steps": "¿Qué tan activo he estado últimamente según mis pasos y cómo se compara con un nivel de referencia saludable?", + "activeEnergy": "¿Qué dice mi tendencia de energía activa sobre mi nivel de actividad últimamente?", + "cardioFitness": "¿Qué significa la tendencia de mi capacidad cardiorrespiratoria (VO₂ máx.) y cómo la llevo en la dirección correcta?", + "bloodGlucose": "Repasa conmigo mis últimas lecturas de glucosa — ¿están en un rango saludable?", + "oxygenSaturation": "¿Qué me dice mi tendencia de oxígeno en sangre (SpO₂)?", + "respiratoryRate": "¿Qué dice mi tendencia de frecuencia respiratoria sobre mi salud?", + "workouts": "Repasa conmigo mis entrenamientos recientes — carga, frecuencia y cómo me recupero entre ellos.", + "recoveryPage": "¿Por qué mi recuperación está donde está ahora mismo y qué la está provocando?", + "briefing": "Repasa conmigo el resumen de hoy — ¿en qué debería centrarme y hay algo en lo que valga la pena actuar?", + "healthScore": "¿Por qué mi puntuación de salud es {score} de 100 y qué la mejoraría más?", + "periodWeek": "Repasa conmigo cómo fue mi última semana en mis métricas de salud — ¿qué destaca?", + "periodMonth": "Repasa conmigo cómo fue mi último mes en mis métricas de salud — ¿qué destaca?", + "correlationBpCompliance": "¿Cómo se relaciona mi adherencia a la medicación con mi presión arterial? Explícame qué significa esta correlación para mí.", + "correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.", + "correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?", + "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?" } }, "relativeJustNow": "ahora mismo", @@ -3240,6 +3269,7 @@ }, "vitals": { "sectionTitle": "Tus constantes", + "sectionSubtitle": "Señales cardiovasculares y respiratorias en reposo frente a tu propia línea base", "loadingLabel": "Cargando tus constantes", "loadError": "No se pudieron cargar tus constantes en este momento.", "building": "Creando tu rango habitual — {count} de {target} días", @@ -3366,6 +3396,7 @@ }, "scores": { "sectionTitle": "Tus indicadores de salud", + "sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados", "loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.", "recovery": "Recuperación", "stress": "Estrés", @@ -3611,7 +3642,10 @@ "returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó.", "daysInside": "{count} días dentro", "daysOutside": "{count} días fuera" - } + }, + "updatedTodayAt": "Actualizado hoy, {time}", + "updatedYesterday": "Actualizado ayer", + "updatedOn": "Actualizado el {date}" }, "thresholds": { "defaultLabel": "Por defecto", @@ -3677,8 +3711,8 @@ "body": "El diario de enfermedades registra episodios agudos y diagnósticos crónicos con sus brotes. Oculto: correlacionar un episodio con tus mediciones y el Modo descanso que pausa los recordatorios. Así el Coach entiende por qué los valores varían un tiempo." }, "vorsorge": { - "title": "Prevención", - "body": "Prevención te recuerda revisiones y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico." + "title": "Revisiones", + "body": "Las revisiones te recuerdan controles y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico." }, "cycle": { "title": "Ciclo", @@ -3702,7 +3736,7 @@ }, "export": { "title": "Informe médico", - "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y prevención en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados." + "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y revisiones en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados." }, "achievements": { "title": "Logros", @@ -4132,8 +4166,8 @@ "subtitle": "Gestiona la vista y el orden de los episodios." }, "vorsorge": { - "title": "Prevención", - "description": "Vista, orden y recordatorios de prevención individuales.", + "title": "Revisiones", + "description": "Vista, orden y recordatorios de revisión individuales.", "subtitle": "Gestiona la vista, el orden y los recordatorios individuales." }, "modules": { @@ -4413,6 +4447,28 @@ "clearedToast": "Se olvidaron {count} datos guardados.", "clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo." }, + "coachReminders": { + "title": "Tus recordatorios", + "description": "Cosas que le pediste al Coach que retomara más tarde. Las que vencen aparecen aquí y en el Coach.", + "empty": "Aún no hay recordatorios. Pídele al Coach que te recuerde algo y aparecerá aquí.", + "loadError": "No se pudieron cargar tus recordatorios.", + "dueHeading": "Vencen ahora", + "allHeading": "Guardados", + "duePrefix": "vence {when}", + "confirm": "Conservar", + "markDone": "Hecho", + "dismiss": "Descartar", + "deleteAria": "Eliminar este recordatorio", + "note": "Los recordatorios se guardan cifrados. Elimina uno cuando quieras — desaparece de lo que el Coach puede ver.", + "status": { + "proposed": "Sugerido", + "active": "Guardado", + "due": "Vencido", + "surfaced": "Mostrado", + "done": "Hecho", + "dismissed": "Descartado" + } + }, "aboutMe": { "title": "Sobre mí", "description": "Contexto personal que el Coach y el informe diario leen junto con tus datos.", @@ -4436,7 +4492,24 @@ "completeness": "{answered} de {total} respondidas", "questionsTitle": "El Coach tiene preguntas", "questionsOpenCoach": "Responderlas en el Coach" - } + }, + "responseTimeoutHeading": "Tiempo de espera de la respuesta", + "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.", + "responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)", + "responseTimeoutPlaceholder": "Predeterminado (~180)", + "responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.", + "ocrHeading": "Escaneo de documentos (informes de laboratorio)", + "ocrBody": "Usa un proveedor dedicado para leer los informes de laboratorio subidos. Si está desactivado, el escaneo usa tu proveedor principal.", + "ocrEnableLabel": "Activar un proveedor dedicado para el escaneo de documentos", + "ocrProviderLabel": "Proveedor de escaneo", + "ocrProviderOption": { + "OPENAI": "OpenAI (tu clave de API)", + "ANTHROPIC": "Anthropic (Claude)", + "LOCAL": "Modelo local (compatible con OpenAI)" + }, + "ocrModelPlaceholder": "p. ej. gpt-4o", + "ocrVisionHint": "Puede que este modelo no pueda leer imágenes. Elige un modelo con capacidad de visión para el escaneo de documentos.", + "ocrKeyLabel": "Clave de API" }, "dashboard": { "units": { @@ -4663,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentación", "linksHeading": "Fuentes y documentación", + "identityDescription": "Versión, licencia y detalles de compilación de esta instancia.", + "linksDescription": "Código fuente, registro de cambios y documentación.", "newerAvailable": "Nueva versión: {tag}" }, "testConnection": { @@ -6483,13 +6558,13 @@ "selectionBarLabel": "Acciones de selección" }, "measurementReminders": { - "sectionTitle": "Prevención", + "sectionTitle": "Revisiones", "sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.", "addButton": "Añadir recordatorio", "markDone": "Hecho", "delete": "Eliminar", "deleteConfirmTitle": "¿Eliminar recordatorio?", - "deleteConfirmDescription": "Este recordatorio de prevención se eliminará de forma permanente.", + "deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.", "disabledBadge": "Apagado", "nextDue": { "none": "Sin programar", @@ -6551,7 +6626,7 @@ "rruleHint": "RFC-5545, p. ej. FREQ=YEARLY para una revisión anual.", "firstDue": "Primer vencimiento (opcional)" }, - "pushTitle": "Recordatorio de prevención", + "pushTitle": "Recordatorio de revisión", "pushBody": "Toca: {label}", "pushLocation": "(en {location})", "edit": "Editar", @@ -6562,7 +6637,7 @@ "measureNow": "Medir ahora", "nextDueLabel": "Próximo vencimiento:", "lastDoneLabel": "Última vez:", - "customize": "Personalizar prevención", + "customize": "Personalizar revisiones", "adherence": { "label": "Dentro del intervalo", "onTrack": "Al día", @@ -6578,8 +6653,8 @@ "empty": "Aún no hay recordatorios para personalizar." }, "dashboard": { - "empty": "No hay recordatorios de prevención próximos.", - "openLink": "Abrir prevención" + "empty": "No hay revisiones próximas.", + "openLink": "Abrir revisiones" }, "showMeasurements": "Ver mediciones" }, @@ -6720,7 +6795,14 @@ "savedToast": "Biomarcador guardado", "updatedToast": "Biomarcador actualizado", "saveError": "No se pudo guardar el biomarcador" - } + }, + "hide": "Ocultar", + "unhide": "Restaurar", + "hiddenToast": "Biomarcador ocultado", + "unhiddenToast": "Biomarcador restaurado", + "hideError": "No se pudo actualizar el biomarcador", + "allHidden": "Todos los biomarcadores están ocultos.", + "hiddenSection": "Ocultos" }, "catalog": { "total-cholesterol": "Colesterol total", @@ -7041,6 +7123,25 @@ "glucoseStructured": "Mide tu glucosa con un horario diario estructurado" } }, + "suggestedAction": { + "apply": "Añadir", + "dismiss": "Ahora no", + "applied": "Añadido", + "dismissed": "Descartado", + "failed": "No se pudo añadir — inténtalo de nuevo", + "checkup": { + "title": "¿Añadir un recordatorio de revisión?" + }, + "note": { + "title": "¿Guardar un recordatorio?" + }, + "interval": { + "yearly": "Cada año", + "halfYearly": "Cada seis meses", + "quarterly": "Cada tres meses", + "monthly": "Cada mes" + } + }, "settings": { "reminderSuggestionsLabel": "Sugerencias de frecuencia", "reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad." diff --git a/messages/fr.json b/messages/fr.json index 11ab9eee5..971a1bbd8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -238,7 +238,7 @@ }, "cycle": "Cycle", "labs": "Analyses", - "vorsorge": "Prévention", + "vorsorge": "Examens", "illness": "Maladie" }, "auth": { @@ -511,6 +511,7 @@ "previousPage": "Page précédente", "nextPage": "Page suivante", "dailyTotalCaption": "({count} échantillons)", + "openMetricInsights": "Ouvrir l'analyse {type}", "sleepNapCaption": "+{count} sieste(s)", "sleepAwakeningsCaption": "{count} réveil(s)", "expandDay": "Afficher les échantillons de ce jour", @@ -1945,7 +1946,6 @@ "confidenceMittel": "Moyenne", "confidenceGering": "Faible", "loadError": "Impossible de charger les analyses", - "heroGenerated": "Généré {time}", "heroPersonalBaseline": "Basé sur tes 90 derniers jours", "heroRegenerate": "Régénérer", "heroRegenerating": "Génération en cours", @@ -1957,7 +1957,6 @@ "narrativeMonth": "Ce mois-ci", "narrativePreparing": "Préparation de votre résumé…", "narrativeUpdating": "Mise à jour…", - "narrativeUpdated": "Mis à jour {time}", "narrativeProvenanceLabel": "Comment cela a été établi", "narrativeProvenanceMethod": "Un résumé de la période en langage clair, établi uniquement à partir de vos valeurs enregistrées et des associations ayant survécu au contrôle statistique des comparaisons multiples. Descriptif, jamais une cause.", "narrativeProvenanceMetrics": "Basé sur : {metrics}", @@ -2098,6 +2097,9 @@ "composerPlaceholder": "Pose-moi une question sur tes données…", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", "fabLabel": "Ouvrir le coach", + "readAloud": "Lire à voix haute", + "readAloudStop": "Arrêter la lecture", + "messageTimeLabel": "Message envoyé {time}", "selfContextAdopt": { "offer": "Ajouter cette réponse à vos informations personnelles ?", "confirm": "Ajouter", @@ -2211,6 +2213,8 @@ "settingsSourcesPointerLink": "Ouvrir le coach", "settingsExcludeLabel": "Exclure des métriques", "settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.", + "settingsContextLabel": "Envoyer le contexte", + "settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse.", "settingsEvidenceLabel": "Afficher les preuves par défaut", "settingsEvidenceHint": "Ouvre automatiquement l’encadré preuves sous chaque réponse.", "settingsDefaultWindowLabel": "Période d’analyse par défaut", @@ -2226,8 +2230,6 @@ "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", - "settingsContextLabel": "Envoyer le contexte", - "settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse.", "cluster": { "cardio": { "label": "Cardiovasculaire", @@ -2320,6 +2322,33 @@ "groupYesterday": "Hier", "groupThisWeek": "Cette semaine", "groupEarlier": "Plus tôt" + }, + "seed": { + "bloodPressure": "Passe en revue avec moi la tendance de ma tension artérielle sur les 30 derniers jours — y a-t-il quelque chose à surveiller ?", + "weight": "Comment mon poids a-t-il évolué ces derniers temps, et la tendance mérite-t-elle d'agir ?", + "bmi": "Que m'indique la tendance de mon IMC, et comment dois-je l'interpréter ?", + "pulse": "Passe en revue avec moi mes mesures de pouls — y a-t-il quelque chose d'inhabituel ?", + "restingHr": "Comment ma fréquence cardiaque au repos a-t-elle évolué, et que dit-elle de ma condition physique ?", + "hrv": "Que m'indique ma variabilité de la fréquence cardiaque sur ma récupération et mon stress ces derniers temps ?", + "sleep": "Passe en revue avec moi mon sommeil récent — durée, régularité et tout ce qui mériterait d'être changé.", + "mood": "Quels schémas observes-tu dans mon humeur ces dernières semaines ?", + "medications": "Où en est mon observance médicamenteuse, et correspond-elle à la façon dont je me sens ?", + "steps": "À quel point ai-je été actif ces derniers temps d'après mes pas, et comment cela se compare-t-il à un niveau de référence sain ?", + "activeEnergy": "Que dit ma tendance de dépense énergétique active sur mon niveau d'activité ces derniers temps ?", + "cardioFitness": "Que signifie la tendance de ma condition cardio (VO₂ max), et comment la faire évoluer dans le bon sens ?", + "bloodGlucose": "Passe en revue avec moi mes dernières mesures de glycémie — sont-elles dans une fourchette saine ?", + "oxygenSaturation": "Que m'indique ma tendance de saturation en oxygène (SpO₂) ?", + "respiratoryRate": "Que dit ma tendance de fréquence respiratoire sur ma santé ?", + "workouts": "Passe en revue avec moi mes séances d'entraînement récentes — charge, fréquence et comment je récupère entre elles.", + "recoveryPage": "Pourquoi ma récupération en est-elle là en ce moment, et qu'est-ce qui l'explique ?", + "briefing": "Passe en revue avec moi le briefing d'aujourd'hui — sur quoi devrais-je me concentrer, et y a-t-il quelque chose à entreprendre ?", + "healthScore": "Pourquoi mon score de santé est-il de {score} sur 100, et qu'est-ce qui le ferait le plus progresser ?", + "periodWeek": "Passe en revue avec moi comment s'est déroulée ma dernière semaine sur mes indicateurs de santé — qu'est-ce qui ressort ?", + "periodMonth": "Passe en revue avec moi comment s'est déroulé mon dernier mois sur mes indicateurs de santé — qu'est-ce qui ressort ?", + "correlationBpCompliance": "Quel est le lien entre mon observance médicamenteuse et ma tension artérielle ? Explique-moi ce que cette corrélation signifie pour moi.", + "correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.", + "correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?", + "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" } }, "relativeJustNow": "à l’instant", @@ -3240,6 +3269,7 @@ }, "vitals": { "sectionTitle": "Vos constantes", + "sectionSubtitle": "Signaux cardiovasculaires et respiratoires au repos par rapport à ta propre référence", "loadingLabel": "Chargement de vos constantes", "loadError": "Impossible de charger vos constantes pour le moment.", "building": "Construction de votre plage habituelle — {count} sur {target} jours", @@ -3366,6 +3396,7 @@ }, "scores": { "sectionTitle": "Tes indicateurs de santé", + "sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés", "loadError": "Impossible de charger vos scores de bien-être pour le moment.", "recovery": "Récupération", "stress": "Stress", @@ -3611,7 +3642,10 @@ "returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé.", "daysInside": "{count} jours dans la plage", "daysOutside": "{count} jours hors plage" - } + }, + "updatedTodayAt": "Mis à jour aujourd'hui, {time}", + "updatedYesterday": "Mis à jour hier", + "updatedOn": "Mis à jour le {date}" }, "thresholds": { "defaultLabel": "Par défaut", @@ -3677,8 +3711,8 @@ "body": "Le journal des maladies consigne les épisodes aigus et les diagnostics chroniques avec leurs poussées. Caché : corréler un épisode à vos mesures et le Mode repos qui suspend les rappels. Le Coach comprend ainsi pourquoi les valeurs dérivent un temps." }, "vorsorge": { - "title": "Prévention", - "body": "Prévention vous rappelle examens et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical." + "title": "Examens", + "body": "Les examens vous rappellent dépistages et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical." }, "cycle": { "title": "Cycle", @@ -3702,7 +3736,7 @@ }, "export": { "title": "Rapport médical", - "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et prévention en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues." + "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et examens en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues." }, "achievements": { "title": "Réussites", @@ -4132,8 +4166,8 @@ "subtitle": "Gérez l’affichage et l’ordre des épisodes." }, "vorsorge": { - "title": "Prévention", - "description": "Affichage, ordre et rappels de prévention individuels.", + "title": "Examens", + "description": "Affichage, ordre et rappels d’examen individuels.", "subtitle": "Gérez l’affichage, l’ordre et les rappels individuels." }, "modules": { @@ -4413,6 +4447,28 @@ "clearedToast": "{count} informations enregistrées oubliées.", "clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer." }, + "coachReminders": { + "title": "Tes rappels", + "description": "Les choses que tu as demandé au Coach de te rappeler plus tard. Celles qui arrivent à échéance apparaissent ici et chez le Coach.", + "empty": "Aucun rappel pour l'instant. Demande au Coach de te rappeler quelque chose et cela apparaîtra ici.", + "loadError": "Impossible de charger tes rappels.", + "dueHeading": "À échéance", + "allHeading": "Enregistrés", + "duePrefix": "à échéance {when}", + "confirm": "Garder", + "markDone": "Terminé", + "dismiss": "Ignorer", + "deleteAria": "Supprimer ce rappel", + "note": "Les rappels sont stockés chiffrés. Supprime-en un à tout moment — il disparaît de ce que le Coach peut voir.", + "status": { + "proposed": "Suggéré", + "active": "Enregistré", + "due": "À échéance", + "surfaced": "Affiché", + "done": "Terminé", + "dismissed": "Ignoré" + } + }, "aboutMe": { "title": "À propos de moi", "description": "Contexte personnel que le Coach et le briefing quotidien lisent en plus de vos données.", @@ -4436,7 +4492,24 @@ "completeness": "{answered} sur {total} renseignés", "questionsTitle": "Le Coach a des questions", "questionsOpenCoach": "Y répondre dans le Coach" - } + }, + "responseTimeoutHeading": "Délai de réponse", + "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.", + "responseTimeoutLabel": "Délai de réponse (secondes)", + "responseTimeoutPlaceholder": "Par défaut (~180)", + "responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.", + "ocrHeading": "Numérisation de documents (comptes rendus de laboratoire)", + "ocrBody": "Utilisez un fournisseur dédié pour lire les comptes rendus de laboratoire importés. Désactivé, la numérisation utilise votre fournisseur principal.", + "ocrEnableLabel": "Activer un fournisseur dédié à la numérisation de documents", + "ocrProviderLabel": "Fournisseur de numérisation", + "ocrProviderOption": { + "OPENAI": "OpenAI (votre clé API)", + "ANTHROPIC": "Anthropic (Claude)", + "LOCAL": "Modèle local (compatible OpenAI)" + }, + "ocrModelPlaceholder": "p. ex. gpt-4o", + "ocrVisionHint": "Ce modèle ne peut peut-être pas lire les images. Choisissez un modèle capable de vision pour la numérisation de documents.", + "ocrKeyLabel": "Clé API" }, "dashboard": { "units": { @@ -4663,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources et documentation", + "identityDescription": "Version, licence et détails de build de cette instance.", + "linksDescription": "Code source, journal des modifications et documentation.", "newerAvailable": "Nouvelle version : {tag}" }, "testConnection": { @@ -6483,13 +6558,13 @@ "selectionBarLabel": "Actions de sélection" }, "measurementReminders": { - "sectionTitle": "Prévention", + "sectionTitle": "Examens", "sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.", "addButton": "Ajouter un rappel", "markDone": "Terminé", "delete": "Supprimer", "deleteConfirmTitle": "Supprimer le rappel ?", - "deleteConfirmDescription": "Ce rappel de prévention sera définitivement supprimé.", + "deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.", "disabledBadge": "Désactivé", "nextDue": { "none": "Non planifié", @@ -6551,7 +6626,7 @@ "rruleHint": "RFC-5545, p. ex. FREQ=YEARLY pour un contrôle annuel.", "firstDue": "Première échéance (facultatif)" }, - "pushTitle": "Rappel de prévention", + "pushTitle": "Rappel d’examen", "pushBody": "C'est l'heure : {label}", "pushLocation": "(chez {location})", "edit": "Modifier", @@ -6562,7 +6637,7 @@ "measureNow": "Mesurer maintenant", "nextDueLabel": "Prochaine échéance :", "lastDoneLabel": "Dernière fois :", - "customize": "Personnaliser la prévention", + "customize": "Personnaliser les examens", "adherence": { "label": "Dans l'intervalle", "onTrack": "Dans les temps", @@ -6578,8 +6653,8 @@ "empty": "Aucun rappel à personnaliser pour l'instant." }, "dashboard": { - "empty": "Aucun rappel de prévention à venir.", - "openLink": "Ouvrir la prévention" + "empty": "Aucun rappel d’examen à venir.", + "openLink": "Ouvrir les examens" }, "showMeasurements": "Afficher les mesures" }, @@ -6720,7 +6795,14 @@ "savedToast": "Biomarqueur enregistré", "updatedToast": "Biomarqueur mis à jour", "saveError": "Impossible d'enregistrer le biomarqueur" - } + }, + "hide": "Masquer", + "unhide": "Restaurer", + "hiddenToast": "Biomarqueur masqué", + "unhiddenToast": "Biomarqueur restauré", + "hideError": "Impossible de mettre à jour le biomarqueur", + "allHidden": "Tous les biomarqueurs sont masqués.", + "hiddenSection": "Masqués" }, "catalog": { "total-cholesterol": "Cholestérol total", @@ -7041,6 +7123,25 @@ "glucoseStructured": "Mesurez votre glycémie selon un horaire quotidien structuré" } }, + "suggestedAction": { + "apply": "Ajouter", + "dismiss": "Pas maintenant", + "applied": "Ajouté", + "dismissed": "Ignoré", + "failed": "Impossible d'ajouter — réessaie", + "checkup": { + "title": "Ajouter un rappel d'examen ?" + }, + "note": { + "title": "Enregistrer un rappel ?" + }, + "interval": { + "yearly": "Tous les ans", + "halfYearly": "Tous les six mois", + "quarterly": "Tous les trois mois", + "monthly": "Tous les mois" + } + }, "settings": { "reminderSuggestionsLabel": "Suggestions de fréquence", "reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement." diff --git a/messages/it.json b/messages/it.json index b3f1f0c92..4b762cd6b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -238,7 +238,7 @@ }, "cycle": "Ciclo", "labs": "Esami", - "vorsorge": "Prevenzione", + "vorsorge": "Controlli", "illness": "Malattia" }, "auth": { @@ -511,6 +511,7 @@ "previousPage": "Pagina precedente", "nextPage": "Pagina successiva", "dailyTotalCaption": "({count} campioni)", + "openMetricInsights": "Apri l'analisi {type}", "sleepNapCaption": "+{count} pisolino/i", "sleepAwakeningsCaption": "{count} risveglio/i", "expandDay": "Mostra i campioni di questa giornata", @@ -1945,7 +1946,6 @@ "confidenceMittel": "Media", "confidenceGering": "Bassa", "loadError": "Impossibile caricare le analisi", - "heroGenerated": "Generato {time}", "heroPersonalBaseline": "Basato sugli ultimi 90 giorni", "heroRegenerate": "Rigenera", "heroRegenerating": "Generazione in corso", @@ -1957,7 +1957,6 @@ "narrativeMonth": "Questo mese", "narrativePreparing": "Preparazione del riepilogo…", "narrativeUpdating": "Aggiornamento…", - "narrativeUpdated": "Aggiornato {time}", "narrativeProvenanceLabel": "Come è stato realizzato", "narrativeProvenanceMethod": "Un riepilogo del periodo in linguaggio semplice, basato esclusivamente sui valori registrati e sulle associazioni che hanno superato il controllo statistico dei confronti multipli. Descrittivo, mai una causa.", "narrativeProvenanceMetrics": "Basato su: {metrics}", @@ -2098,6 +2097,9 @@ "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", "fabLabel": "Apri il coach", + "readAloud": "Leggi ad alta voce", + "readAloudStop": "Interrompi lettura", + "messageTimeLabel": "Messaggio inviato {time}", "selfContextAdopt": { "offer": "Aggiungere questa risposta alle tue informazioni personali?", "confirm": "Aggiungi", @@ -2211,6 +2213,8 @@ "settingsSourcesPointerLink": "Apri il coach", "settingsExcludeLabel": "Escludi metriche", "settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.", + "settingsContextLabel": "Invia contesto", + "settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta.", "settingsEvidenceLabel": "Mostra le evidenze per default", "settingsEvidenceHint": "Apre automaticamente il riquadro delle evidenze sotto ogni risposta.", "settingsDefaultWindowLabel": "Periodo di analisi predefinito", @@ -2226,8 +2230,6 @@ "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", - "settingsContextLabel": "Invia contesto", - "settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta.", "cluster": { "cardio": { "label": "Cardiovascolare", @@ -2320,6 +2322,33 @@ "groupYesterday": "Ieri", "groupThisWeek": "Questa settimana", "groupEarlier": "Precedenti" + }, + "seed": { + "bloodPressure": "Esamina con me l'andamento della mia pressione arteriosa negli ultimi 30 giorni — c'è qualcosa che dovrei tenere d'occhio?", + "weight": "Come si è evoluto il mio peso ultimamente e la direzione è qualcosa su cui intervenire?", + "bmi": "Cosa mi dice l'andamento del mio BMI e come dovrei interpretarlo?", + "pulse": "Esamina con me le mie letture del battito — c'è qualcosa fuori dal comune?", + "restingHr": "Come si è evoluta la mia frequenza cardiaca a riposo e cosa dice sulla mia forma fisica?", + "hrv": "Cosa mi dice la mia variabilità della frequenza cardiaca su recupero e stress ultimamente?", + "sleep": "Esamina con me il mio sonno recente — durata, regolarità e qualsiasi cosa valga la pena cambiare.", + "mood": "Quali schemi noti nel mio umore nelle ultime settimane?", + "medications": "Com'è la mia aderenza alla terapia e corrisponde a come mi sono sentito?", + "steps": "Quanto sono stato attivo ultimamente in base ai miei passi e come si confronta con un valore di riferimento sano?", + "activeEnergy": "Cosa dice il mio andamento dell'energia attiva sul mio livello di attività ultimamente?", + "cardioFitness": "Cosa significa l'andamento della mia forma cardiorespiratoria (VO₂ max) e come la indirizzo nella direzione giusta?", + "bloodGlucose": "Esamina con me le mie ultime letture di glicemia — sono in un intervallo sano?", + "oxygenSaturation": "Cosa mi dice l'andamento della mia saturazione di ossigeno (SpO₂)?", + "respiratoryRate": "Cosa dice il mio andamento della frequenza respiratoria sulla mia salute?", + "workouts": "Esamina con me i miei allenamenti recenti — carico, frequenza e come recupero tra uno e l'altro.", + "recoveryPage": "Perché il mio recupero è dov'è in questo momento e cosa lo determina?", + "briefing": "Esamina con me il riepilogo di oggi — su cosa dovrei concentrarmi e c'è qualcosa su cui vale la pena agire?", + "healthScore": "Perché il mio punteggio di salute è {score} su 100 e cosa lo migliorerebbe di più?", + "periodWeek": "Esamina con me com'è andata la mia ultima settimana sui miei parametri di salute — cosa spicca?", + "periodMonth": "Esamina con me com'è andato il mio ultimo mese sui miei parametri di salute — cosa spicca?", + "correlationBpCompliance": "Che relazione c'è tra la mia aderenza alla terapia e la mia pressione arteriosa? Spiegami cosa significa per me questa correlazione.", + "correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.", + "correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?", + "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?" } }, "relativeJustNow": "proprio ora", @@ -3240,6 +3269,7 @@ }, "vitals": { "sectionTitle": "I tuoi parametri vitali", + "sectionSubtitle": "Segnali cardiovascolari e respiratori a riposo rispetto alla tua baseline personale", "loadingLabel": "Caricamento dei tuoi parametri vitali", "loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.", "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", @@ -3366,6 +3396,7 @@ }, "scores": { "sectionTitle": "I tuoi indicatori di salute", + "sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi", "loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.", "recovery": "Recupero", "stress": "Stress", @@ -3611,7 +3642,10 @@ "returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata.", "daysInside": "{count} giorni dentro", "daysOutside": "{count} giorni fuori" - } + }, + "updatedTodayAt": "Aggiornato oggi, {time}", + "updatedYesterday": "Aggiornato ieri", + "updatedOn": "Aggiornato il {date}" }, "thresholds": { "defaultLabel": "Predefinito", @@ -3677,8 +3711,8 @@ "body": "Il diario delle malattie registra episodi acuti e diagnosi croniche con le loro riacutizzazioni. Nascosto: correlare un episodio alle tue misurazioni e la Modalità riposo che mette in pausa i promemoria. Così il Coach capisce perché i valori variano per un periodo." }, "vorsorge": { - "title": "Prevenzione", - "body": "Prevenzione ti ricorda controlli e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico." + "title": "Controlli", + "body": "I controlli ti ricordano screening e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico." }, "cycle": { "title": "Ciclo", @@ -3702,7 +3736,7 @@ }, "export": { "title": "Referto medico", - "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e prevenzione in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati." + "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e controlli in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati." }, "achievements": { "title": "Traguardi", @@ -4132,8 +4166,8 @@ "subtitle": "Gestisci la vista e l’ordine degli episodi." }, "vorsorge": { - "title": "Prevenzione", - "description": "Vista, ordine e singoli promemoria di prevenzione.", + "title": "Controlli", + "description": "Vista, ordine e singoli promemoria di controllo.", "subtitle": "Gestisci la vista, l’ordine e i singoli promemoria." }, "modules": { @@ -4413,6 +4447,28 @@ "clearedToast": "Dimenticate {count} informazioni memorizzate.", "clearError": "Impossibile cancellare le informazioni memorizzate. Riprova." }, + "coachReminders": { + "title": "I tuoi promemoria", + "description": "Cose che hai chiesto al Coach di riprendere più tardi. Quelle in scadenza compaiono qui e nel Coach.", + "empty": "Ancora nessun promemoria. Chiedi al Coach di ricordarti qualcosa e comparirà qui.", + "loadError": "Impossibile caricare i tuoi promemoria.", + "dueHeading": "In scadenza", + "allHeading": "Salvati", + "duePrefix": "in scadenza {when}", + "confirm": "Mantieni", + "markDone": "Fatto", + "dismiss": "Ignora", + "deleteAria": "Elimina questo promemoria", + "note": "I promemoria sono memorizzati cifrati. Eliminane uno in qualsiasi momento — viene rimosso da ciò che il Coach può vedere.", + "status": { + "proposed": "Suggerito", + "active": "Salvato", + "due": "In scadenza", + "surfaced": "Mostrato", + "done": "Fatto", + "dismissed": "Ignorato" + } + }, "aboutMe": { "title": "Su di me", "description": "Contesto personale che il Coach e il briefing giornaliero leggono insieme ai tuoi dati.", @@ -4436,7 +4492,24 @@ "completeness": "{answered} su {total} compilati", "questionsTitle": "Il Coach ha alcune domande", "questionsOpenCoach": "Rispondi nel Coach" - } + }, + "responseTimeoutHeading": "Timeout della risposta", + "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.", + "responseTimeoutLabel": "Timeout della risposta (secondi)", + "responseTimeoutPlaceholder": "Predefinito (~180)", + "responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.", + "ocrHeading": "Scansione documenti (referti di laboratorio)", + "ocrBody": "Usa un provider dedicato per leggere i referti di laboratorio caricati. Se disattivato, la scansione usa il tuo provider principale.", + "ocrEnableLabel": "Attiva un provider dedicato per la scansione dei documenti", + "ocrProviderLabel": "Provider di scansione", + "ocrProviderOption": { + "OPENAI": "OpenAI (la tua chiave API)", + "ANTHROPIC": "Anthropic (Claude)", + "LOCAL": "Modello locale (compatibile con OpenAI)" + }, + "ocrModelPlaceholder": "es. gpt-4o", + "ocrVisionHint": "Questo modello potrebbe non essere in grado di leggere le immagini. Scegli un modello con capacità di visione per la scansione dei documenti.", + "ocrKeyLabel": "Chiave API" }, "dashboard": { "units": { @@ -4663,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentazione", "linksHeading": "Fonti e documentazione", + "identityDescription": "Versione, licenza e dettagli di build di questa istanza.", + "linksDescription": "Codice sorgente, registro delle modifiche e documentazione.", "newerAvailable": "Nuova versione: {tag}" }, "testConnection": { @@ -6483,13 +6558,13 @@ "selectionBarLabel": "Azioni di selezione" }, "measurementReminders": { - "sectionTitle": "Prevenzione", + "sectionTitle": "Controlli", "sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.", "addButton": "Aggiungi promemoria", "markDone": "Fatto", "delete": "Elimina", "deleteConfirmTitle": "Eliminare il promemoria?", - "deleteConfirmDescription": "Questo promemoria di prevenzione verrà eliminato definitivamente.", + "deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.", "disabledBadge": "Disattivato", "nextDue": { "none": "Non pianificato", @@ -6551,7 +6626,7 @@ "rruleHint": "RFC-5545, es. FREQ=YEARLY per un controllo annuale.", "firstDue": "Prima scadenza (facoltativo)" }, - "pushTitle": "Promemoria di prevenzione", + "pushTitle": "Promemoria di controllo", "pushBody": "È ora di: {label}", "pushLocation": "(presso {location})", "edit": "Modifica", @@ -6562,7 +6637,7 @@ "measureNow": "Misura ora", "nextDueLabel": "Prossima scadenza:", "lastDoneLabel": "Ultima volta:", - "customize": "Personalizza prevenzione", + "customize": "Personalizza controlli", "adherence": { "label": "Nell'intervallo", "onTrack": "In linea", @@ -6578,8 +6653,8 @@ "empty": "Nessun promemoria da personalizzare." }, "dashboard": { - "empty": "Nessun promemoria di prevenzione in arrivo.", - "openLink": "Apri prevenzione" + "empty": "Nessun promemoria di controllo in arrivo.", + "openLink": "Apri controlli" }, "showMeasurements": "Mostra le misurazioni" }, @@ -6720,7 +6795,14 @@ "savedToast": "Biomarcatore salvato", "updatedToast": "Biomarcatore aggiornato", "saveError": "Impossibile salvare il biomarcatore" - } + }, + "hide": "Nascondi", + "unhide": "Ripristina", + "hiddenToast": "Biomarcatore nascosto", + "unhiddenToast": "Biomarcatore ripristinato", + "hideError": "Impossibile aggiornare il biomarcatore", + "allHidden": "Tutti i biomarcatori sono nascosti.", + "hiddenSection": "Nascosti" }, "catalog": { "total-cholesterol": "Colesterolo totale", @@ -7041,6 +7123,25 @@ "glucoseStructured": "Misura la glicemia con un programma giornaliero strutturato" } }, + "suggestedAction": { + "apply": "Aggiungi", + "dismiss": "Non ora", + "applied": "Aggiunto", + "dismissed": "Ignorato", + "failed": "Impossibile aggiungere — riprova", + "checkup": { + "title": "Aggiungere un promemoria di controllo?" + }, + "note": { + "title": "Salvare un promemoria?" + }, + "interval": { + "yearly": "Ogni anno", + "halfYearly": "Ogni sei mesi", + "quarterly": "Ogni tre mesi", + "monthly": "Ogni mese" + } + }, "settings": { "reminderSuggestionsLabel": "Suggerimenti di cadenza", "reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente." diff --git a/messages/pl.json b/messages/pl.json index d227d654f..664af0d8d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -238,7 +238,7 @@ }, "cycle": "Cykl", "labs": "Wyniki badań", - "vorsorge": "Profilaktyka", + "vorsorge": "Badania", "illness": "Choroba" }, "auth": { @@ -511,6 +511,7 @@ "previousPage": "Poprzednia strona", "nextPage": "Następna strona", "dailyTotalCaption": "({count} pomiarów)", + "openMetricInsights": "Otwórz analizę {type}", "sleepNapCaption": "+{count} drzemka/i", "sleepAwakeningsCaption": "{count} przebudzenia", "expandDay": "Pokaż pomiary z tego dnia", @@ -1945,7 +1946,6 @@ "confidenceMittel": "Średnia", "confidenceGering": "Niska", "loadError": "Nie udało się załadować analiz", - "heroGenerated": "Wygenerowano {time}", "heroPersonalBaseline": "Na podstawie ostatnich 90 dni", "heroRegenerate": "Wygeneruj ponownie", "heroRegenerating": "Generowanie", @@ -1957,7 +1957,6 @@ "narrativeMonth": "Ten miesiąc", "narrativePreparing": "Przygotowywanie podsumowania…", "narrativeUpdating": "Aktualizowanie…", - "narrativeUpdated": "Zaktualizowano {time}", "narrativeProvenanceLabel": "Jak to powstało", "narrativeProvenanceMethod": "Podsumowanie okresu w prostym języku, oparte wyłącznie na zarejestrowanych wartościach i powiązaniach, które przeszły statystyczną korektę dla porównań wielokrotnych. Opisowe, nigdy nie przyczynowe.", "narrativeProvenanceMetrics": "Na podstawie: {metrics}", @@ -2098,6 +2097,9 @@ "composerPlaceholder": "Zapytaj o swoje dane…", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", "fabLabel": "Otwórz trenera", + "readAloud": "Czytaj na głos", + "readAloudStop": "Zatrzymaj czytanie", + "messageTimeLabel": "Wiadomość wysłana {time}", "selfContextAdopt": { "offer": "Dodać tę odpowiedź do Twoich informacji o sobie?", "confirm": "Dodaj", @@ -2211,6 +2213,8 @@ "settingsSourcesPointerLink": "Otwórz coacha", "settingsExcludeLabel": "Wyklucz metryki", "settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.", + "settingsContextLabel": "Wysyłaj kontekst", + "settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi.", "settingsEvidenceLabel": "Pokazuj dowody domyślnie", "settingsEvidenceHint": "Automatycznie otwiera pole dowodów pod każdą odpowiedzią.", "settingsDefaultWindowLabel": "Domyślny okres analizy", @@ -2226,8 +2230,6 @@ "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", - "settingsContextLabel": "Wysyłaj kontekst", - "settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi.", "cluster": { "cardio": { "label": "Sercowo-naczyniowe", @@ -2320,6 +2322,33 @@ "groupYesterday": "Wczoraj", "groupThisWeek": "W tym tygodniu", "groupEarlier": "Wcześniej" + }, + "seed": { + "bloodPressure": "Przejdź ze mną przez trend mojego ciśnienia krwi z ostatnich 30 dni — czy jest coś, na co powinienem zwrócić uwagę?", + "weight": "Jak zmieniała się ostatnio moja waga i czy ten kierunek wymaga reakcji?", + "bmi": "Co mówi mi mój trend BMI i jak powinienem go odczytywać?", + "pulse": "Przejdź ze mną przez moje odczyty tętna — czy coś odbiega od normy?", + "restingHr": "Jak zmieniało się moje tętno spoczynkowe i co mówi o mojej kondycji?", + "hrv": "Co moja zmienność rytmu serca mówi mi o regeneracji i stresie w ostatnim czasie?", + "sleep": "Przejdź ze mną przez mój ostatni sen — długość, regularność i wszystko, co warto zmienić.", + "mood": "Jakie wzorce widzisz w moim nastroju w ciągu ostatnich kilku tygodni?", + "medications": "Jak wygląda moje przestrzeganie zaleceń lekowych i czy pokrywa się z tym, jak się czułem?", + "steps": "Jak aktywny byłem ostatnio na podstawie moich kroków i jak wypada to na tle zdrowego punktu odniesienia?", + "activeEnergy": "Co mój trend aktywnej energii mówi o moim poziomie aktywności w ostatnim czasie?", + "cardioFitness": "Co oznacza trend mojej wydolności krążeniowo-oddechowej (VO₂ max) i jak skierować go we właściwą stronę?", + "bloodGlucose": "Przejdź ze mną przez moje ostatnie odczyty glukozy — czy mieszczą się w zdrowym zakresie?", + "oxygenSaturation": "Co mówi mi mój trend saturacji krwi tlenem (SpO₂)?", + "respiratoryRate": "Co mój trend częstości oddechów mówi o moim zdrowiu?", + "workouts": "Przejdź ze mną przez moje ostatnie treningi — obciążenie, częstotliwość i jak regeneruję się między nimi.", + "recoveryPage": "Dlaczego moja regeneracja jest teraz na takim poziomie i co na nią wpływa?", + "briefing": "Przejdź ze mną przez dzisiejszy briefing — na czym powinienem się skupić i czy jest coś, co warto podjąć?", + "healthScore": "Dlaczego mój wynik zdrowia wynosi {score} na 100 i co poprawiłoby go najbardziej?", + "periodWeek": "Przejdź ze mną przez to, jak minął mój ostatni tydzień w moich wskaźnikach zdrowia — co się wyróżnia?", + "periodMonth": "Przejdź ze mną przez to, jak minął mój ostatni miesiąc w moich wskaźnikach zdrowia — co się wyróżnia?", + "correlationBpCompliance": "Jak moje przestrzeganie zaleceń lekowych wiąże się z moim ciśnieniem krwi? Wyjaśnij mi, co ta korelacja oznacza dla mnie.", + "correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.", + "correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?", + "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?" } }, "relativeJustNow": "przed chwilą", @@ -3240,6 +3269,7 @@ }, "vitals": { "sectionTitle": "Twoje parametry życiowe", + "sectionSubtitle": "Spoczynkowe sygnały sercowo-naczyniowe i oddechowe na tle Twojej własnej linii bazowej", "loadingLabel": "Wczytywanie parametrów życiowych", "loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.", "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", @@ -3366,6 +3396,7 @@ }, "scores": { "sectionTitle": "Twoje wskaźniki zdrowia", + "sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń", "loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.", "recovery": "Regeneracja", "stress": "Stres", @@ -3611,7 +3642,10 @@ "returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło.", "daysInside": "{count} dni w zakresie", "daysOutside": "{count} dni poza zakresem" - } + }, + "updatedTodayAt": "Zaktualizowano dzisiaj, {time}", + "updatedYesterday": "Zaktualizowano wczoraj", + "updatedOn": "Zaktualizowano {date}" }, "thresholds": { "defaultLabel": "Domyślne", @@ -3677,8 +3711,8 @@ "body": "Dziennik chorób zapisuje ostre epizody i przewlekłe diagnozy wraz z zaostrzeniami. Ukryte: korelacja epizodu z pomiarami oraz Tryb odpoczynku wstrzymujący przypomnienia. Dzięki temu Coach rozumie, czemu wartości przez chwilę odbiegają." }, "vorsorge": { - "title": "Profilaktyka", - "body": "Profilaktyka przypomina o badaniach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza." + "title": "Badania", + "body": "Badania przypominają o przeglądach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza." }, "cycle": { "title": "Cykl", @@ -3702,7 +3736,7 @@ }, "export": { "title": "Raport dla lekarza", - "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i profilaktykę w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych." + "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i badania w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych." }, "achievements": { "title": "Osiągnięcia", @@ -4132,8 +4166,8 @@ "subtitle": "Zarządzaj widokiem i kolejnością epizodów." }, "vorsorge": { - "title": "Profilaktyka", - "description": "Widok, kolejność i poszczególne przypomnienia profilaktyczne.", + "title": "Badania", + "description": "Widok, kolejność i poszczególne przypomnienia o badaniach.", "subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami." }, "modules": { @@ -4413,6 +4447,28 @@ "clearedToast": "Zapomniano {count} zapisanych informacji.", "clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie." }, + "coachReminders": { + "title": "Twoje przypomnienia", + "description": "Rzeczy, o których przypomnienie poprosiłeś Coacha. Te, których termin nadszedł, pojawiają się tutaj i w Coachu.", + "empty": "Brak przypomnień. Poproś Coacha, aby przypomniał Ci o czymś, a pojawi się tutaj.", + "loadError": "Nie udało się wczytać Twoich przypomnień.", + "dueHeading": "Termin teraz", + "allHeading": "Zapisane", + "duePrefix": "termin {when}", + "confirm": "Zachowaj", + "markDone": "Gotowe", + "dismiss": "Odrzuć", + "deleteAria": "Usuń to przypomnienie", + "note": "Przypomnienia są przechowywane w postaci zaszyfrowanej. Usuń je w dowolnej chwili — znika z tego, co widzi Coach.", + "status": { + "proposed": "Sugerowane", + "active": "Zapisane", + "due": "Termin", + "surfaced": "Wyświetlone", + "done": "Gotowe", + "dismissed": "Odrzucone" + } + }, "aboutMe": { "title": "O mnie", "description": "Osobisty kontekst, który Coach i codzienny briefing czytają obok Twoich danych.", @@ -4436,7 +4492,24 @@ "completeness": "Wypełnione: {answered} z {total}", "questionsTitle": "Coach ma pytania", "questionsOpenCoach": "Odpowiedz w Coachu" - } + }, + "responseTimeoutHeading": "Limit czasu odpowiedzi", + "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.", + "responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)", + "responseTimeoutPlaceholder": "Domyślnie (~180)", + "responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.", + "ocrHeading": "Skanowanie dokumentów (wyniki badań)", + "ocrBody": "Użyj dedykowanego dostawcy do odczytu przesłanych wyników badań. Gdy wyłączone, skanowanie używa Twojego głównego dostawcy.", + "ocrEnableLabel": "Włącz dedykowanego dostawcę do skanowania dokumentów", + "ocrProviderLabel": "Dostawca skanowania", + "ocrProviderOption": { + "OPENAI": "OpenAI (Twój klucz API)", + "ANTHROPIC": "Anthropic (Claude)", + "LOCAL": "Model lokalny (zgodny z OpenAI)" + }, + "ocrModelPlaceholder": "np. gpt-4o", + "ocrVisionHint": "Ten model może nie odczytywać obrazów. Wybierz model obsługujący obraz do skanowania dokumentów.", + "ocrKeyLabel": "Klucz API" }, "dashboard": { "units": { @@ -4663,6 +4736,8 @@ "changelog": "Changelog", "docs": "Dokumentacja", "linksHeading": "Źródła i dokumentacja", + "identityDescription": "Wersja, licencja i szczegóły kompilacji tej instancji.", + "linksDescription": "Kod źródłowy, dziennik zmian i dokumentacja.", "newerAvailable": "Nowa wersja: {tag}" }, "testConnection": { @@ -6483,13 +6558,13 @@ "selectionBarLabel": "Akcje zaznaczenia" }, "measurementReminders": { - "sectionTitle": "Profilaktyka", + "sectionTitle": "Badania", "sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.", "addButton": "Dodaj przypomnienie", "markDone": "Gotowe", "delete": "Usuń", "deleteConfirmTitle": "Usunąć przypomnienie?", - "deleteConfirmDescription": "To przypomnienie profilaktyczne zostanie trwale usunięte.", + "deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.", "disabledBadge": "Wył.", "nextDue": { "none": "Niezaplanowane", @@ -6551,7 +6626,7 @@ "rruleHint": "RFC-5545, np. FREQ=YEARLY dla corocznego badania.", "firstDue": "Pierwszy termin (opcjonalnie)" }, - "pushTitle": "Przypomnienie profilaktyczne", + "pushTitle": "Przypomnienie o badaniu", "pushBody": "Czas na: {label}", "pushLocation": "(u {location})", "edit": "Edytuj", @@ -6562,7 +6637,7 @@ "measureNow": "Zmierz teraz", "nextDueLabel": "Następny termin:", "lastDoneLabel": "Ostatnio wykonano:", - "customize": "Dostosuj profilaktykę", + "customize": "Dostosuj badania", "adherence": { "label": "W interwale", "onTrack": "Zgodnie z planem", @@ -6578,8 +6653,8 @@ "empty": "Brak przypomnień do dostosowania." }, "dashboard": { - "empty": "Brak nadchodzących przypomnień profilaktycznych.", - "openLink": "Otwórz profilaktykę" + "empty": "Brak nadchodzących przypomnień o badaniach.", + "openLink": "Otwórz badania" }, "showMeasurements": "Pokaż pomiary" }, @@ -6720,7 +6795,14 @@ "savedToast": "Biomarker zapisany", "updatedToast": "Biomarker zaktualizowany", "saveError": "Nie udało się zapisać biomarkera" - } + }, + "hide": "Ukryj", + "unhide": "Przywróć", + "hiddenToast": "Biomarker ukryty", + "unhiddenToast": "Biomarker przywrócony", + "hideError": "Nie udało się zaktualizować biomarkera", + "allHidden": "Wszystkie biomarkery są ukryte.", + "hiddenSection": "Ukryte" }, "catalog": { "total-cholesterol": "Cholesterol całkowity", @@ -7041,6 +7123,25 @@ "glucoseStructured": "Mierz glukozę według ustrukturyzowanego planu dziennego" } }, + "suggestedAction": { + "apply": "Dodaj", + "dismiss": "Nie teraz", + "applied": "Dodano", + "dismissed": "Odrzucono", + "failed": "Nie udało się dodać — spróbuj ponownie", + "checkup": { + "title": "Dodać przypomnienie o badaniu?" + }, + "note": { + "title": "Zapisać przypomnienie?" + }, + "interval": { + "yearly": "Co roku", + "halfYearly": "Co sześć miesięcy", + "quarterly": "Co trzy miesiące", + "monthly": "Co miesiąc" + } + }, "settings": { "reminderSuggestionsLabel": "Sugestie częstotliwości", "reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie." diff --git a/next.config.ts b/next.config.ts index 8b38781dc..2c6e0c4ed 100644 --- a/next.config.ts +++ b/next.config.ts @@ -134,6 +134,21 @@ const nextConfig: NextConfig = { destination: "/settings/notifications", permanent: true, }, + // v1.22.0 — the preventive-care surface moved from the German slug + // `/vorsorge` to the English `/checkups` (the feature reads "Checkups" + // in every non-German locale now). 301-redirect so bookmarks, the PWA's + // cached navigation, and any push deep-link keep resolving. The + // persisted internal id stays `vorsorge`; only the URL changes. + { + source: "/vorsorge", + destination: "/checkups", + permanent: true, + }, + { + source: "/vorsorge/:path*", + destination: "/checkups/:path*", + permanent: true, + }, ]; }, async headers() { diff --git a/package.json b/package.json index 348c30ddf..9c5bb7c9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.21.4", + "version": "1.22.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0195_biomarker_hidden/migration.sql b/prisma/migrations/0195_biomarker_hidden/migration.sql new file mode 100644 index 000000000..6d0eba592 --- /dev/null +++ b/prisma/migrations/0195_biomarker_hidden/migration.sql @@ -0,0 +1,9 @@ +-- v1.22.0 — Biomarker catalog: per-marker "hidden" flag. +-- +-- A marker the user no longer needs is hidden rather than deleted: it leaves +-- the active catalog list and the lab-entry pickers, but its readings and its +-- canonical unit/reference-range definition survive (unhide restores it). A +-- plain additive boolean column with a server-side default; no existing row is +-- touched (every back-filled row defaults to visible). `IF NOT EXISTS` makes +-- the rerun safe. +ALTER TABLE "biomarkers" ADD COLUMN IF NOT EXISTS "hidden" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/0196_coach_reminder/migration.sql b/prisma/migrations/0196_coach_reminder/migration.sql new file mode 100644 index 000000000..d11bcd3a0 --- /dev/null +++ b/prisma/migrations/0196_coach_reminder/migration.sql @@ -0,0 +1,42 @@ +-- v1.22 (B2/B6) — Coach episodic reminder memory. +-- +-- A time-anchored "bring this back later" intent the Coach captured from a +-- conversation. The missing memory type between coach_facts (durable traits) +-- and coach_plans (standing if-then habits): an episode with a due moment and +-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea` +-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted. +-- metric / status / trigger fields / dates / source stay plain so the daily +-- sweep and the injection picker can rank and filter without a per-row decrypt. +-- status (proposed | active | due | surfaced | done | dismissed) and +-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB +-- enums so this migration stays purely additive. Soft-delete via deleted_at. +CREATE TABLE "coach_reminders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "note_encrypted" BYTEA NOT NULL, + "metric" TEXT, + "related_plan_id" TEXT, + "trigger_kind" TEXT NOT NULL DEFAULT 'date', + "due_at" TIMESTAMP(3), + "context_cue" TEXT, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL, + "source_conversation_id" TEXT, + "last_surfaced_at" TIMESTAMP(3), + "surface_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id") +); + +-- The daily sweep scans a user's active reminders whose due_at has passed; the +-- status + deleted_at columns are part of that predicate, so the composite +-- index lets the planner serve the sweep + the injection read index-backed. +CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at"); + +-- The context-cue evaluation pass filters by trigger_kind + status per user. +CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status"); + +ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0197_coach_plan_outcome/migration.sql b/prisma/migrations/0197_coach_plan_outcome/migration.sql new file mode 100644 index 000000000..1a8ea6d9d --- /dev/null +++ b/prisma/migrations/0197_coach_plan_outcome/migration.sql @@ -0,0 +1,6 @@ +-- v1.22 (W9, C2) — n-of-1 experiment outcome read-back, encrypted at rest. +-- An "experiment" is a CoachPlan with a reviewDate; when its review is +-- processed, the grounded before/after result is written here. Additive, +-- nullable Bytes column — no backfill, no enum change (the new statuses +-- review_due / reviewed are app-side strings on the existing `status` column). +ALTER TABLE "coach_plans" ADD COLUMN IF NOT EXISTS "outcome_encrypted" BYTEA; diff --git a/prisma/migrations/0198_v122_ai_settings/migration.sql b/prisma/migrations/0198_v122_ai_settings/migration.sql new file mode 100644 index 000000000..65c345959 --- /dev/null +++ b/prisma/migrations/0198_v122_ai_settings/migration.sql @@ -0,0 +1,26 @@ +-- v1.22 (#89 + #90) — AI-settings wave. +-- +-- Two additive, UI/DB-backed features on the per-user AI config (no new env +-- vars — every value is configured in the app): +-- +-- #89 ai_response_timeout_seconds — per-user response timeout for AI +-- generation, in SECONDS. Threaded onto CompletionParams.timeoutMs +-- (x1000). NULL = the generous built-in default (~180 s). Surfaced in +-- Settings -> AI mainly for local/self-hosted servers where the first +-- request can take >60 s while the model loads. +-- +-- #90 dedicated document-scan (Lab-OCR) provider. OFF by default +-- (ai_ocr_enabled = false) -> the OCR ingestion uses the main provider +-- chain unchanged. When enabled, the OCR path resolves this dedicated +-- provider/model/key instead. ai_ocr_key_encrypted is AES-256-GCM +-- ciphertext like every other *_encrypted column (registered in the +-- encrypted-columns registry + the rotation script). +-- +-- All columns are nullable / defaulted, so existing rows need no backfill. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "ai_response_timeout_seconds" INTEGER, + ADD COLUMN IF NOT EXISTS "ai_ocr_enabled" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "ai_ocr_provider" TEXT, + ADD COLUMN IF NOT EXISTS "ai_ocr_model" TEXT, + ADD COLUMN IF NOT EXISTS "ai_ocr_base_url" TEXT, + ADD COLUMN IF NOT EXISTS "ai_ocr_key_encrypted" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0c8bb68b..dfcb5020a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -254,6 +254,27 @@ model User { // behaviour (single-provider resolution via `aiProvider`). aiProviderChain Json? @map("ai_provider_chain") + // v1.22 (#89) — per-user response timeout for AI generation, in SECONDS. + // Threaded onto `CompletionParams.timeoutMs` (×1000). Null = the generous + // built-in default (~180 s). Surfaced in Settings → AI mainly for + // local/self-hosted servers (an MLX/exo backend can take >60 s on the + // first request while it loads the model), where the legacy 60 s client + // default timed the Coach out before the first token landed. + aiResponseTimeoutSeconds Int? @map("ai_response_timeout_seconds") + + // v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. OFF by default: + // the lab-report OCR path uses the main provider chain unchanged. When + // `aiOcrEnabled` is true the OCR ingestion resolves THIS provider/model/key + // instead, so a user can keep a cheap text Coach on one provider and route + // expensive vision OCR to a vision-capable one. The key is AES-256-GCM at + // rest like every other `*Encrypted` column (registered in + // `src/lib/crypto/encrypted-columns.ts` + the rotation script). + aiOcrEnabled Boolean @default(false) @map("ai_ocr_enabled") + aiOcrProvider String? @map("ai_ocr_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" + aiOcrModel String? @map("ai_ocr_model") + aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only + aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted") + // Onboarding onboardingCompletedAt DateTime? @map("onboarding_completed_at") // v1.4.25 W14b — last persisted onboarding wizard step. @@ -571,6 +592,7 @@ model User { coachConversations CoachConversation[] coachFacts CoachFact[] coachPlans CoachPlan[] + coachReminders CoachReminder[] coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS @@ -3657,6 +3679,10 @@ model Biomarker { /// Optional panel grouping moved off the per-entry row, e.g. /// "Großes Blutbild". NULL = standalone marker. panel String? + /// A marker the user no longer needs: dropped from the active catalog list + /// and from the lab-entry pickers, but never deleted — its readings and + /// canonical unit/range definition survive. + hidden Boolean @default(false) @map("hidden") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -3903,7 +3929,10 @@ model CoachFact { // so an abandoned plan is hidden from injection but kept for audit. // // `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration -// purely additive): proposed | active | met | abandoned. +// purely additive): proposed | active | met | abandoned | review_due | reviewed. +// The last two are the v1.22 (W9, C2) n-of-1 experiment lifecycle: an +// "experiment" is just a CoachPlan with a reviewDate; when the review fires and +// the read-back is generated, `outcomeEncrypted` carries the grounded result. model CoachPlan { id String @id @default(cuid()) userId String @map("user_id") @@ -3917,10 +3946,15 @@ model CoachPlan { // Optional free-text target ("70 kg by August", "lights out by 23:00"). Free // prose like the cue/action, so encrypted at rest as well. targetEncrypted Bytes? @map("target_encrypted") - // App-side closed enum: proposed | active | met | abandoned. + // App-side closed enum: proposed | active | met | abandoned | review_due | + // reviewed (the last two are the W9/C2 experiment lifecycle). status String @default("proposed") // Optional review checkpoint the Coach can recall ("how did this go by …"). reviewDate DateTime? @map("review_date") + // v1.22 (W9, C2) — the n-of-1 read-back prose, grounded in the user's own + // before/after numbers and encrypted at rest like the cue/action. Written + // once when an experiment's review is processed; surfaced behind a flag. + outcomeEncrypted Bytes? @map("outcome_encrypted") // Provenance — nullable so a future manual-entry surface can leave it null. sourceConversationId String? @map("source_conversation_id") createdAt DateTime @default(now()) @map("created_at") @@ -3934,6 +3968,56 @@ model CoachPlan { @@map("coach_plans") } +// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a +// time-anchored "bring this back later" intent the Coach captured from a +// conversation ("remind me about my sleep next week"). This is the middle +// memory type that neither CoachFact (durable traits) nor CoachPlan (standing +// if-then habits) covers — an episode with a due moment and a done state. +// +// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM +// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`, +// `status`, the trigger fields and the dates stay plain so the daily sweep and +// the injection picker can rank / filter without paying a per-row decrypt. +// +// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration +// purely additive): proposed | active | due | surfaced | done | dismissed. +// `triggerKind` is likewise app-side closed: date | context. Soft-delete via +// `deletedAt` so a "forget this" action is reversible / auditable. +model CoachReminder { + id String @id @default(cuid()) + userId String @map("user_id") + // What to bring back — the user's own framing, encrypted at rest. + noteEncrypted Bytes @map("note_encrypted") + // Optional link to the metric / plan it concerns; plain for filtering. + metric String? + relatedPlanId String? @map("related_plan_id") + // Trigger model — app-side closed enum (date | context). For `date`, `dueAt` + // carries the fire moment; for `context`, `contextCue` names a closed cue. + triggerKind String @default("date") @map("trigger_kind") + dueAt DateTime? @map("due_at") + contextCue String? @map("context_cue") + // Lifecycle: proposed | active | due | surfaced | done | dismissed. + status String @default("active") + // Provenance — how it was captured (sentinel | extractor | manual | action). + source String + sourceConversationId String? @map("source_conversation_id") + lastSurfacedAt DateTime? @map("last_surfaced_at") + // How many times the sweep has surfaced this without the user acting; capped + // before auto-dismiss so an ignored reminder cannot nag indefinitely. + surfaceCount Int @default(0) @map("surface_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The daily sweep scans active reminders whose dueAt has passed; the status + + // deleted_at columns are part of the predicate so the read stays index-backed. + @@index([userId, status, dueAt, deletedAt]) + @@index([userId, triggerKind, status]) + @@map("coach_reminders") +} + // v1.15.20 — user-authored "about me" self-description the AI surfaces // (Coach system prompt + daily-briefing user prompt) inject as a clearly // delimited, user-provided context block. 1:1 with User and lazily diff --git a/public/sw.js b/public/sw.js index 9ac3bc7ea..069510dbe 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.21.4"; + /* @sw-version-fallback */ "v1.22.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 53149ef99..41e8ee4af 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -191,6 +191,7 @@ async function main() { "aiAnthropicKeyEncrypted", "aiLocalKeyEncrypted", "aiOpenaiKeyEncrypted", + "aiOcrKeyEncrypted", "insuranceNumberEncrypted", ]; for (const field of userFields) { @@ -319,15 +320,26 @@ async function main() { ); // ───── CoachPlan (Bytes columns) ───── - // "ifCueEncrypted" "thenActionEncrypted" "targetEncrypted" + // "ifCueEncrypted" "thenActionEncrypted" "targetEncrypted" "outcomeEncrypted" for (const field of [ "ifCueEncrypted", "thenActionEncrypted", "targetEncrypted", + "outcomeEncrypted", ]) { results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan)); } + // ───── CoachReminder (Bytes column) ───── + // "noteEncrypted" + results.push( + await rotateBytesColumn( + "CoachReminder", + "noteEncrypted", + prisma.coachReminder, + ), + ); + // ───── UserHealthProfile (Bytes columns) ───── // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" // "coachFocusEncrypted" "pendingQuestionsEncrypted" diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 87ec6f236..3b533241b 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -39,6 +39,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -54,6 +55,7 @@ function serialiseBiomarker(row: { context: row.contextEncrypted ? decryptContextFromBytes(row.contextEncrypted) : null, + hidden: row.hidden, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -137,6 +139,7 @@ export const PUT = apiHandler( if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound; if (d.upperBound !== undefined) data.upperBound = d.upperBound; if (d.panel !== undefined) data.panel = d.panel; + if (d.hidden !== undefined) data.hidden = d.hidden; // Inverted-range guard for a PARTIAL bound update. The schema refine only // fires when both bounds arrive together; moving a single bound past the diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index b30c84be6..e4c753bee 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -50,6 +50,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -63,6 +64,7 @@ function serialiseBiomarker(row: { panel: row.panel, hasContext: row.contextEncrypted !== null, context: decryptContextSoft(row.contextEncrypted), + hidden: row.hidden, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -71,9 +73,11 @@ function serialiseBiomarker(row: { export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Visible markers first, then hidden — the manager renders them in two + // sections and the lab-entry pickers drop the hidden tail client-side. const rows = await prisma.biomarker.findMany({ where: { userId: user.id }, - orderBy: [{ name: "asc" }], + orderBy: [{ hidden: "asc" }, { name: "asc" }], }); annotate({ diff --git a/src/app/api/coach/reminders/[id]/route.ts b/src/app/api/coach/reminders/[id]/route.ts new file mode 100644 index 000000000..8c702a04b --- /dev/null +++ b/src/app/api/coach/reminders/[id]/route.ts @@ -0,0 +1,178 @@ +/** + * PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle. + * DELETE /api/coach/reminders/[id] — soft-delete one reminder. + * + * v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic + * reminders. `status` confirms a `proposed` reminder (→ active), marks it + * `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the + * closed grammar. The body never carries the note text, so a client can never + * inject or overwrite a reminder's prose — only its lifecycle / timing. + * + * Ownership + existence privacy: every mutation is scoped + * `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id, + * an unknown id, or an already-deleted reminder all resolve to a `count: 0` + * no-op rather than a P2025 throw — the existence channel never leaks across + * accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent + * `{ deleted: false }`. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { resolveWhenGrammar } from "@/lib/ai/coach/reminders"; +import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder"; + +interface RouteCtx { + params: Promise<{ id: string }>; +} + +const MUTATE_RATE_LIMIT = 40; +const MUTATE_WINDOW_MS = 60_000; + +async function enforceMutateLimit( + op: string, + userId: string, +): Promise { + const rl = await checkRateLimit( + `coach-reminders:${op}:${userId}`, + MUTATE_RATE_LIMIT, + MUTATE_WINDOW_MS, + ); + if (rl.allowed) return null; + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; +} + +export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const limited = await enforceMutateLimit("patch", user.id); + if (limited) return limited; + + const { id } = await ctx.params; + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachReminderPatchSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Field-by-field (no mass assignment): only the lifecycle / timing fields the + // body carried are written; the note + encrypted text are never touched here. + const data: { + status?: string; + dueAt?: Date | null; + triggerKind?: string; + contextCue?: string | null; + } = {}; + if (parsed.data.status !== undefined) data.status = parsed.data.status; + if (parsed.data.when !== undefined) { + if (parsed.data.when === null) { + data.dueAt = null; + data.triggerKind = "date"; + data.contextCue = null; + } else { + const trigger = resolveWhenGrammar(parsed.data.when, new Date()); + if (!trigger) return apiError("coach.reminder.invalidWhen", 422); + data.dueAt = trigger.dueAt; + data.triggerKind = trigger.triggerKind; + data.contextCue = trigger.contextCue; + } + } + + const { count } = await prisma.coachReminder.updateMany({ + where: { id, userId: user.id, deletedAt: null }, + data, + }); + + if (count === 0) return apiError("Reminder not found", 404); + + const row = await prisma.coachReminder.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + metric: true, + noteEncrypted: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!row) return apiError("Reminder not found", 404); + + let note: string | null = null; + try { + note = decryptFromBytes(row.noteEncrypted); + } catch { + note = null; + } + + annotate({ + action: { name: "coach.reminders.updated" }, + meta: { status: row.status }, + }); + + return apiSuccess({ + reminder: { + id: row.id, + note, + metric: row.metric, + triggerKind: row.triggerKind, + dueAt: row.dueAt?.toISOString() ?? null, + contextCue: row.contextCue, + status: row.status, + source: row.source, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }, + }); +}); + +export const DELETE = apiHandler( + async (_request: NextRequest, ctx: RouteCtx) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const limited = await enforceMutateLimit("delete", user.id); + if (limited) return limited; + + const { id } = await ctx.params; + + const { count } = await prisma.coachReminder.updateMany({ + where: { id, userId: user.id, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + + const deleted = count > 0; + annotate({ + action: { name: "coach.reminders.deleted" }, + meta: { deleted }, + }); + return apiSuccess({ deleted }); + }, +); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/coach/reminders/__tests__/route.test.ts b/src/app/api/coach/reminders/__tests__/route.test.ts new file mode 100644 index 000000000..5f3fd26dd --- /dev/null +++ b/src/app/api/coach/reminders/__tests__/route.test.ts @@ -0,0 +1,312 @@ +/** + * v1.22 (B2/B6) — `/api/coach/reminders` (+ `/[id]`) routes. + * + * Covers: + * - GET lists the caller's reminders decrypted, scoped to the user; an + * undecryptable row is skipped rather than 500ing the list. + * - POST creates a reminder field-by-field (source manual, active), resolving + * the closed `when` grammar server-side; an invalid `when` 422s. + * - PATCH confirms proposed → active field-by-field; a 0-count match 404s + * (no-IDOR: the cross-user / unknown id never leaks existence). + * - DELETE soft-deletes the caller's reminder; a cross-user id is a 0-count + * no-op returning `{ deleted: false }`. + * - the coach module gate runs on every verb. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + coachReminder: { + findMany: vi.fn(), + count: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 39, + resetAt: Date.now() + 60_000, + })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn((buf: Uint8Array) => { + const tag = Buffer.from(buf).toString("utf8"); + if (tag === "__bad__") throw new Error("unknown key id"); + return `dec:${tag}`; + }), + encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(`enc:${s}`))), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { PATCH, DELETE as DELETE_ONE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "t", + role: "USER" as const, + displayName: null, + }, +}; + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +const callGet = (url = "http://localhost/api/coach/reminders") => + (GET as unknown as (req: Request) => Promise)(new Request(url)); + +function jsonReq(body: unknown, method = "POST"): NextRequest { + return new NextRequest("http://localhost/api/coach/reminders", { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} +const callPost = (body: unknown) => + (POST as unknown as (req: Request) => Promise)(jsonReq(body)); + +const callPatch = (body: unknown, id = "r1") => + ( + PATCH as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new NextRequest("http://localhost/api/coach/reminders/x", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); + +const callDelete = (id = "r1") => + ( + DELETE_ONE as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new NextRequest("http://localhost/api/coach/reminders/x", { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("GET /api/coach/reminders", () => { + it("lists the caller's reminders decrypted, scoped to the user", async () => { + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([ + { + id: "r1", + metric: "SLEEP", + noteEncrypted: bytes("revisit sleep"), + triggerKind: "date", + dueAt: new Date("2026-07-11T09:00:00Z"), + contextCue: null, + status: "active", + source: "sentinel", + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-02T00:00:00Z"), + }, + ] as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { reminders: Array> }; + }; + expect(body.data.reminders[0]).toMatchObject({ + id: "r1", + note: "dec:revisit sleep", + status: "active", + }); + const where = vi.mocked(prisma.coachReminder.findMany).mock.calls[0]?.[0] + ?.where as { userId: string; deletedAt: null }; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("skips an undecryptable row rather than 500ing", async () => { + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([ + { + id: "r1", + metric: null, + noteEncrypted: bytes("__bad__"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "r2", + metric: null, + noteEncrypted: bytes("ok"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { reminders: Array<{ id: string }> }; + }; + expect(body.data.reminders).toHaveLength(1); + expect(body.data.reminders[0]?.id).toBe("r2"); + }); +}); + +describe("POST /api/coach/reminders", () => { + it("creates a reminder field-by-field, resolving the when grammar", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + vi.mocked(prisma.coachReminder.create).mockResolvedValue({ + id: "r9", + metric: "SLEEP", + noteEncrypted: bytes("x"), + triggerKind: "date", + dueAt: new Date(), + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const res = await callPost({ + note: "revisit sleep", + when: "+7d", + metric: "SLEEP", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + status: string; + source: string; + }; + expect(data.userId).toBe("user-1"); + expect(data.status).toBe("active"); + expect(data.source).toBe("manual"); + }); + + it("422s on an invalid when token", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + const res = await callPost({ note: "x", when: "someday" }); + expect(res.status).toBe(422); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + + it("422s on an unknown key (strict)", async () => { + const res = await callPost({ note: "x", userId: "evil" }); + expect(res.status).toBe(422); + }); +}); + +describe("PATCH /api/coach/reminders/[id]", () => { + it("confirms proposed → active field-by-field, owner-scoped", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachReminder.findFirst).mockResolvedValue({ + id: "r1", + metric: null, + noteEncrypted: bytes("x"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "sentinel", + createdAt: new Date(), + updatedAt: new Date(), + } as never); + const res = await callPatch({ status: "active" }); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.coachReminder.updateMany).mock + .calls[0]?.[0] as { + where: { id: string; userId: string; deletedAt: null }; + data: Record; + }; + expect(arg.where.userId).toBe("user-1"); + expect(Object.keys(arg.data)).toEqual(["status"]); + }); + + it("404s on a 0-count (cross-user / unknown) match — no IDOR leak", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callPatch({ status: "done" }, "someone-elses"); + expect(res.status).toBe(404); + expect(prisma.coachReminder.findFirst).not.toHaveBeenCalled(); + }); + + it("422s on an empty body", async () => { + const res = await callPatch({}); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/coach/reminders/[id]", () => { + it("soft-deletes the caller's reminder", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(true); + }); + + it("returns deleted:false for a cross-user id (0-count no-op)", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callDelete("someone-elses"); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(false); + }); +}); diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts new file mode 100644 index 000000000..c69985795 --- /dev/null +++ b/src/app/api/coach/reminders/route.ts @@ -0,0 +1,202 @@ +/** + * GET /api/coach/reminders — list the caller's Coach episodic reminders. + * POST /api/coach/reminders — create one manually (the ledger "+ add" path). + * + * v1.22 (B2/B6) — the management + in-app-surface read for the durable + * "remind me about X" memory the Coach captures inline via the `---REMEMBER---` + * sentinel. The note free-text is decrypted on the fly; an undecryptable row (a + * key rotated out of the map) is skipped rather than 500ing the whole list. + * + * Ownership: every query is scoped `where: { userId, ... }`, so a caller can + * only ever see / create their own reminders. The owner is always narrowed from + * the session, never the body. Coach-gated by the same + * `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach + * management stack. + * + * `?status=` optionally filters (one status, or a comma set like `due,surfaced` + * for the in-app tile). Omitted returns the non-terminal set (proposed / active + * / due / surfaced), soonest-due first. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + resolveWhenGrammar, + MAX_REMINDERS_PER_USER, +} from "@/lib/ai/coach/reminders"; +import { + coachRemindersListQuerySchema, + coachReminderCreateSchema, +} from "@/lib/validations/coach-reminder"; + +const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const; + +interface ReminderRow { + id: string; + metric: string | null; + noteEncrypted: Uint8Array; + triggerKind: string; + dueAt: Date | null; + contextCue: string | null; + status: string; + source: string; + createdAt: Date; + updatedAt: Date; +} + +function toDto(row: ReminderRow, note: string) { + return { + id: row.id, + note, + metric: row.metric, + triggerKind: row.triggerKind, + dueAt: row.dueAt?.toISOString() ?? null, + contextCue: row.contextCue, + status: row.status, + source: row.source, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const url = new URL(req.url); + const parsed = coachRemindersListQuerySchema.safeParse({ + status: url.searchParams.get("status") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const statusWhere = parsed.data.status + ? { status: { in: parsed.data.status } } + : { status: { in: [...NON_TERMINAL] } }; + + const rows = (await prisma.coachReminder.findMany({ + where: { userId: user.id, deletedAt: null, ...statusWhere }, + orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }], + select: { + id: true, + metric: true, + noteEncrypted: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + createdAt: true, + updatedAt: true, + }, + })) as ReminderRow[]; + + const reminders: ReturnType[] = []; + for (const row of rows) { + let note: string; + try { + note = decryptFromBytes(row.noteEncrypted); + } catch { + // Fail closed per row — never surface ciphertext, never 500 the list. + continue; + } + reminders.push(toDto(row, note)); + } + + annotate({ + action: { name: "coach.reminders.listed" }, + meta: { count: reminders.length }, + }); + + return apiSuccess({ reminders }); +}); + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const rate = await checkRateLimit( + `coach-reminders:create:${user.id}`, + 30, + 60_000, + ); + if (!rate.allowed) return apiError("Too many requests", 429); + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachReminderCreateSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Resolve the optional `when` against the closed grammar; an invalid token + // 422s rather than silently dropping the timing the user asked for. + const now = new Date(); + let trigger: ReturnType = null; + if (parsed.data.when) { + trigger = resolveWhenGrammar(parsed.data.when, now); + if (!trigger) return apiError("coach.reminder.invalidWhen", 422); + } + + // Per-user cap on the non-terminal set. + const nonTerminal = await prisma.coachReminder.count({ + where: { + userId: user.id, + deletedAt: null, + status: { in: [...NON_TERMINAL] }, + }, + }); + if (nonTerminal >= MAX_REMINDERS_PER_USER) { + return apiError("coach.reminder.capReached", 409); + } + + // Field-by-field — no mass assignment. Manual reminders are active on create. + const created = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes(parsed.data.note), + metric: parsed.data.metric ?? null, + triggerKind: trigger?.triggerKind ?? "date", + dueAt: trigger?.dueAt ?? null, + contextCue: trigger?.contextCue ?? null, + status: "active", + source: "manual", + }, + select: { + id: true, + metric: true, + noteEncrypted: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + createdAt: true, + updatedAt: true, + }, + }); + + annotate({ + action: { name: "coach.reminders.created" }, + meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null }, + }); + + return apiSuccess( + { reminder: toDto(created as ReminderRow, parsed.data.note) }, + 201, + ); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/coach/suggested-actions/__tests__/route.test.ts b/src/app/api/coach/suggested-actions/__tests__/route.test.ts new file mode 100644 index 000000000..f38a76c2c --- /dev/null +++ b/src/app/api/coach/suggested-actions/__tests__/route.test.ts @@ -0,0 +1,169 @@ +/** + * v1.22 (F6) — `POST /api/coach/suggested-actions`. + * + * Covers the closed allowlist confirm endpoint: + * - checkup.create builds a Vorsorge MeasurementReminder field-by-field + * (origin COACH, server-resolved RRULE from the closed interval id). + * - reminder.note builds a CoachReminder field-by-field (source action). + * - an off-allowlist actionType (e.g. medication.create) 422s — the moat + * never auto-applies or mints an arbitrary entity. + * - the owner is always the session user (no IDOR, no body userId). + * - the coach module gate runs. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + measurementReminder: { create: vi.fn() }, + coachReminder: { count: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + resetAt: Date.now() + 1000, + })), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), +})); +vi.mock("@/lib/measurement-reminders/scheduling", () => ({ + computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")), +})); +vi.mock("@/lib/measurement-reminders/dto", () => ({ + toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "t", + role: "USER" as const, + displayName: null, + }, +}; + +const call = (body: unknown) => + (POST as unknown as (req: Request) => Promise)( + new NextRequest("http://localhost/api/coach/suggested-actions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("POST /api/coach/suggested-actions", () => { + it("checkup.create builds a Vorsorge reminder field-by-field (origin COACH, server RRULE)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.measurementReminder.create).mockResolvedValue({ + id: "mr1", + } as never); + + const res = await call({ + actionType: "checkup.create", + label: "Annual blood panel", + interval: "yearly", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.measurementReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + origin: string; + rrule: string; + measurementType: null; + label: string; + }; + expect(data.userId).toBe("user-1"); // owner from session, never the body + expect(data.origin).toBe("COACH"); + expect(data.rrule).toBe("FREQ=YEARLY;INTERVAL=1"); + expect(data.measurementType).toBeNull(); + expect(data.label).toBe("Annual blood panel"); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("reminder.note builds a CoachReminder field-by-field (source action)", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + vi.mocked(prisma.coachReminder.create).mockResolvedValue({ + id: "cr1", + } as never); + + const res = await call({ + actionType: "reminder.note", + note: "revisit evening walk", + when: "+14d", + metric: "SLEEP", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + source: string; + status: string; + }; + expect(data.userId).toBe("user-1"); + expect(data.source).toBe("action"); + expect(data.status).toBe("active"); + }); + + it("422s an off-allowlist actionType — never auto-applies a clinical/med change", async () => { + const res = await call({ + actionType: "medication.create", + label: "start a drug", + interval: "monthly", + }); + expect(res.status).toBe(422); + expect(prisma.measurementReminder.create).not.toHaveBeenCalled(); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + + it("403s when the coach module is disabled", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + const res = await call({ + actionType: "reminder.note", + note: "x", + }); + expect(res.status).toBe(403); + }); +}); diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts new file mode 100644 index 000000000..8302bc33c --- /dev/null +++ b/src/app/api/coach/suggested-actions/route.ts @@ -0,0 +1,188 @@ +/** + * POST /api/coach/suggested-actions — confirm a generalised Coach action card. + * + * v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The + * Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel → + * `suggestedAction` SSE frame → ``. This endpoint is what + * the card's confirm button calls. It switches on the CLOSED `actionType` + * allowlist and, for each, builds the entity FIELD-BY-FIELD from a + * server-resolved spec: + * + * - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder` + * (free-text label + a closed interval id the SERVER resolves to an RRULE), + * minted through the SAME engine the Vorsorge surface uses. + * - `reminder.note` → a `CoachReminder` (note + optional closed `when` + * grammar + optional metric). + * + * HARD SAFETY: never auto-apply (this endpoint runs only on the explicit + * confirm tap); closed allowlist only (the discriminated-union Zod gate rejects + * any other `actionType`); NEVER a medication / dose / clinical-target change. + * Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no + * IDOR, no mass assignment. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + resolveWhenGrammar, + MAX_REMINDERS_PER_USER, +} from "@/lib/ai/coach/reminders"; +import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action"; +import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; +const CHECKUP_NOTIFY_HOUR = 9; + +async function postAction(request: NextRequest): Promise { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const rate = await checkRateLimit( + `coach:suggested-action:${user.id}`, + 30, + 60_000, + ); + if (!rate.allowed) return apiError("coach.action.rateLimited", 429); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachSuggestedActionSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const action = parsed.data; + + // ── checkup.create → a preventive-care Vorsorge reminder ───── + if (action.actionType === "checkup.create") { + // The model named an interval id; the SERVER owns the RRULE so the model + // can never mint an arbitrary schedule. + const preset = CHECKUP_INTERVAL_CATALOG[action.interval]; + if (!preset) return apiError("coach.action.unknownInterval", 422); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const timezone = row?.timezone || DEFAULT_TIMEZONE; + const now = new Date(); + const scheduleInput: ReminderScheduleInput = { + intervalDays: null, + rrule: preset.rrule, + anchorDate: null, + notifyHour: CHECKUP_NOTIFY_HOUR, + lastSatisfiedAt: null, + createdAt: now, + endsOn: null, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + + // Field-by-field — no mass assignment. A free-text checklist Vorsorge + // (measurementType null) so it resolves only on a manual "done". origin + // COACH marks the provenance. + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: action.label, + measurementType: null, + intervalDays: null, + rrule: preset.rrule, + anchorDate: null, + endsOn: null, + origin: "COACH", + notifyHour: CHECKUP_NOTIFY_HOUR, + location: null, + enabled: true, + nextDueAt, + }, + }); + + await auditLog("measurementReminder.create.coachAction", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: created.id, interval: action.interval }, + }); + annotate({ + action: { name: "coach.action.applied" }, + meta: { actionType: action.actionType, interval: action.interval }, + }); + + return apiSuccess( + { + ok: true, + actionType: action.actionType, + reminder: toMeasurementReminderDto(created), + }, + 201, + ); + } + + // ── reminder.note → a CoachReminder ────────────────────────── + const now = new Date(); + let trigger: ReturnType = null; + if (action.when) { + trigger = resolveWhenGrammar(action.when, now); + if (!trigger) return apiError("coach.reminder.invalidWhen", 422); + } + + const nonTerminal = await prisma.coachReminder.count({ + where: { + userId: user.id, + deletedAt: null, + status: { in: ["proposed", "active", "due", "surfaced"] }, + }, + }); + if (nonTerminal >= MAX_REMINDERS_PER_USER) { + return apiError("coach.reminder.capReached", 409); + } + + const created = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes(action.note), + metric: action.metric ?? null, + triggerKind: trigger?.triggerKind ?? "date", + dueAt: trigger?.dueAt ?? null, + contextCue: trigger?.contextCue ?? null, + status: "active", + source: "action", + }, + select: { id: true }, + }); + + annotate({ + action: { name: "coach.action.applied" }, + meta: { + actionType: action.actionType, + hasDue: trigger?.dueAt != null, + }, + }); + + return apiSuccess( + { ok: true, actionType: action.actionType, reminderId: created.id }, + 201, + ); +} + +export const POST = apiHandler(postAction); +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index eeaea14f3..39423d43b 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -50,12 +50,12 @@ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); -const { runRawCompletionWithFallback } = vi.hoisted(() => ({ - runRawCompletionWithFallback: vi.fn(), +const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({ + runStreamingRawCompletionWithFallback: vi.fn(), })); vi.mock("@/lib/ai/provider-runner", () => ({ AllProvidersFailedError: class extends Error {}, - runRawCompletionWithFallback, + runStreamingRawCompletionWithFallback, })); vi.mock("@/lib/ai/provider", () => ({ // v1.20.0 (F1) — pin a no-tools provider so this suite exercises the legacy @@ -138,17 +138,18 @@ vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })), DEFAULT_REMINDER_SUGGESTION_PREFS: {}, })); +// v1.22 (#89) — the provider call now runs INSIDE the stream producer. Capture +// the producer promise so the test can await it before asserting on call args. +const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise })); vi.mock("@/lib/sse/create-stream", () => ({ - // Drain the producer synchronously so the handler completes; we only care - // about the provider call args, not the wire frames. createSseStream: ( producer: (c: { signal: { aborted: boolean }; enqueue: () => void; }) => void | Promise, ) => { - void Promise.resolve( - producer({ signal: { aborted: true }, enqueue: () => {} }), + sse.done = Promise.resolve( + producer({ signal: { aborted: false }, enqueue: () => {} }), ); return new ReadableStream(); }, @@ -158,6 +159,12 @@ import { POST } from "../route"; const post = POST as unknown as (req: Request) => Promise; +async function postAndDrain(body: Record): Promise { + const res = await post(chatReq(body)); + await sse.done; + return res; +} + function chatReq(body: Record): Request { return new Request("http://localhost/api/insights/chat", { method: "POST", @@ -167,7 +174,8 @@ function chatReq(body: Record): Request { } function lastUserPrompt(): string { - const calls = runRawCompletionWithFallback.mock.calls as unknown as Array< + const calls = runStreamingRawCompletionWithFallback.mock + .calls as unknown as Array< [{ params: { messages: Array<{ role: string; content: string }> } }] >; const last = calls[calls.length - 1]; @@ -178,14 +186,14 @@ function lastUserPrompt(): string { describe("coach chat — snapshot sent once per conversation (C4)", () => { beforeEach(() => { vi.clearAllMocks(); - runRawCompletionWithFallback.mockResolvedValue({ + runStreamingRawCompletionWithFallback.mockResolvedValue({ result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" }, workingProvider: { providerType: "admin-openai" }, }); }); it("ships the full SNAPSHOT + grounding on the first turn", async () => { - await post(chatReq({ message: "How is my BP?" })); + await postAndDrain({ message: "How is my BP?" }); const prompt = lastUserPrompt(); expect(prompt).toContain(SNAPSHOT_JSON); expect(prompt).toContain(GROUNDING); @@ -201,7 +209,7 @@ describe("coach chat — snapshot sent once per conversation (C4)", () => { ], }); - await post(chatReq({ conversationId: "c1", message: "High?" })); + await postAndDrain({ conversationId: "c1", message: "High?" }); const prompt = lastUserPrompt(); // The expensive figures + grounding must be gone … expect(prompt).not.toContain(SNAPSHOT_JSON); diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index aa53e254b..38f51811c 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -52,12 +52,12 @@ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); -const { runRawCompletionWithFallback } = vi.hoisted(() => ({ - runRawCompletionWithFallback: vi.fn(), +const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({ + runStreamingRawCompletionWithFallback: vi.fn(), })); vi.mock("@/lib/ai/provider-runner", () => ({ AllProvidersFailedError: class extends Error {}, - runRawCompletionWithFallback, + runStreamingRawCompletionWithFallback, })); const { resolveProviderChain } = vi.hoisted(() => ({ @@ -194,6 +194,10 @@ const { parseKeyValuesSentinel } = await import("@/lib/ai/coach/keyvalues"); const { parseSuggestReminder } = await import("@/lib/ai/coach/suggest-reminder"); +// v1.22 (#89) — the provider call + guards + persistence now run INSIDE the +// stream producer (heartbeat-fronted). The mock captures the producer's promise +// so tests can await it after `post()` before asserting on persisted effects. +const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise })); vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: ( producer: (c: { @@ -201,8 +205,9 @@ vi.mock("@/lib/sse/create-stream", () => ({ enqueue: () => void; }) => void | Promise, ) => { - void Promise.resolve( - producer({ signal: { aborted: true }, enqueue: () => {} }), + // aborted:false so the producer streams to completion (persist + frames). + sse.done = Promise.resolve( + producer({ signal: { aborted: false }, enqueue: () => {} }), ); return new ReadableStream(); }, @@ -212,6 +217,13 @@ import { POST } from "../route"; const post = POST as unknown as (req: Request) => Promise; +/** Run a POST and wait for the in-stream producer to finish. */ +async function postAndDrain(body: Record): Promise { + const res = await post(chatReq(body)); + await sse.done; + return res; +} + function chatReq(body: Record): Request { return new Request("http://localhost/api/insights/chat", { method: "POST", @@ -233,10 +245,10 @@ describe("coach chat — tool-mode routing (F1)", () => { resolveProviderChain.mockResolvedValue([ { providerType: "anthropic", instance: {} }, // no supportsTools=false ⇒ tools on ]); - await post(chatReq({ message: "How is my BP?" })); + await postAndDrain({ message: "How is my BP?" }); expect(runCoachToolLoop).toHaveBeenCalledTimes(1); - expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled(); // The base context carries the inventory, NOT the snapshot figures. const loopArgs = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as { messages: Array<{ content: string }>; @@ -277,6 +289,7 @@ describe("coach chat — tool-mode routing (F1)", () => { ); const res = await post(chatReq({ message: "How is my BP?" })); + await sse.done; // No throw → no 500. The provider-error frame streams over a 200. expect(res.status).toBe(200); @@ -293,7 +306,7 @@ describe("coach chat — tool-mode routing (F1)", () => { resolveProviderChain.mockResolvedValue([ { providerType: "anthropic", instance: {} }, ]); - await post(chatReq({ message: "How is my BP?" })); + await postAndDrain({ message: "How is my BP?" }); const calls = (appendMessage as ReturnType).mock.calls; const assistantCall = calls.find( (c) => (c[0] as { role: string }).role === "assistant", @@ -338,7 +351,7 @@ describe("coach chat — tool-mode routing (F1)", () => { ], }), ); - await post(chatReq({ message: "How is my BP?" })); + await postAndDrain({ message: "How is my BP?" }); const calls = (appendMessage as ReturnType).mock.calls; const assistantCall = calls.find( (c) => (c[0] as { role: string }).role === "assistant", @@ -353,14 +366,14 @@ describe("coach chat — tool-mode routing (F1)", () => { { providerType: "anthropic", instance: {} }, { providerType: "local", instance: { supportsTools: false } }, ]); - runRawCompletionWithFallback.mockResolvedValue({ + runStreamingRawCompletionWithFallback.mockResolvedValue({ result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" }, workingProvider: { providerType: "anthropic" }, }); - await post(chatReq({ message: "How is my BP?" })); + await postAndDrain({ message: "How is my BP?" }); expect(runCoachToolLoop).not.toHaveBeenCalled(); - expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + expect(runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1); // Single-round budget reservation in the fallback path; the 4th arg is the // provider-aware daily cap (F1). expect(reserveBudget).toHaveBeenCalledWith( @@ -371,7 +384,7 @@ describe("coach chat — tool-mode routing (F1)", () => { ); // The legacy path ships the snapshot figures in the user turn. const params = ( - (runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as { + (runStreamingRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as { params: { messages: Array<{ content: string }> }; } ).params; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c43ad8afa..4efedf7ea 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -30,7 +30,8 @@ import { NextResponse, type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { redactOptional, redactSecrets } from "@/lib/logging/redact"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -40,7 +41,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { AllProvidersFailedError, - runRawCompletionWithFallback, + runStreamingRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; @@ -72,6 +73,11 @@ import { coachOutboundFallback, } from "@/lib/ai/coach/outbound-guard"; import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { + openerArchetypeHint, + shouldUseNameForTurn, + firstNameFromDisplayName, +} from "@/lib/ai/prompts/opener-archetype"; import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; import { @@ -93,6 +99,16 @@ import { import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; +import { + parseRememberSentinel, + captureReminderFromSentinel, + buildRememberAddendum, +} from "@/lib/ai/coach/reminders"; +import { + parseSuggestAction, + buildSuggestActionAddendum, + type CoachSuggestedAction, +} from "@/lib/ai/coach/suggest-action"; import { parseCoachPrefs, DEFAULT_REMINDER_SUGGESTION_PREFS, @@ -119,6 +135,17 @@ function encodeFrame(event: CoachStreamEvent): Uint8Array { return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`); } +/** + * v1.22 (#89) — keepalive heartbeat. An SSE COMMENT frame (a line starting + * with `:`) carries no `data:` payload, so the Coach client's frame parser + * (`parseSseChunk` finds the `data:` line; a comment frame has none) drops it + * silently — exactly what a keepalive should be. Flushed on `HEARTBEAT_MS` + * while the provider is still loading the model / generating, so a reverse + * proxy never idle-drops the long-lived SSE connection. + */ +const HEARTBEAT_MS = 12_000; +const HEARTBEAT_FRAME = new TextEncoder().encode(": ka\n\n"); + /** * Split a full assistant reply into ~roughly-word-sized chunks so the * UI gets a "streaming" feel even when the underlying provider client @@ -356,9 +383,24 @@ async function handleChatRequest(request: NextRequest): Promise { // we don't accidentally widen narrow per-call scopes. const prefsRow = await prisma.user.findUnique({ where: { id: userId }, - select: { coachPrefsJson: true }, + select: { + coachPrefsJson: true, + displayName: true, + // v1.22 (#89) — per-user response timeout (seconds), mainly for slow + // local/self-hosted backends. Threaded onto the provider call below. + aiResponseTimeoutSeconds: true, + }, }); const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson); + // v1.22 (#89) — the upstream timeout for THIS turn's provider call. On the + // streaming (local) path it is the per-idle-gap ceiling; on the buffered path + // it is the whole-call ceiling. A generous ~180 s default replaces the legacy + // 60 s client default that timed the Coach out on an MLX/exo backend whose + // first request loads the model. Clamped to sane bounds at write-time. + const aiResponseTimeoutMs = + prefsRow?.aiResponseTimeoutSeconds != null + ? prefsRow.aiResponseTimeoutSeconds * 1000 + : 180_000; const effectiveScope = scope?.window === undefined && coachPrefs.defaultWindow ? { ...(scope ?? {}), window: coachPrefs.defaultWindow } @@ -371,7 +413,24 @@ async function handleChatRequest(request: NextRequest): Promise { // v1.16.0 — composed self-context: structured questionnaire fields // plus age/gender merged in from the User profile. const aboutMe = await getSelfContextTextForUser(userId, locale); - const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); + // v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so + // the two memory/action clauses are appended at assembly time here: one + // teaches the model to emit `---REMEMBER---` (durable "remind me" capture), + // one teaches the closed `---SUGGEST-ACTION---` confirm-card allowlist. Both + // are provider-neutral sentinels stripped from the prose before it streams. + // v1.22 (W6) — per-turn personalization: a sparse, hash-gated first name and + // an opener-archetype hint so multi-turn sessions vary. The turn index is the + // count of prior turns, so the name surfaces on ~1-in-3 turns, varied and + // never on a fixed cadence; both omit cleanly when no display name is set. + const turnIndex = priorTurns.length; + const firstName = firstNameFromDisplayName(prefsRow?.displayName ?? null); + const coachPersonalization = { + firstName, + mayUseName: + firstName != null && shouldUseNameForTurn(`${userId}:${turnIndex}`), + openerHint: openerArchetypeHint(`${userId}:${turnIndex}`, locale), + }; + const systemPrompt = `${getCoachSystemPrompt(locale, coachPrefs, aboutMe, coachPersonalization)}\n\n${buildRememberAddendum(locale)}\n\n${buildSuggestActionAddendum(locale)}`; const allTurns: CoachTurn[] = [ ...priorTurns, { role: "user", content: message }, @@ -529,508 +588,665 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; return streamProviderError({ code: "coach.budget.exceeded" }); } - let result: CompletionResult; - let workingProviderType: string; - let toolTrace: CoachToolTrace[] = []; - // v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc - // prose number-verifier. Empty on the no-tools path. - let toolResultPayloads: unknown[] = []; - // v1.21.2 (A8) — no-tools/local-provider parity for the prose number-verifier. - // The tool path grades prose against the figures the tools returned; the - // no-tools path has no tools, so the authoritative set is the SNAPSHOT the - // model was actually shown this turn — `snapshot.sections`, the structured - // record `snapshotJson` is serialised from, which already carries the - // correlations-snapshot block. Populated only when the full figures were - // delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block - // was not re-sent, so there is no fresh authoritative set to grade against. - let noToolsSnapshotPayloads: unknown[] = []; - let totalTokensSpent: number; - // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached - // input the user did not re-pay for must not be billed to the daily meter). - let cachedTokensSpent = 0; - try { - if (toolMode) { - // v1.20.0 (F1) — base context: the full system prompt + a tool-mode - // grounding addendum, with the tiny DATA INVENTORY manifest and the - // transcript on the user turn. The figures are NOT in the prompt — the - // model pulls only what it needs via the retrieval tools. The inventory - // build reuses the snapshot we already computed (60s LRU), so the tools - // that fire this turn share its reads. - const inventory = await buildCoachDataInventory(userId, effectiveScope); - const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`; - // v1.21.0 (D1) — when the Coach was opened from a metric page/card, thread - // the launch sources into a one-line FOCUS hint so tool mode honours the - // metric the user is looking at (the no-tools path already narrows the - // snapshot; the inventory probes the full set, so this is the tool-mode - // equivalent of that narrowing). Empty string on a generic open. - const focusHint = renderFocusHint(effectiveScope?.sources); - const focusBlock = focusHint ? `${focusHint}\n\n` : ""; - const messages: AiMessage[] = [ - { - role: "user", - content: `${focusBlock}${renderDataInventory(inventory)}${guidedBlock} + // v1.22 (#89) — the provider call + every safety guard + persistence run + // INSIDE the SSE stream now (see `produceReply` + the heartbeat-fronted + // emit below). This is the real fix for a slow local backend: the HTTP + // response headers flush immediately, a keepalive comment frame goes out + // every few seconds while the model is still loading / generating so the + // reverse proxy never idle-drops the connection, and the no-tools (local) + // path streams real provider tokens with a per-idle-gap timeout instead of + // one buffered fetch under a total-timeout. Client-visible token frames + // still carry the FULLY-GUARDED text — every guard runs on the complete + // reply before the first token frame leaves, exactly as before. + type ReplyOutcome = + | { + ok: true; + replyText: string; + provenance: typeof snapshot.provenance; + suggestion: CoachSuggestion | null; + action: CoachSuggestedAction | null; + messageId: string; + totalTokens: number; + model: string | null; + } + | { ok: false; code: string }; + + async function produceReply(): Promise { + let result: CompletionResult; + let workingProviderType: string; + let toolTrace: CoachToolTrace[] = []; + // v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc + // prose number-verifier. Empty on the no-tools path. + let toolResultPayloads: unknown[] = []; + // v1.21.2 (A8) — no-tools/local-provider parity for the prose number-verifier. + // The tool path grades prose against the figures the tools returned; the + // no-tools path has no tools, so the authoritative set is the SNAPSHOT the + // model was actually shown this turn — `snapshot.sections`, the structured + // record `snapshotJson` is serialised from, which already carries the + // correlations-snapshot block. Populated only when the full figures were + // delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block + // was not re-sent, so there is no fresh authoritative set to grade against. + let noToolsSnapshotPayloads: unknown[] = []; + let totalTokensSpent: number; + // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached + // input the user did not re-pay for must not be billed to the daily meter). + let cachedTokensSpent = 0; + try { + if (toolMode) { + // v1.20.0 (F1) — base context: the full system prompt + a tool-mode + // grounding addendum, with the tiny DATA INVENTORY manifest and the + // transcript on the user turn. The figures are NOT in the prompt — the + // model pulls only what it needs via the retrieval tools. The inventory + // build reuses the snapshot we already computed (60s LRU), so the tools + // that fire this turn share its reads. + const inventory = await buildCoachDataInventory(userId, effectiveScope); + const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`; + // v1.21.0 (D1) — when the Coach was opened from a metric page/card, thread + // the launch sources into a one-line FOCUS hint so tool mode honours the + // metric the user is looking at (the no-tools path already narrows the + // snapshot; the inventory probes the full set, so this is the tool-mode + // equivalent of that narrowing). Empty string on a generic open. + const focusHint = renderFocusHint(effectiveScope?.sources); + const focusBlock = focusHint ? `${focusHint}\n\n` : ""; + const messages: AiMessage[] = [ + { + role: "user", + content: `${focusBlock}${renderDataInventory(inventory)}${guidedBlock} CONVERSATION ${transcript} Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`, - }, - ]; - const loop = await runCoachToolLoop({ - userId, - providers: chain, - system: toolSystem, - messages, - tools: COACH_TOOL_DEFS, - temperature: AI_BUDGETS.coach.temperature, - maxTokens: AI_BUDGETS.coach.maxTokens, - fallbackWindow: effectiveScope?.window, - // v1.21.0 (D5-1) — share the inventory's full-source snapshot across - // every tool so the turn builds ONE snapshot, not one per tool. The - // probe scope is the exact scope the inventory was built against, so the - // per-tool reads land its 60s LRU entry. - sharedScope: inventory.probeScope, - // v1.20.1 — thread the abort signal so a mid-generation disconnect tears - // down the per-round provider calls instead of paying the full cost. - signal: request.signal, - }); - result = loop.result; - workingProviderType = loop.workingProviderType; - toolTrace = loop.toolTrace; - toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data); - totalTokensSpent = loop.totalTokens; - cachedTokensSpent = loop.cachedTokens; - } else { - const fallback = await runRawCompletionWithFallback({ - userId, - providers: chain, - // v1.20.0 — the no-tools path still builds one assembled user turn (the - // transcript-flattening preserves the once-per-conversation snapshot - // trick + grounding exactly), so it ships as a single user message. The - // stable persona/grounding rides `system` and is now cache-eligible. - params: singleUserTurn({ - system: systemPrompt, - user: userPrompt, + }, + ]; + const loop = await runCoachToolLoop({ + userId, + providers: chain, + system: toolSystem, + messages, + tools: COACH_TOOL_DEFS, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, - // v1.20.1 — thread the request's abort signal so a mid-generation - // client disconnect tears the upstream provider call down instead of - // paying the full token cost into a closed connection. + fallbackWindow: effectiveScope?.window, + // v1.21.0 (D5-1) — share the inventory's full-source snapshot across + // every tool so the turn builds ONE snapshot, not one per tool. The + // probe scope is the exact scope the inventory was built against, so the + // per-tool reads land its 60s LRU entry. + sharedScope: inventory.probeScope, + // v1.20.1 — thread the abort signal so a mid-generation disconnect tears + // down the per-round provider calls instead of paying the full cost. signal: request.signal, - }), - }); - result = fallback.result; - workingProviderType = fallback.workingProvider.providerType; - totalTokensSpent = result.tokensUsed ?? 0; - cachedTokensSpent = result.cachedInputTokens ?? 0; - // v1.21.2 (A8) — the no-tools path showed the model the full SNAPSHOT only - // when `includeFullSnapshot` was set; otherwise it shipped the - // grounded-elsewhere pointer with no fresh figures, so there is nothing to - // grade. When figures WERE delivered, the authoritative set is the - // structured snapshot record (incl. the correlations block). - if (includeFullSnapshot) { - noToolsSnapshotPayloads = [snapshot.sections]; - } - } - } catch (err) { - // The provider chain failed outright — no tokens were billed, so refund - // the full reservation before surfacing the error frame. - await reconcileSpend(userId, reservation.reserved, 0, reqDateKey).catch( - () => {}, - ); - if (err instanceof AllProvidersFailedError) { - annotate({ - action: { name: "insights.coach.providerFailed" }, - meta: { - attempts: err.attempts.length, - firstStatus: err.attempts[0]?.httpStatus ?? null, - credentialExpired: err.primaryCredentialExpired, - }, - }); - // v1.11.0 W1 — when the user's PRIMARY provider failed with an - // auth-class status (401/403), the credential is dead, not the - // service. Surface a distinct `credential_expired` frame so the - // drawer can deep-link the user to reconnect rather than telling - // them to "try again later" — the gap that let an expired codex - // token silently kill all generation. - if (err.primaryCredentialExpired) { - return streamProviderError({ - code: "coach.provider.credential_expired", + // v1.22 (#89) — per-user response timeout for each tool-round call. + timeoutMs: aiResponseTimeoutMs, }); - } - // v1.4.25 W5 — distinguish provider rate-limit (every attempt - // landed on 429) from generic unavailability. The drawer's - // error-decoder surfaces the rate-limit copy with a warning - // toast instead of the generic provider-down message, so the - // user understands the limit is transient. - const allRateLimited = - err.attempts.length > 0 && - err.attempts.every((a) => a.httpStatus === 429); - return streamProviderError({ - code: allRateLimited - ? "coach.provider.rate_limited" - : "coach.provider.unavailable", - }); - } - // v1.21.3 — defence in depth. The chain runner wraps every hard provider - // failure in `AllProvidersFailedError`, but a provider client can still - // throw a tagged wire error that reaches here un-wrapped (e.g. a Codex 400 - // raised mid tool-loop on a path the chain runner did not catch). Such an - // error is a PROVIDER failure, not a server bug — surface the same graceful - // `coach.provider.*` frame the chain path uses rather than rethrowing into - // an HTTP 500 (the bug that took the live Coach down for codex users). Only - // a genuinely unexpected error (no upstream tag, no httpStatus) keeps the - // 500 + GlitchTip path so real defects stay visible. - const providerError = classifyBubblingProviderError(err); - if (providerError) { - annotate({ - action: { name: "insights.coach.providerFailed" }, - meta: { - attempts: 1, - firstStatus: providerError.httpStatus, - credentialExpired: providerError.code === "credential_expired", - unwrapped: true, - }, - }); - return streamProviderError({ - code: `coach.provider.${providerError.code}`, - }); - } - throw err; - } - - // v1.18.7 (SENIOR-DEV MEDIUM) — the provider call returned, so its tokens - // were billed regardless of reply quality. Reconcile the reservation - // against the actual count NOW, before any empty/sentinel short-circuit, so - // an empty or sentinel-only reply still records its burned cost (the old - // post-hoc `recordSpend` ran only on the happy path, undercounting these). - // v1.20.0 (F1) — reconcile against the SUMMED tokens across every tool round - // (the loop accumulates them); the no-tools path sums to the single call. - await reconcileSpend( - userId, - reservation.reserved, - totalTokensSpent, - reqDateKey, - cachedTokensSpent, - ).catch(() => { - // Ledger reconcile is best-effort; a failure leaves the conservative - // reservation in place (never an undercount) and never breaks the turn. - }); - - const rawReply = (result.content ?? "").trim(); - if (!rawReply) { - return streamProviderError({ code: "coach.provider.empty" }); - } - - // v1.4.22 — strip the optional `---KEYVALUES---` … `---END---` - // sentinel out of the prose. The stripped prose is what we stream - // to the client and persist; the parsed entries enrich the - // provenance envelope so the UI can render the collapsible - // "Worauf bezieht sich das?" disclosure. - const sentinel = parseKeyValuesSentinel(rawReply); - const proseAfterStrip = sentinel.prose.trim(); - // v1.4.22 W5 reconcile (Code-H1) — when the model emits a - // sentinel-only / malformed reply, `sentinel.prose` is empty after - // stripping. The previous fallback `sentinel.prose.trim() || rawReply` - // surfaced raw `---KEYVALUES---` markers to the user. The empty-prose - // condition signals an unusable provider response: short-circuit to - // the structured `coach.provider.empty` error frame instead of - // streaming the raw sentinel body. - if (!proseAfterStrip) { - annotate({ - action: { name: "coach.keyvalues.parse_failed" }, - meta: { - kept: sentinel.keyValues.length, - reason: "empty_prose_after_strip", - promptVersion: PROMPT_VERSION, - }, - }); - return streamProviderError({ code: "coach.provider.empty" }); - } - // v1.18.1 (Workstream C) — strip the optional `---SUGGEST-REMINDER---` - // block out of the prose-after-keyvalues. The model proposes a cadence; - // the gate decides whether it actually surfaces (module-toggle + opt-out - // + dismissal memory + cooldown + dedup against a live COACH reminder). - // A suppressed proposal leaves the prose unchanged and emits no card. - const suggestParse = parseSuggestReminder(proseAfterStrip); - let replyText = suggestParse.prose.trim() || proseAfterStrip; - - // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant - // reply, before persistence and streaming. The inbound `detectRefusal` - // guards the user's message; this guards the model's reply for a - // dose-prescription or a fabricated clinical risk score that slipped past - // the system-prompt GLP-1/grounding contracts. On a trip the turn is - // replaced with a calm, grounded fallback and any reminder suggestion / - // key-value provenance is dropped — the user never sees the unsafe text. - const outbound = screenCoachReply(replyText); - if (outbound.block && outbound.reason) { - replyText = coachOutboundFallback(outbound.reason, locale); - annotate({ - action: { name: "insights.coach.outbound_blocked" }, - meta: { reason: outbound.reason, promptVersion: PROMPT_VERSION }, - }); - await auditLog("insights.coach.outbound_blocked", { - userId, - details: { - conversationId: workingConversationId, - reason: outbound.reason, - }, - }); - } - - // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross- - // check every number the model cited against this turn's authoritative figure - // set; an unmatched number (transcription / paraphrase drift) is soft-stripped - // to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there - // is no authoritative set — the prompt-level grounding rule remains the - // backstop, exactly like the briefing's "no signals → skip". A blocked turn - // already carries canned fallback prose, so skip it. - // - // v1.21.2 (A8) — the authoritative set is the figures the tools returned on the - // tool path, and the SNAPSHOT the model was shown on the no-tools/local path - // (`snapshot.sections`, which already carries the correlations-snapshot block). - // Exactly one is populated per turn; the grading, tolerance, and exemptions are - // identical, so a number the model invents is flagged the same way on both. - const authoritativePayloads = - toolResultPayloads.length > 0 - ? toolResultPayloads - : noToolsSnapshotPayloads; - if (!outbound.block && authoritativePayloads.length > 0) { - const unverified = findUnverifiedCoachNumbers( - replyText, - authoritativePayloads, - ); - if (unverified.length > 0) { - const { prose: corrected, stripped } = stripUnverifiedNumbers( - replyText, - unverified, - ); - replyText = corrected; - annotate({ - action: { name: "coach.prose.number_unverified" }, - meta: { - flagged: unverified.length, - stripped, - // No raw values — just the count + truncated tokens for ops triage. - tokens: unverified.slice(0, 6).map((u) => u.source), - promptVersion: PROMPT_VERSION, - }, - }); - } - } - - // v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the - // model to only link a published `/learn/`, but that is guidance, not - // enforcement: a fabricated `/learn/` would otherwise ship as - // a dead link. Scrub any reference whose slug is not in the catalog (a real - // one is kept verbatim). A blocked turn carries canned fallback prose with no - // links, so skip it. - if (!outbound.block && replyText.includes("/learn/")) { - const scrubbed = scrubUnknownLearnLinks(replyText); - if (scrubbed.dropped.length > 0) { - replyText = scrubbed.text; - annotate({ - action: { name: "coach.learn.link_dropped" }, - meta: { - dropped: scrubbed.dropped.length, - // Truncated slug tokens for ops triage — no user content. - slugs: scrubbed.dropped.slice(0, 6), - promptVersion: PROMPT_VERSION, - }, - }); - } - } - - let surfacedSuggestion: CoachSuggestion | null = null; - if (!outbound.block && suggestParse.cadence) { - const decision = await gateSuggestion({ - prisma, - userId, - cadence: suggestParse.cadence, - prefs: - coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS, - }); - if (decision.surface) { - const cadence = suggestParse.cadence; - surfacedSuggestion = { - cadenceId: cadence.id, - measurementType: cadence.measurementType, - label: cadence.labelKey, - }; - // Stamp the cooldown anchor (frequency cap) onto the prefs blob. - const nextSuggestionPrefs = { - ...(coachPrefs.reminderSuggestions ?? - DEFAULT_REMINDER_SUGGESTION_PREFS), - lastSuggestedAt: new Date().toISOString(), - }; - void prisma.user - .update({ - where: { id: userId }, - data: { - coachPrefsJson: { - ...coachPrefs, - reminderSuggestions: nextSuggestionPrefs, - }, + result = loop.result; + workingProviderType = loop.workingProviderType; + toolTrace = loop.toolTrace; + toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data); + totalTokensSpent = loop.totalTokens; + cachedTokensSpent = loop.cachedTokens; + } else { + // v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain + // that includes a non-tool provider) runs through the STREAMING runner so + // the local client emits real tokens as they arrive and the per-idle-gap + // timeout governs. `onDelta` counts streamed chunks for observability; the + // heartbeat keeps the proxy connection warm and the assembled reply is + // returned in full so every guard below still runs on the complete text. + let streamedDeltas = 0; + const fallback = await runStreamingRawCompletionWithFallback({ + userId, + providers: chain, + onDelta: () => { + streamedDeltas += 1; }, - }) - .catch(() => { - // Cooldown stamp is best-effort: a write failure at worst lets a - // second suggestion through sooner, never breaks the chat turn. + // v1.20.0 — the no-tools path still builds one assembled user turn (the + // transcript-flattening preserves the once-per-conversation snapshot + // trick + grounding exactly), so it ships as a single user message. The + // stable persona/grounding rides `system` and is now cache-eligible. + params: singleUserTurn({ + system: systemPrompt, + user: userPrompt, + temperature: AI_BUDGETS.coach.temperature, + maxTokens: AI_BUDGETS.coach.maxTokens, + // v1.20.1 — thread the request's abort signal so a mid-generation + // client disconnect tears the upstream provider call down instead of + // paying the full token cost into a closed connection. + signal: request.signal, + // v1.22 (#89) — per-idle-gap timeout for the streaming local call / + // whole-call timeout for the buffered cloud fallback. + timeoutMs: aiResponseTimeoutMs, + }), }); + annotate({ + action: { name: "coach.stream.deltas" }, + meta: { deltas: streamedDeltas }, + }); + result = fallback.result; + workingProviderType = fallback.workingProvider.providerType; + totalTokensSpent = result.tokensUsed ?? 0; + cachedTokensSpent = result.cachedInputTokens ?? 0; + // v1.21.2 (A8) — the no-tools path showed the model the full SNAPSHOT only + // when `includeFullSnapshot` was set; otherwise it shipped the + // grounded-elsewhere pointer with no fresh figures, so there is nothing to + // grade. When figures WERE delivered, the authoritative set is the + // structured snapshot record (incl. the correlations block). + if (includeFullSnapshot) { + noToolsSnapshotPayloads = [snapshot.sections]; + } + } + } catch (err) { + // The provider chain failed outright — no tokens were billed, so refund + // the full reservation before surfacing the error frame. + await reconcileSpend(userId, reservation.reserved, 0, reqDateKey).catch( + () => {}, + ); + if (err instanceof AllProvidersFailedError) { + annotate({ + action: { name: "insights.coach.providerFailed" }, + meta: { + attempts: err.attempts.length, + firstStatus: err.attempts[0]?.httpStatus ?? null, + credentialExpired: err.primaryCredentialExpired, + }, + }); + // v1.11.0 W1 — when the user's PRIMARY provider failed with an + // auth-class status (401/403), the credential is dead, not the + // service. Surface a distinct `credential_expired` frame so the + // drawer can deep-link the user to reconnect rather than telling + // them to "try again later" — the gap that let an expired codex + // token silently kill all generation. + if (err.primaryCredentialExpired) { + return { ok: false, code: "coach.provider.credential_expired" }; + } + // v1.4.25 W5 — distinguish provider rate-limit (every attempt + // landed on 429) from generic unavailability. The drawer's + // error-decoder surfaces the rate-limit copy with a warning + // toast instead of the generic provider-down message, so the + // user understands the limit is transient. + const allRateLimited = + err.attempts.length > 0 && + err.attempts.every((a) => a.httpStatus === 429); + return { + ok: false, + code: allRateLimited + ? "coach.provider.rate_limited" + : "coach.provider.unavailable", + }; + } + // v1.21.3 — defence in depth. The chain runner wraps every hard provider + // failure in `AllProvidersFailedError`, but a provider client can still + // throw a tagged wire error that reaches here un-wrapped (e.g. a Codex 400 + // raised mid tool-loop on a path the chain runner did not catch). Such an + // error is a PROVIDER failure, not a server bug — surface the same graceful + // `coach.provider.*` frame the chain path uses rather than rethrowing into + // an HTTP 500 (the bug that took the live Coach down for codex users). Only + // a genuinely unexpected error (no upstream tag, no httpStatus) keeps the + // 500 + GlitchTip path so real defects stay visible. + const providerError = classifyBubblingProviderError(err); + if (providerError) { + annotate({ + action: { name: "insights.coach.providerFailed" }, + meta: { + attempts: 1, + firstStatus: providerError.httpStatus, + credentialExpired: providerError.code === "credential_expired", + unwrapped: true, + }, + }); + return { ok: false, code: `coach.provider.${providerError.code}` }; + } + throw err; + } + + // v1.18.7 (SENIOR-DEV MEDIUM) — the provider call returned, so its tokens + // were billed regardless of reply quality. Reconcile the reservation + // against the actual count NOW, before any empty/sentinel short-circuit, so + // an empty or sentinel-only reply still records its burned cost (the old + // post-hoc `recordSpend` ran only on the happy path, undercounting these). + // v1.20.0 (F1) — reconcile against the SUMMED tokens across every tool round + // (the loop accumulates them); the no-tools path sums to the single call. + await reconcileSpend( + userId, + reservation.reserved, + totalTokensSpent, + reqDateKey, + cachedTokensSpent, + ).catch(() => { + // Ledger reconcile is best-effort; a failure leaves the conservative + // reservation in place (never an undercount) and never breaks the turn. + }); + + const rawReply = (result.content ?? "").trim(); + if (!rawReply) { + return { ok: false, code: "coach.provider.empty" }; + } + + // v1.4.22 — strip the optional `---KEYVALUES---` … `---END---` + // sentinel out of the prose. The stripped prose is what we stream + // to the client and persist; the parsed entries enrich the + // provenance envelope so the UI can render the collapsible + // "Worauf bezieht sich das?" disclosure. + const sentinel = parseKeyValuesSentinel(rawReply); + const proseAfterStrip = sentinel.prose.trim(); + // v1.4.22 W5 reconcile (Code-H1) — when the model emits a + // sentinel-only / malformed reply, `sentinel.prose` is empty after + // stripping. The previous fallback `sentinel.prose.trim() || rawReply` + // surfaced raw `---KEYVALUES---` markers to the user. The empty-prose + // condition signals an unusable provider response: short-circuit to + // the structured `coach.provider.empty` error frame instead of + // streaming the raw sentinel body. + if (!proseAfterStrip) { annotate({ - action: { name: "coach.reminder.suggested" }, - meta: { cadenceId: cadence.id, metric: cadence.measurementType }, + action: { name: "coach.keyvalues.parse_failed" }, + meta: { + kept: sentinel.keyValues.length, + reason: "empty_prose_after_strip", + promptVersion: PROMPT_VERSION, + }, }); - } else { + return { ok: false, code: "coach.provider.empty" }; + } + // v1.18.1 (Workstream C) — strip the optional `---SUGGEST-REMINDER---` + // block out of the prose-after-keyvalues. The model proposes a cadence; + // the gate decides whether it actually surfaces (module-toggle + opt-out + // + dismissal memory + cooldown + dedup against a live COACH reminder). + // A suppressed proposal leaves the prose unchanged and emits no card. + const suggestParse = parseSuggestReminder(proseAfterStrip); + let replyText = suggestParse.prose.trim() || proseAfterStrip; + + // v1.22 (B2) — strip the optional `---REMEMBER---` block and capture the + // reminder INLINE on this turn (not in the >20-turn memory worker), so a + // casual "remind me about X" in a SHORT chat is no longer lost. The note is + // the user's OWN explicit request → captured silently as `active` (no confirm + // gate); the model acknowledges it in prose. A missing note / invalid `when` + // drops the block (the user never sees the raw marker). Fire-and-forget: the + // capture write must never break the chat turn. + const rememberParse = parseRememberSentinel(replyText, new Date()); + replyText = rememberParse.prose.trim() || replyText; + if (rememberParse.reminder) { + const capture = rememberParse.reminder; + void captureReminderFromSentinel({ + userId, + conversationId: workingConversationId, + parsed: capture, + }).catch(() => { + // Reminder capture is best-effort; never sink the turn. + }); + } else if (rememberParse.malformed) { annotate({ - action: { name: "coach.reminder.suppressed" }, - meta: { cadenceId: suggestParse.cadence.id, reason: decision.reason }, + action: { name: "coach.reminder.capture_malformed" }, + meta: { conversationId: workingConversationId }, }); } - } - const enrichedProvenance: typeof snapshot.provenance = { - ...snapshot.provenance, - // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the - // key-values from the discarded reply must not ride along as provenance. - ...(!outbound.block && sentinel.keyValues.length > 0 - ? { keyValues: sentinel.keyValues } - : {}), - ...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}), - // v1.20.0 (F1) — persist the retrieval-tool trace (which tools ran + - // whether each found data) so a reload can show "what I looked at" and the - // audit can replay grounding. Metadata only. - ...(toolTrace.length > 0 - ? { - toolCalls: toolTrace.map((t) => ({ - name: t.name, - present: t.present, - })), - } - : {}), - }; - if (sentinel.malformed) { - // Graceful degrade: log so ops can spot a provider whose - // sentinel format has drifted, but pass the prose through - // unchanged. v1.4.23 H1 — split the annotation: - // - parse_partial: at least one row parsed AND at least one - // row failed (mixed-format drift on a single reply) - // - parse_failed: the whole block was unusable - // Both annotations carry the per-line `reasons` array so an ops - // dashboard can attribute the failure cause without re-running - // the parser. - const reasons = sentinel.malformedEntries.map((entry) => entry.reason); - const annotationName = - sentinel.keyValues.length > 0 && sentinel.malformedEntries.length > 0 - ? "coach.keyvalues.parse_partial" - : "coach.keyvalues.parse_failed"; + + // v1.22 (F6) — strip the optional `---SUGGEST-ACTION---` block (the + // generalised confirm→apply moat). The model names ONE action from the closed + // allowlist (`checkup.create` / `reminder.note`); the card surfaces additively + // and NOTHING is created until the user taps confirm (the entity is built + // server-side, field-by-field, by `POST /api/coach/suggested-actions`). + const actionParse = parseSuggestAction(replyText); + replyText = actionParse.prose.trim() || replyText; + + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant + // reply, before persistence and streaming. The inbound `detectRefusal` + // guards the user's message; this guards the model's reply for a + // dose-prescription or a fabricated clinical risk score that slipped past + // the system-prompt GLP-1/grounding contracts. On a trip the turn is + // replaced with a calm, grounded fallback and any reminder suggestion / + // key-value provenance is dropped — the user never sees the unsafe text. + const outbound = screenCoachReply(replyText); + if (outbound.block && outbound.reason) { + replyText = coachOutboundFallback(outbound.reason, locale); + annotate({ + action: { name: "insights.coach.outbound_blocked" }, + meta: { reason: outbound.reason, promptVersion: PROMPT_VERSION }, + }); + await auditLog("insights.coach.outbound_blocked", { + userId, + details: { + conversationId: workingConversationId, + reason: outbound.reason, + }, + }); + } + + // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross- + // check every number the model cited against this turn's authoritative figure + // set; an unmatched number (transcription / paraphrase drift) is soft-stripped + // to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there + // is no authoritative set — the prompt-level grounding rule remains the + // backstop, exactly like the briefing's "no signals → skip". A blocked turn + // already carries canned fallback prose, so skip it. + // + // v1.21.2 (A8) — the authoritative set is the figures the tools returned on the + // tool path, and the SNAPSHOT the model was shown on the no-tools/local path + // (`snapshot.sections`, which already carries the correlations-snapshot block). + // Exactly one is populated per turn; the grading, tolerance, and exemptions are + // identical, so a number the model invents is flagged the same way on both. + const authoritativePayloads = + toolResultPayloads.length > 0 + ? toolResultPayloads + : noToolsSnapshotPayloads; + if (!outbound.block && authoritativePayloads.length > 0) { + const unverified = findUnverifiedCoachNumbers( + replyText, + authoritativePayloads, + ); + if (unverified.length > 0) { + const { prose: corrected, stripped } = stripUnverifiedNumbers( + replyText, + unverified, + ); + replyText = corrected; + annotate({ + action: { name: "coach.prose.number_unverified" }, + meta: { + flagged: unverified.length, + stripped, + // No raw values — just the count + truncated tokens for ops triage. + tokens: unverified.slice(0, 6).map((u) => u.source), + promptVersion: PROMPT_VERSION, + }, + }); + } + } + + // v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the + // model to only link a published `/learn/`, but that is guidance, not + // enforcement: a fabricated `/learn/` would otherwise ship as + // a dead link. Scrub any reference whose slug is not in the catalog (a real + // one is kept verbatim). A blocked turn carries canned fallback prose with no + // links, so skip it. + if (!outbound.block && replyText.includes("/learn/")) { + const scrubbed = scrubUnknownLearnLinks(replyText); + if (scrubbed.dropped.length > 0) { + replyText = scrubbed.text; + annotate({ + action: { name: "coach.learn.link_dropped" }, + meta: { + dropped: scrubbed.dropped.length, + // Truncated slug tokens for ops triage — no user content. + slugs: scrubbed.dropped.slice(0, 6), + promptVersion: PROMPT_VERSION, + }, + }); + } + } + + let surfacedSuggestion: CoachSuggestion | null = null; + if (!outbound.block && suggestParse.cadence) { + const decision = await gateSuggestion({ + prisma, + userId, + cadence: suggestParse.cadence, + prefs: + coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS, + }); + if (decision.surface) { + const cadence = suggestParse.cadence; + surfacedSuggestion = { + cadenceId: cadence.id, + measurementType: cadence.measurementType, + label: cadence.labelKey, + }; + // Stamp the cooldown anchor (frequency cap) onto the prefs blob. + const nextSuggestionPrefs = { + ...(coachPrefs.reminderSuggestions ?? + DEFAULT_REMINDER_SUGGESTION_PREFS), + lastSuggestedAt: new Date().toISOString(), + }; + void prisma.user + .update({ + where: { id: userId }, + data: { + coachPrefsJson: { + ...coachPrefs, + reminderSuggestions: nextSuggestionPrefs, + }, + }, + }) + .catch(() => { + // Cooldown stamp is best-effort: a write failure at worst lets a + // second suggestion through sooner, never breaks the chat turn. + }); + annotate({ + action: { name: "coach.reminder.suggested" }, + meta: { cadenceId: cadence.id, metric: cadence.measurementType }, + }); + } else { + annotate({ + action: { name: "coach.reminder.suppressed" }, + meta: { cadenceId: suggestParse.cadence.id, reason: decision.reason }, + }); + } + } + // v1.22 (F6) — surface the confirm-card action when the turn was not blocked. + // Additive: the prose already stands alone; the card only offers the one-tap + // confirm. Nothing is created server-side until the user taps it. + let surfacedAction: CoachSuggestedAction | null = null; + if (!outbound.block && actionParse.action) { + surfacedAction = actionParse.action; + annotate({ + action: { name: "coach.action.suggested" }, + meta: { actionType: actionParse.action.actionType }, + }); + } + + const enrichedProvenance: typeof snapshot.provenance = { + ...snapshot.provenance, + ...(surfacedAction ? { suggestedAction: surfacedAction } : {}), + // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the + // key-values from the discarded reply must not ride along as provenance. + ...(!outbound.block && sentinel.keyValues.length > 0 + ? { keyValues: sentinel.keyValues } + : {}), + ...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}), + // v1.20.0 (F1) — persist the retrieval-tool trace (which tools ran + + // whether each found data) so a reload can show "what I looked at" and the + // audit can replay grounding. Metadata only. + ...(toolTrace.length > 0 + ? { + toolCalls: toolTrace.map((t) => ({ + name: t.name, + present: t.present, + })), + } + : {}), + }; + if (sentinel.malformed) { + // Graceful degrade: log so ops can spot a provider whose + // sentinel format has drifted, but pass the prose through + // unchanged. v1.4.23 H1 — split the annotation: + // - parse_partial: at least one row parsed AND at least one + // row failed (mixed-format drift on a single reply) + // - parse_failed: the whole block was unusable + // Both annotations carry the per-line `reasons` array so an ops + // dashboard can attribute the failure cause without re-running + // the parser. + const reasons = sentinel.malformedEntries.map((entry) => entry.reason); + const annotationName = + sentinel.keyValues.length > 0 && sentinel.malformedEntries.length > 0 + ? "coach.keyvalues.parse_partial" + : "coach.keyvalues.parse_failed"; + annotate({ + action: { name: annotationName }, + meta: { + kept: sentinel.keyValues.length, + malformedCount: sentinel.malformedEntries.length, + reasons, + promptVersion: PROMPT_VERSION, + }, + }); + } + + // Persist the assistant message BEFORE we begin streaming; if the + // client disconnects we still have the canonical row. + const assistantMessage = await appendMessage({ + conversationId: workingConversationId, + role: "assistant", + content: replyText, + metricSource: enrichedProvenance, + providerType: workingProviderType, + promptVersion: PROMPT_VERSION, + // v1.18.9 — persist the per-turn token count + model so the quiet + // token footer survives a conversation reload. The live turn paints + // from the `done.usage` SSE frame below; reloads read these columns. + // v1.20.0 (F1) — the summed cost across every tool round, so the footer + // reflects the true turn cost on the tool path too. + tokensUsed: totalTokensSpent || null, + model: result.model ?? null, + }); + + // v1.18.7 — the day's spend was already reconciled against the + // reservation immediately after the provider returned (above), so there is + // no post-persistence ledger bump here. The reservation guarantees the + // tokens are counted even if persistence or streaming fails afterwards. + annotate({ - action: { name: annotationName }, + action: { name: "insights.coach.replied" }, meta: { - kept: sentinel.keyValues.length, - malformedCount: sentinel.malformedEntries.length, - reasons, + provider: workingProviderType, + // v1.20.0 (F1) — summed tokens across every tool round (the loop) or the + // single call (no-tools path), so the dashboards see the true turn cost. + tokens: totalTokensSpent, promptVersion: PROMPT_VERSION, + conversationId: workingConversationId, + historyTurns: window.length, + // v1.20.0 (F1) — whether this turn ran the tool-retrieval path, and how + // many tools it fetched, so the dashboards can correlate the token delta + // with the new path vs the legacy snapshot path. + toolMode, + toolsCalled: toolTrace.length, + // v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the + // expensive prefix) vs the cheap pointer. On the tool path the snapshot + // never rides the prompt, so this is the legacy-path signal only. + snapshotSent: !toolMode && includeFullSnapshot, + promptChars: userPrompt.length, + // v1.7.0 — count of provenance metrics the snapshot surfaced + // this turn (a proxy for cluster breadth) so the dashboards can + // correlate reply shape with cluster activation. + clusterCount: snapshot.provenance.metrics.length, }, }); - } - // Persist the assistant message BEFORE we begin streaming; if the - // client disconnects we still have the canonical row. - const assistantMessage = await appendMessage({ - conversationId: workingConversationId, - role: "assistant", - content: replyText, - metricSource: enrichedProvenance, - providerType: workingProviderType, - promptVersion: PROMPT_VERSION, - // v1.18.9 — persist the per-turn token count + model so the quiet - // token footer survives a conversation reload. The live turn paints - // from the `done.usage` SSE frame below; reloads read these columns. - // v1.20.0 (F1) — the summed cost across every tool round, so the footer - // reflects the true turn cost on the tool path too. - tokensUsed: totalTokensSpent || null, - model: result.model ?? null, - }); - - // v1.18.7 — the day's spend was already reconciled against the - // reservation immediately after the provider returned (above), so there is - // no post-persistence ledger bump here. The reservation guarantees the - // tokens are counted even if persistence or streaming fails afterwards. - - annotate({ - action: { name: "insights.coach.replied" }, - meta: { - provider: workingProviderType, - // v1.20.0 (F1) — summed tokens across every tool round (the loop) or the - // single call (no-tools path), so the dashboards see the true turn cost. - tokens: totalTokensSpent, - promptVersion: PROMPT_VERSION, - conversationId: workingConversationId, - historyTurns: window.length, - // v1.20.0 (F1) — whether this turn ran the tool-retrieval path, and how - // many tools it fetched, so the dashboards can correlate the token delta - // with the new path vs the legacy snapshot path. - toolMode, - toolsCalled: toolTrace.length, - // v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the - // expensive prefix) vs the cheap pointer. On the tool path the snapshot - // never rides the prompt, so this is the legacy-path signal only. - snapshotSent: !toolMode && includeFullSnapshot, - promptChars: userPrompt.length, - // v1.7.0 — count of provenance metrics the snapshot surfaced - // this turn (a proxy for cluster breadth) so the dashboards can - // correlate reply shape with cluster activation. - clusterCount: snapshot.provenance.metrics.length, - }, - }); + // produceReply success — hand the fully-guarded reply + provenance back to + // the heartbeat-fronted stream below for client-visible emission. + return { + ok: true, + replyText, + provenance: enrichedProvenance, + suggestion: surfacedSuggestion, + action: surfacedAction, + messageId: assistantMessage.id, + totalTokens: totalTokensSpent, + model: result.model ?? null, + }; + } // end produceReply // ── Stream the body to the client ──────────────────────────── - // v1.12.0 — yield to the event loop between token frames so each one - // flushes as its own network chunk. The provider clients return the - // full reply in one shot; without the yield the whole tokenised body - // was enqueued synchronously inside `start()` and the runtime - // coalesced every frame into a single read, so the client painted the - // answer all at once despite the per-token render path in `use-coach`. - // A zero-delay yield is enough to land each frame on its own tick — - // the visible cadence reads ChatGPT/Claude-style without a contrived - // sleep. The refusal + error paths stay single-frame (nothing to - // pace). + // v1.22 (#89) — the provider call + every guard + persistence run INSIDE the + // stream (via `produceReply`) so the HTTP headers + a keepalive heartbeat + // flush immediately and keep the reverse proxy from idle-dropping the + // connection while a slow local backend is still loading the model / + // generating. Only once the FULLY-GUARDED reply is ready do client-visible + // token frames go out. + // + // v1.12.0 — yield to the event loop between token frames so each one flushes + // as its own network chunk; the visible cadence reads ChatGPT/Claude-style. const stream = createSseStream(async (controller) => { - for (const tok of tokeniseForStreaming(replyText)) { - // v1.18.10 (A-2) — stop tokenising the moment the client disconnects; - // the cancel handler flips this signal, so we don't pace frames into a - // closed connection. + // Keepalive: an SSE comment frame (`: ka`) the client parser ignores, + // flushed on an interval through the pre-first-token (prompt-processing) + // and generation phases. + const heartbeat = setInterval(() => { + controller.enqueue(HEARTBEAT_FRAME); + }, HEARTBEAT_MS); + + let outcome: ReplyOutcome; + try { + outcome = await produceReply(); + } catch (err) { + clearInterval(heartbeat); + // The stream is already open, so we cannot 500: a tagged provider error + // surfaces a graceful `coach.provider.*` frame; anything else degrades to + // a generic unavailable frame. A genuine defect is still annotated. + const providerError = classifyBubblingProviderError(err); + if (!providerError) { + // Not a tagged provider failure — a genuine server defect raised + // inside the open producer (e.g. a DB error in the post-stream + // `appendMessage` persistence). Record it on the wide event and + // forward it to GlitchTip so it stays visible instead of reading + // as a provider outage; the stream is open, so we still emit an + // error frame below rather than a 500. + getEvent()?.setError( + err instanceof Error ? err : new Error(String(err)), + ); + void reportCoachStreamDefect(err); + } + annotate({ + action: { name: "insights.coach.streamError" }, + meta: { + unwrapped: Boolean(providerError), + reported: !providerError, + firstStatus: providerError?.httpStatus ?? null, + }, + }); + if (!controller.signal.aborted) { + const code = providerError + ? `coach.provider.${providerError.code}` + : "coach.provider.unavailable"; + controller.enqueue(encodeFrame({ type: "error", code, message: code })); + } + return; + } + clearInterval(heartbeat); + + if (!outcome.ok) { + if (!controller.signal.aborted) { + controller.enqueue( + encodeFrame({ + type: "error", + code: outcome.code, + message: outcome.code, + }), + ); + } + return; + } + + for (const tok of tokeniseForStreaming(outcome.replyText)) { + // v1.18.10 (A-2) — stop tokenising the moment the client disconnects. if (controller.signal.aborted) return; controller.enqueue(encodeFrame({ type: "token", token: tok })); await flushTick(); } if (controller.signal.aborted) return; controller.enqueue( - encodeFrame({ - type: "provenance", - metricSource: enrichedProvenance, - }), + encodeFrame({ type: "provenance", metricSource: outcome.provenance }), ); - // v1.18.1 (Workstream C) — additive `suggestion` frame. Older clients - // ignore it; newer ones render the one-tap action card. - if (surfacedSuggestion) { + // v1.18.1 (Workstream C) — additive `suggestion` frame. + if (outcome.suggestion) { controller.enqueue( - encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }), + encodeFrame({ type: "suggestion", suggestion: outcome.suggestion }), ); } - // v1.18.9 — additive `usage` envelope on the `done` frame so the client - // can paint the quiet per-message token footer the instant the stream - // closes. Server-authoritative: the client renders these numbers, never - // recomputes them. Older clients drop the unknown key. + // v1.22 (F6) — additive `suggestedAction` frame. + if (outcome.action) { + controller.enqueue( + encodeFrame({ + type: "suggestedAction", + suggestedAction: outcome.action, + }), + ); + } + // v1.18.9 — additive `usage` envelope on the `done` frame. controller.enqueue( encodeFrame({ type: "done", conversationId: workingConversationId, - messageId: assistantMessage.id, + messageId: outcome.messageId, usage: { - // v1.20.0 (F1) — summed across every tool round. - totalTokens: totalTokensSpent || null, - model: result.model ?? null, + totalTokens: outcome.totalTokens || null, + model: outcome.model, }, }), ); @@ -1125,6 +1341,46 @@ function classifyBubblingProviderError(err: unknown): { return { code: "unavailable", httpStatus: status }; } +/** + * Fire-and-forget GlitchTip forward for a genuine server defect that + * surfaces INSIDE the open SSE producer — e.g. a Prisma failure in the + * post-stream `appendMessage` persistence, which runs after the provider + * call and therefore outside its try/catch. Once the stream is open the + * route can no longer return a 500, so without this such a defect would + * read as a generic provider outage in error tracking rather than the + * server bug it is. Mirrors the api-handler / worker forwarders: dynamic + * import (no cycle, no startup cost), redacted message + stack, and it + * NEVER throws so a sink failure cannot mask the original error. + */ +async function reportCoachStreamDefect(err: unknown): Promise { + const e = + err instanceof Error ? err : new Error("Unknown coach stream error"); + const message = redactSecrets(`[insights.coach.stream] ${e.message}`); + console.error("[coach-stream]", message, e); + try { + const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = + await Promise.all([ + import("@/lib/monitoring-settings"), + import("@/lib/monitoring/glitchtip"), + ]); + const settings = await getGlitchtipSettings(); + if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return; + await sendGlitchtipEvent({ + dsn: settings.glitchtipDsn, + input: { + environment: settings.glitchtipEnvironment || "production", + message, + level: "error", + type: e.name || "Error", + stack: redactOptional(e.stack), + sourceTag: "healthlog-api-handler", + }, + }); + } catch { + /* the reporter must never throw */ + } +} + function streamProviderError(args: { code: string }): Response { const stream = createSseStream((controller) => { controller.enqueue( diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 48d9d58c6..13653ac7d 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -39,7 +39,11 @@ export const GET = apiHandler(async () => { prisma.coachMessage.findFirst({ where: { role: "assistant", conversation: { userId: user.id } }, orderBy: { createdAt: "desc" }, - select: { createdAt: true }, + // v1.22 (W5) — also return the owning conversation id so the FAB can + // deep-link straight into the proactive thread (`/coach?c=`) + // instead of opening a blank new chat and relying on a most-recent + // heuristic to find it. + select: { createdAt: true, conversationId: true }, }), prisma.user.findUnique({ where: { id: user.id }, @@ -55,6 +59,10 @@ export const GET = apiHandler(async () => { return apiSuccess({ nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, unread, + // The conversation holding the newest assistant message. Present + // whenever a Coach reply exists; the FAB only consumes it on the + // unread path. + conversationId: lastAssistant?.conversationId ?? null, }); }); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index ea5d940a5..a4d8b711f 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -18,6 +18,8 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) }, illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.22 — labs ↔ outcome pass fetches lab draws; default empty. + labResult: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -109,6 +111,8 @@ beforeEach(() => { (prisma.illnessDayLog.findMany as ReturnType).mockResolvedValue( [], ); + // v1.22 — labs ↔ outcome pass reads lab draws; default empty. + (prisma.labResult.findMany as ReturnType).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index e9b511687..33cf3329a 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -26,9 +26,12 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import type { MeasurementType } from "@/generated/prisma/client"; import { discoverCorrelations, + discoverEmergingCorrelations, + discoverLabOutcomeCorrelations, discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + EARLY_WINDOW_DAYS, MEDICATION_COMPLIANCE_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, @@ -36,6 +39,7 @@ import { } from "@/lib/insights/correlation-discovery"; import { fetchComplianceSeries, + fetchLabDraws, fetchSymptomSeries, } from "@/lib/insights/correlation-channel-series"; @@ -147,8 +151,12 @@ export const GET = apiHandler(async () => { // v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). - const complianceSeries = await fetchComplianceSeries(user.id, tz, since); - const symptomSeries = await fetchSymptomSeries(user.id, tz, since); + // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside. + const [complianceSeries, symptomSeries, labDraws] = await Promise.all([ + fetchComplianceSeries(user.id, tz, since), + fetchSymptomSeries(user.id, tz, since), + fetchLabDraws(user.id, tz, since), + ]); const points = (key: string): DailySeriesPoint[] => key === "MOOD" @@ -175,6 +183,21 @@ export const GET = apiHandler(async () => { const result = discoverCorrelations(series); + // v1.22 — rolling early-detection pass over the trailing window, re-using + // the already-built series (no extra DB read). Emerging pairs exclude anything + // the retrospective scan already established (no double-count). + const recentFromDayKey = tzDayKey( + new Date(now.getTime() - EARLY_WINDOW_DAYS * MS_PER_DAY), + tz, + ); + const emerging = discoverEmergingCorrelations(series, result, { + recentFromDayKey, + }); + + // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades + // to absent when the user has too few draws to clear the per-pair floor. + const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series); + annotate({ action: { name: "insights.correlations.discover" }, meta: { @@ -185,8 +208,13 @@ export const GET = apiHandler(async () => { // two sparse new channels reached the n ≥ 20 floor or degraded to absent. compliance_days: complianceSeries.points.length, symptom_days: symptomSeries.points.length, + // v1.22 — early-detection + labs reach. + emerging: emerging.emerging.length, + emerging_window_days: emerging.windowDays, + lab_draws: labDraws.length, + lab_correlations: labCorrelations.discovered.length, }, }); - return apiSuccess(result); + return apiSuccess({ ...result, emerging, labCorrelations }); }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 731779898..72c5af606 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -34,7 +34,10 @@ import { buildUserPrompt, type ComparisonSnapshot, } from "@/lib/ai/prompts/insight-system-prompt"; -import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { + buildSystemPromptWithReferences, + buildBriefingPersonalisationBlock, +} from "@/lib/ai/prompts/insight-generator"; import { buildAboutMeInsightBlock, getSelfContextTextForUser, @@ -57,6 +60,7 @@ import { singleUserTurn, type InsightResult, } from "@/lib/ai/types"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { AllProvidersFailedError, @@ -397,6 +401,8 @@ export const POST = apiHandler((request: NextRequest) => dateOfBirth: true, gender: true, heightCm: true, + // v1.22 (W6) — first name for the sparse, hash-gated briefing opener. + displayName: true, }, }); @@ -665,6 +671,14 @@ export const POST = apiHandler((request: NextRequest) => userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); } + // v1.22 (W6) — opener-archetype rotation + sparse first-name personalization + // (deterministic per user+day; omitted for unnamed accounts). + userPrompt += buildBriefingPersonalisationBlock( + userId, + dbUser?.displayName ?? null, + locale, + ); + // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections // this briefing actually carries, so a normative claim can cite a real // `referenceId` the schema + UI footnote already support. Returns the plain @@ -700,6 +714,10 @@ export const POST = apiHandler((request: NextRequest) => user: userPrompt, temperature: 0.3, maxTokens: 1500, + // v1.21.5 — wider upstream budget so the reasoning-heavy briefing + // generation is not aborted at the client's 60 s default on large + // accounts (which returned an empty briefing + trend narrative). + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, // The reply is parsed with `JSON.parse` below, so opt the OpenAI / // Codex chains into their strict JSON mode (gated on this flag). responseFormat: "json", @@ -821,6 +839,7 @@ export const POST = apiHandler((request: NextRequest) => let ungrounded = findUngroundedBriefingNumbers( readBriefingBlock(insights), signals, + features, ); if (ungrounded.length > 0) { annotate({ @@ -836,6 +855,8 @@ export const POST = apiHandler((request: NextRequest) => user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, temperature: 0.3, maxTokens: 1500, + // v1.21.5 — wider upstream budget; see the first generation call. + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, // Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode. responseFormat: "json", }), @@ -848,6 +869,7 @@ export const POST = apiHandler((request: NextRequest) => ungrounded = findUngroundedBriefingNumbers( readBriefingBlock(retryInsights), signals, + features, ); if (ungrounded.length === 0) { insights = retryInsights; diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 0c58d877b..6f2250e96 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -18,7 +18,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), update: vi.fn() }, }, })); @@ -35,14 +35,26 @@ vi.mock("@/lib/validations/notifications", () => ({ isPublicUrl: vi.fn(() => true), })); +vi.mock("@/lib/ai/local-host-allowlist", () => ({ + isLocalAiHostAllowed: vi.fn(() => false), +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); -import { GET } from "../route"; +import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { resolveProviderAvailability } from "@/lib/ai/provider"; +function patchRequest(body: unknown): Request { + return new Request("http://localhost/api/user/ai-provider", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + interface AiProviderResponse { data: { provider: string | null; @@ -126,4 +138,101 @@ describe("GET /api/user/ai-provider availability", () => { expect(body.data.aiAvailable).toBe(false); expect(body.data.managedBy).toBeNull(); }); + + it("returns the v1.22 timeout + OCR fields", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: "OPENAI", + aiModel: "gpt-4o", + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiResponseTimeoutSeconds: 240, + aiOcrEnabled: true, + aiOcrProvider: "ANTHROPIC", + aiOcrModel: "claude-sonnet-4-6", + aiOcrBaseUrl: null, + aiOcrKeyEncrypted: "enc-ocr-1234", + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "user", + }); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as { + data: { + responseTimeoutSeconds: number | null; + ocrEnabled: boolean; + ocrProvider: string | null; + hasOcrKey: boolean; + ocrKeyPreview: string | null; + }; + }; + + expect(body.data.responseTimeoutSeconds).toBe(240); + expect(body.data.ocrEnabled).toBe(true); + expect(body.data.ocrProvider).toBe("ANTHROPIC"); + expect(body.data.hasOcrKey).toBe(true); + // Preview is `...` + last 4 of the decrypted value (decrypt → `dec:...`). + expect(body.data.ocrKeyPreview).toMatch(/^\.\.\./); + }); +}); + +describe("PATCH /api/user/ai-provider — v1.22 fields", () => { + const patch = PATCH as (req: Request) => Promise; + + it("persists a valid response timeout", async () => { + const res = await patch(patchRequest({ responseTimeoutSeconds: 120 })); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ aiResponseTimeoutSeconds: 120 }), + }), + ); + }); + + it("rejects an out-of-range response timeout", async () => { + await expect( + patch(patchRequest({ responseTimeoutSeconds: 5 })), + ).rejects.toThrow(/between 10 and 600/); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("clears the timeout when null is sent", async () => { + await patch(patchRequest({ responseTimeoutSeconds: null })); + expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ aiResponseTimeoutSeconds: null }), + }), + ); + }); + + it("persists OCR provider config and encrypts the key", async () => { + const res = await patch( + patchRequest({ + ocrEnabled: true, + ocrProvider: "OPENAI", + ocrModel: "gpt-4o", + ocrKey: "sk-ocr-secret", + }), + ); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + aiOcrEnabled: true, + aiOcrProvider: "OPENAI", + aiOcrModel: "gpt-4o", + aiOcrKeyEncrypted: "enc:sk-ocr-secret", + }), + }), + ); + }); + + it("rejects an invalid OCR provider", async () => { + await expect( + patch(patchRequest({ ocrProvider: "CHATGPT_OAUTH" })), + ).rejects.toThrow(/Invalid OCR provider/); + }); }); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 885a66a32..2fa312548 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -11,6 +11,9 @@ import { annotate } from "@/lib/logging/context"; export const dynamic = "force-dynamic"; const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]); +// v1.22 (#90) — the dedicated document-scan provider has no OAuth path, so the +// allowlist is the three keyed/self-hosted providers only (no CHATGPT_OAUTH). +const OCR_PROVIDERS = new Set(["OPENAI", "ANTHROPIC", "LOCAL"]); export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -25,6 +28,13 @@ export const GET = apiHandler(async () => { aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + // v1.22 (#89 + #90) + aiResponseTimeoutSeconds: true, + aiOcrEnabled: true, + aiOcrProvider: true, + aiOcrModel: true, + aiOcrBaseUrl: true, + aiOcrKeyEncrypted: true, }, }); @@ -50,6 +60,18 @@ export const GET = apiHandler(async () => { openaiKeyPreview: u?.aiOpenaiKeyEncrypted ? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}` : null, + // v1.22 (#89) — per-user response timeout, in seconds (null = default). + responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null, + // v1.22 (#90) — dedicated document-scan (Lab-OCR) provider config. Key + // material is presence + 4-char preview only, like every other provider. + ocrEnabled: Boolean(u?.aiOcrEnabled), + ocrProvider: u?.aiOcrProvider ?? null, + ocrModel: u?.aiOcrModel ?? null, + ocrBaseUrl: u?.aiOcrBaseUrl ?? null, + hasOcrKey: Boolean(u?.aiOcrKeyEncrypted), + ocrKeyPreview: u?.aiOcrKeyEncrypted + ? `...${decrypt(u.aiOcrKeyEncrypted).slice(-4)}` + : null, }); }); @@ -132,6 +154,87 @@ export const PATCH = apiHandler(async (request: NextRequest) => { } } + // ── v1.22 (#89) — response timeout (seconds) ────────────────── + if (body.responseTimeoutSeconds !== undefined) { + if (body.responseTimeoutSeconds === null) { + updates.aiResponseTimeoutSeconds = null; + } else if ( + typeof body.responseTimeoutSeconds === "number" && + Number.isInteger(body.responseTimeoutSeconds) + ) { + // Sane bounds: 10 s floor (a stricter value just trips the timeout) and + // 600 s ceiling (a generous cap for a slow self-hosted backend). + if ( + body.responseTimeoutSeconds < 10 || + body.responseTimeoutSeconds > 600 + ) { + throw new HttpError( + 422, + "Response timeout must be between 10 and 600 seconds", + ); + } + updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds; + } else { + throw new HttpError(422, "Invalid response timeout"); + } + } + + // ── v1.22 (#90) — dedicated document-scan (Lab-OCR) provider ── + if (body.ocrEnabled !== undefined) { + if (typeof body.ocrEnabled === "boolean") { + updates.aiOcrEnabled = body.ocrEnabled; + } else { + throw new HttpError(422, "Invalid ocrEnabled"); + } + } + + if (body.ocrProvider !== undefined) { + if (body.ocrProvider === null || body.ocrProvider === "") { + updates.aiOcrProvider = null; + } else if ( + typeof body.ocrProvider === "string" && + OCR_PROVIDERS.has(body.ocrProvider) + ) { + updates.aiOcrProvider = body.ocrProvider; + } else { + throw new HttpError(422, "Invalid OCR provider"); + } + } + + if (body.ocrModel !== undefined) { + if (body.ocrModel === null || body.ocrModel === "") { + updates.aiOcrModel = null; + } else if (typeof body.ocrModel === "string") { + updates.aiOcrModel = body.ocrModel.trim() || null; + } + } + + if (body.ocrBaseUrl !== undefined) { + if (body.ocrBaseUrl === null || body.ocrBaseUrl === "") { + updates.aiOcrBaseUrl = null; + } else if (typeof body.ocrBaseUrl === "string") { + const trimmed = body.ocrBaseUrl.trim(); + // Same SSRF guard as the main LOCAL base URL: reject private/internal + // hosts unless the operator opted in via ALLOW_LOCAL_AI_PRIVATE_HOSTS. + const allowPrivate = isLocalAiHostAllowed(trimmed); + if (!allowPrivate && !isPublicUrl(trimmed)) { + return apiError( + "OCR base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS.", + 422, + ); + } + updates.aiOcrBaseUrl = trimmed; + } + } + + if (body.ocrKey !== undefined) { + if (body.ocrKey === null || body.ocrKey === "") { + updates.aiOcrKeyEncrypted = null; + } else if (typeof body.ocrKey === "string") { + updates.aiOcrKeyEncrypted = encrypt(body.ocrKey.trim()); + } + } + if (Object.keys(updates).length === 0) { throw new HttpError(422, "No valid fields"); } diff --git a/src/app/vorsorge/page.tsx b/src/app/checkups/page.tsx similarity index 93% rename from src/app/vorsorge/page.tsx rename to src/app/checkups/page.tsx index 126f17539..162843a3c 100644 --- a/src/app/vorsorge/page.tsx +++ b/src/app/checkups/page.tsx @@ -12,7 +12,7 @@ import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-sec import { queryKeys } from "@/lib/query-keys"; /** - * v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated + * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated * feature surface for "wann muss ich was wo machen". Auth-gated; the * section component owns the list + create flow. * @@ -20,7 +20,7 @@ import { queryKeys } from "@/lib/query-keys"; * measurements): a pull invalidates the measurement-reminders read so the * list + server-computed next-due repaint. */ -export default function VorsorgePage() { +export default function CheckupsPage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); const queryClient = useQueryClient(); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 0855a364b..3d68a8fbf 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -211,7 +211,7 @@ function CoachConversationsBody() { @@ -183,11 +183,11 @@ export function CoachDrawer({ diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 088b83235..b9cbd0be3 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,15 @@ "use client"; -import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import Link from "next/link"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -9,22 +18,34 @@ import { Bot, Check, ChevronRight, + Clock, Loader2, Sparkles, ThumbsDown, ThumbsUp, User, + Volume2, + VolumeX, } from "lucide-react"; import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; +import { + parseChartTokens, + stripChartTokens, + tokenToMetric, + type ChartToken, +} from "@/lib/insights/chart-tokens"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { ProseBlocks } from "@/components/insights/prose-blocks"; import { SourceChips } from "./source-chips"; import { ReminderSuggestionCard } from "./reminder-suggestion-card"; +import { SuggestedActionCard } from "./suggested-action-card"; import { StreamedProse } from "./streamed-prose"; import { MessageTokenFooter } from "./message-token-footer"; import type { @@ -32,7 +53,10 @@ import type { CoachOptimisticUserMessage, CoachStreamingMessage, } from "./use-coach"; -import type { CoachMessageDTO } from "@/lib/ai/coach/types"; +import type { + CoachMessageDTO, + CoachProvenanceMetric, +} from "@/lib/ai/coach/types"; /** * v1.4.20 phase B2b — message-thread renderer. @@ -209,6 +233,285 @@ export function errorCodeToI18nKey(code: string): string { } } +/** + * v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half. + * + * The chart-token mechanism (`chart-tokens.ts`) was prepared for the + * Insights prose but its render path was never wired for the Coach — + * `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens` + * had no caller, so a `metric:` token the model emitted rendered no + * chart. This map activates the render half for the Coach. + * + * Each entry pairs an allowlisted chart token with the Coach provenance + * TOPIC that the snapshot stamps when it actually drew on that metric. A + * chart only renders when its topic is present in `metricSource.metrics`, so + * the series is grounded twice over: the closed allowlist drops a + * hallucinated token, and the provenance intersect drops a metric the turn + * never saw (and which therefore has no data). The chart itself self-fetches + * the user's real series from `/api/measurements` — the model never emits a + * data point. + * + * Only MeasurementType-backed tokens that render through the generic, + * self-fetching `` are listed. The synthetic `metric:MOOD` + * token (served by a separate ``) and the allowlist's reserved + * score classes are intentionally omitted from Phase 1. + * + * The prompt clause that tells the model it MAY emit one such token is a + * separate concern (the narrative/prompt workstream); this is render-only, + * provider-agnostic (it reads the plain inline token, so it works for every + * provider including the codex inline-text path), and a graceful no-op when + * no grounded token is present. + */ +const CHART_TOKEN_PROVENANCE: Partial< + Record +> = { + "metric:WEIGHT": "weight", + "metric:BLOOD_PRESSURE_SYS": "bp", + "metric:BLOOD_PRESSURE_DIA": "bp", + "metric:PULSE": "pulse", + "metric:BODY_FAT": "body_fat", + "metric:SLEEP_DURATION": "sleep", + "metric:ACTIVITY_STEPS": "steps", + "metric:BLOOD_GLUCOSE": "glucose", + "metric:TOTAL_BODY_WATER": "total_body_water", + "metric:BONE_MASS": "bone_mass", + "metric:OXYGEN_SATURATION": "spo2", + "metric:HEART_RATE_VARIABILITY": "hrv", + "metric:RESTING_HEART_RATE": "resting_hr", + "metric:ACTIVE_ENERGY_BURNED": "active_energy", + "metric:FLIGHTS_CLIMBED": "flights", + "metric:WALKING_RUNNING_DISTANCE": "distance", + "metric:VO2_MAX": "vo2_max", + "metric:BODY_TEMPERATURE": "body_temp", + "metric:FAT_FREE_MASS": "fat_free_mass", + "metric:FAT_MASS": "fat_mass", + "metric:MUSCLE_MASS": "muscle_mass", + "metric:LEAN_BODY_MASS": "lean_body_mass", + "metric:BODY_MASS_INDEX": "bmi", + "metric:VISCERAL_FAT": "visceral_fat", + "metric:SKIN_TEMPERATURE": "skin_temp", + "metric:RESPIRATORY_RATE": "respiratory_rate", + "metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity", + "metric:VASCULAR_AGE": "vascular_age", + "metric:WALKING_HEART_RATE_AVERAGE": "walking_hr", + "metric:WALKING_ASYMMETRY": "walking_asymmetry", + "metric:WALKING_DOUBLE_SUPPORT": "walking_double_support", + "metric:WALKING_STEP_LENGTH": "walking_step_length", + "metric:WALKING_SPEED": "walking_speed", + "metric:AUDIO_EXPOSURE_ENV": "audio_env", + "metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone", + "metric:AUDIO_EXPOSURE_EVENT": "audio_event", + "metric:TIME_IN_DAYLIGHT": "daylight", +}; + +/** Max charts rendered under a single Coach turn (keeps the reply scannable). */ +const MAX_COACH_CHARTS = 2; + +/** + * Pure selection of the chart tokens to render under an assistant turn: + * allowlist-parsed, intersected with the turn's grounded provenance topics, + * de-duplicated by metric, and capped. Exported for unit tests so the + * grounding contract is pinned without standing up the chart component. + */ +export function selectCoachChartTokens( + content: string, + metrics: readonly CoachProvenanceMetric[] | undefined, +): ChartToken[] { + const grounded = new Set(metrics ?? []); + const out: ChartToken[] = []; + const seen = new Set(); + for (const token of parseChartTokens(content)) { + const topic = CHART_TOKEN_PROVENANCE[token]; + if (!topic || !grounded.has(topic)) continue; + const metric = tokenToMetric(token); + if (seen.has(metric)) continue; + seen.add(metric); + out.push(token); + if (out.length >= MAX_COACH_CHARTS) break; + } + return out; +} + +/** + * Chart tokens whose canonical measurement-type label has no dedicated + * `measurements.type*` key — route them to the closest existing key so the + * chart header reads cleanly rather than echoing the raw enum. + */ +const CHART_TITLE_KEY_OVERRIDE: Record = { + BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure", + BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure", +}; + +/** Localised chart header for a MeasurementType, mirroring the snapshot's + * `measurements.type` convention with a readable fallback. */ +function coachChartTitle( + metric: string, + t: (key: string, vars?: Record) => string, +): string { + const override = CHART_TITLE_KEY_OVERRIDE[metric]; + if (override) { + const resolved = t(override); + if (resolved !== override) return resolved; + } + const camel = metric + .toLowerCase() + .split("_") + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); + const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + const resolved = t(key); + return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved; +} + +/** + * v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe. + * `useSyncExternalStore` returns false on the server + first client paint + * (so the read-aloud button is absent in markup) and resolves to the real + * capability after hydration, mirroring the mic button's detection pattern + * — no hydration mismatch warning, no `connect-src` (it runs in-browser). + */ +function useSpeechSynthesisSupported(): boolean { + return useSyncExternalStore( + () => () => {}, + () => + typeof window !== "undefined" && + "speechSynthesis" in window && + "SpeechSynthesisUtterance" in window, + () => false, + ); +} + +/** + * v1.22 (W5) — read the assistant prose aloud via the browser's + * `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A + * single in-flight utterance: a new `speak` cancels any prior one; `toggle` + * stops while speaking. The utterance language follows the active UI locale + * so the browser picks the closest installed voice (best-effort by OS). + */ +function useReadAloud(): { + supported: boolean; + speaking: boolean; + toggle: (text: string, lang: string) => void; +} { + const supported = useSpeechSynthesisSupported(); + const [speaking, setSpeaking] = useState(false); + + // Stop any in-flight utterance if the bubble unmounts (thread reset, nav). + useEffect(() => { + return () => { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + }; + }, []); + + const toggle = useCallback( + (text: string, lang: string) => { + if (!supported) return; + const synth = window.speechSynthesis; + if (speaking) { + synth.cancel(); + setSpeaking(false); + return; + } + const trimmed = text.trim(); + if (!trimmed) return; + synth.cancel(); + const utterance = new SpeechSynthesisUtterance(trimmed); + utterance.lang = lang; + utterance.onend = () => setSpeaking(false); + utterance.onerror = () => setSpeaking(false); + setSpeaking(true); + synth.speak(utterance); + }, + [supported, speaking], + ); + + return { supported, speaking, toggle }; +} + +/** + * v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when + * the browser has no Speech Synthesis support. Speaks `stripChartTokens` + * (the same text the prose shows), so stray tokens are never voiced. + */ +function ReadAloudButton({ content }: { content: string }) { + const { t, locale } = useTranslations(); + const { supported, speaking, toggle } = useReadAloud(); + if (!supported) return null; + const label = speaking + ? t("insights.coach.readAloudStop") + : t("insights.coach.readAloud"); + return ( + + ); +} + +/** + * v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small, + * calm clock affordance; the locale-aware date + time surfaces in a tooltip + * on hover or keyboard focus (desktop) and on tap (mobile, toggled state). + * Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe. + */ +function BubbleTimestamp({ + iso, + align = "start", +}: { + iso: string; + align?: "start" | "end"; +}) { + const { t } = useTranslations(); + const formatters = useFormatters(); + const [open, setOpen] = useState(false); + const label = formatters.dateTime(iso); + return ( + + + + {label} + + + ); +} + export function MessageThread({ conversation, streaming, @@ -425,6 +728,7 @@ export function MessageThread({ messageId={m.id} tokensUsed={m.tokensUsed} model={m.model} + createdAt={m.createdAt} /> ); @@ -466,6 +770,7 @@ export function MessageThread({ content={streaming.content} metricSource={streaming.metricSource} suggestion={streaming.suggestion} + suggestedAction={streaming.suggestedAction} providerType={streaming.inProgress ? "streaming" : null} inProgress={streaming.inProgress} errorCode={streaming.errorCode} @@ -494,6 +799,14 @@ interface ChatBubbleProps { * instead; the bubble falls back to that so the card survives reload. */ suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null; + /** + * v1.22 (W7/W6) — live generalised confirm-card action from the streaming + * hook. Persisted messages carry it on `metricSource.suggestedAction`; the + * bubble falls back to that so the card survives reload. + */ + suggestedAction?: + | import("@/lib/ai/coach/suggest-action").CoachSuggestedAction + | null; providerType?: string | null; inProgress?: boolean; errorCode?: string | null; @@ -521,6 +834,12 @@ interface ChatBubbleProps { */ tokensUsed?: number | null; model?: string | null; + /** + * v1.22 (W5) — ISO creation timestamp for the persisted bubble's + * hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles + * (no persisted time yet), so those render no timestamp. + */ + createdAt?: string; } function ChatBubble({ @@ -528,6 +847,7 @@ function ChatBubble({ content, metricSource, suggestion, + suggestedAction, providerType, inProgress, errorCode, @@ -536,6 +856,7 @@ function ChatBubble({ usage, tokensUsed, model, + createdAt, }: ChatBubbleProps) { const { t } = useTranslations(); const { user } = useAuth(); @@ -580,8 +901,12 @@ function ChatBubble({ "text-sm leading-relaxed", )} > - {content} + {/* v1.22 (W5) — render real paragraph blocks so a multi-line + message reads as paragraphs, not one run-on block. User text + is verbatim: no chart-token strip, no Learn linkify. */} + + {createdAt && } {/* v1.16.8 — explicit remember control. Stating an allergy in chat used to leave no durable trace unless a narrow pattern pass happened to match the phrasing; this stores @@ -607,7 +932,7 @@ function ChatBubble({ @@ -645,6 +970,15 @@ function ChatBubble({ (metricSource.windows?.length ?? 0) > 0); const hasProvenance = hasChips || keyValues.length > 0; + // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- + // grounded `metric:` chart under a SETTLED assistant turn. Skipped + // while streaming / in-flight / errored / on a refusal; a no-op when no + // grounded token is present (provider-agnostic — reads the inline token). + const chartTokens = + !streaming && !inProgress && !errorCode && providerType !== "refusal" + ? selectCoachChartTokens(content, metricSource?.metrics) + : []; + return (
{safeError}

)} + {/* v1.22 (W5) — accompanying chart(s). The token was already + stripped from the prose by ; here it mounts the + real, self-fetching Recharts chart for the user's own series. + The model never emits data — only a metric identifier from a + closed allowlist, intersected with the turn's grounded metrics. */} + {chartTokens.length > 0 && ( +
+ {chartTokens.map((token) => { + const metric = tokenToMetric(token); + return ( +
+ +
+ ); + })} +
+ )} {/* v1.18.6 — a "no provider configured anywhere" turn is a setup gap, not a transient failure: surface a direct link to Settings → AI so the Coach guides the user into BYOK / local @@ -817,6 +1175,16 @@ function ChatBubble({ const sug = suggestion ?? metricSource?.suggestion ?? null; return sug ? : null; })()} + {/* v1.22 (W7/W6) — generalised confirm-card action. Live from the + streaming hook, or restored from persisted message provenance on + reload. Mirrors the reminder-suggestion block above. */} + {!inProgress && + !errorCode && + (() => { + const action = + suggestedAction ?? metricSource?.suggestedAction ?? null; + return action ? : null; + })()} {/* v1.18.9 — quiet per-message token footer. The just-finished streaming turn reads the `done.usage` envelope; a persisted / reloaded turn reads the message's own `tokensUsed` + `model`. @@ -829,14 +1197,19 @@ function ChatBubble({ )} {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted assistant messages get the row (skipped for refusals, - errors, in-flight stream bubbles). The aggregator buckets - the rating by (promptVersion, tone, verbosity). */} - {messageId && - !inProgress && - !errorCode && - providerType !== "refusal" && ( - - )} + errors, in-flight stream bubbles). v1.22 (W5) — the + read-aloud toggle joins the same action row on a settled turn; + the bubble timestamp trails it. */} + {!inProgress && !errorCode && providerType !== "refusal" && content && ( +
+ {!streaming && } + {messageId && } + {createdAt && } +
+ )}
); @@ -908,7 +1281,7 @@ function RememberUserMessage({ content }: { content: string }) { disabled={remember.isPending} className={cn( "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50", - "inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs", + "inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs sm:min-h-9", "outline-none focus-visible:ring-2 disabled:opacity-50", // Calmer thread on pointer devices: the control stays invisible // until its bubble is hovered or holds focus. Touch viewports diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx index 370d70a43..7b41f4df0 100644 --- a/src/components/insights/coach-panel/streamed-prose.tsx +++ b/src/components/insights/coach-panel/streamed-prose.tsx @@ -4,22 +4,37 @@ import { useMemo } from "react"; import { cn } from "@/lib/utils"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + ProseBlocks, + ParagraphText, + splitParagraphs, + PROSE_PARAGRAPH_CLASS, +} from "@/components/insights/prose-blocks"; /** * v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn. * * The provider clients return the whole reply in one shot; the chat route * re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`), - * so `content` grows as frames land. This component renders each - * whitespace segment as its own `` that fades + drifts up on mount, - * giving the reply a "thinking out loud" cadence rather than a hard dump - * of word groups. Once the turn settles (`streaming === false`) the spans - * collapse to a single plain-text node — zero residual animation DOM, and - * the feedback / evidence layout underneath stays stable. + * so `content` grows as frames land. While the turn is streaming each + * whitespace segment of the GROWING tail renders as its own `` that + * fades + drifts up on mount, giving the reply a "thinking out loud" + * cadence rather than a hard dump of word groups. * - * Why no "only animate the new words" bookkeeping: each segment is keyed - * by its index, so when a fresh SSE frame appends words, React keeps the - * already-mounted spans (their CSS entrance already ran and does not + * v1.22 (W5) — paragraph-aware + SSE-safe. The reply now renders as real + * `

` blocks (shared `ProseBlocks`), so a model that emits blank-line + * paragraph breaks reads as paragraphs, not one run-on block — consistent + * with the daily briefing + insight cards. Under streaming, every COMPLETE + * paragraph settles immediately (plain text, with the safe Learn linkifier) + * and only the LAST, still-growing paragraph animates word-by-word. When a + * fresh `\n\n` arrives the tail becomes a completed paragraph and stops + * animating; the already-settled paragraphs keep their index keys and never + * replay. Once the whole turn settles (`streaming === false`) it collapses + * to the same `ProseBlocks` render the persisted history uses. + * + * Why no "only animate the new words" bookkeeping: each tail segment is + * keyed by its index, so when a fresh SSE frame appends words React keeps + * the already-mounted spans (their CSS entrance already ran and does not * replay on reconcile) and only MOUNTS the new trailing spans — which is * exactly when the `animate-in` entrance fires. The browser does the * "new words only" gating for us; no render-time ref is needed. @@ -30,8 +45,8 @@ import { stripChartTokens } from "@/lib/insights/chart-tokens"; * attributes are already permitted under the strict CSP * (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected * `