diff --git a/.env.production.example b/.env.production.example index c5f56207c..5ea504a55 100644 --- a/.env.production.example +++ b/.env.production.example @@ -327,6 +327,13 @@ API_TOKEN_HMAC_KEY="" # docs/integrations/ai-providers.md. # DOCUMENT_AI_LIMIT_PER_HOUR="" +# Minimum hours between two regenerations of a status card after a sync +# (optional). A watch or an integration delivers readings all day, and each +# delivery would otherwise regenerate the cards it touches every hour. Hand +# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value +# under Admin > Coach wins over this one. See docs/integrations/ai-providers.md. +# AI_SYNC_REGEN_GAP_HOURS="" + # Hourly upload ceiling for each document-upload token (optional). Applies # only to uploads made with a token minted under Settings > API & Tokens > # Document tokens: a Paperless workflow or the import script. Every upload diff --git a/docker-compose.yml b/docker-compose.yml index eaacd11d4..031a8f3ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -332,6 +332,11 @@ x-healthlog-env: &healthlog_env # (the `environment:` block is a whitelist). See # docs/integrations/ai-providers.md. DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}" + # Minimum hours between two status-card regenerations after a sync + # (optional; default 6, whole hours 1-48; hand entries keep one hour). The + # value under Admin > Coach wins. Listed here so an operator value in .env + # reaches the container. See docs/integrations/ai-providers.md. + AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}" # Hourly upload ceiling for each narrow document-upload token (a Paperless # workflow, the import script; optional; default 120, clamped 1-1000). # Uploads from the web app and the phone keep their own bucket. Listed here diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 27982c45f..5559a9937 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24012,7 +24012,6 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "501": *a44 /api/medications/{id}/ingredients: put: tags: @@ -35543,41 +35542,42 @@ components: (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai — - ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.invalid, - ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, - aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — - analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, - anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, - anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required, - auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, - auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, - auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required, - auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, - backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; - biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, - coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, - coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, - coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, - coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; - consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; - demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, + ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider + — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — + anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, + anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid, + anamnesis.lifeSections.invalid; auth — auth.admin.required, auth.already_authenticated, + auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, + auth.session.insecure_transport, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, + backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — + body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — + cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -35665,19 +35665,27 @@ components: capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, - `documentAi`, `labsOcr`, `medicationExtract`)." + `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`)." type: string reason: description: "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own - typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → - `consent.ai.required`, `check_failed` → `ai.unavailable` (503)." + typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` + with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` + (503)." type: string module: description: "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module." type: string + requiredLevel: + description: "Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered." + type: string + enum: + - frugal + - standard + - full additionalProperties: {} required: - data @@ -47840,6 +47848,7 @@ components: - not_permitted_for_record - module_disabled - user_disabled + - usage_level - no_provider - consent_required description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is @@ -47849,9 +47858,10 @@ components: admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. - `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider - chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does - not know treats the capability as unavailable." + `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today + only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. + `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a + client that meets a value it does not know treats the capability as unavailable." AiCapabilityState: type: object properties: @@ -47865,10 +47875,10 @@ components: onDeviceAllowed: type: boolean description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, - `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the - device too. True otherwise, since a missing server provider or a missing consent for server egress do not - concern a model that runs on the device. Resolved here so the client never decides which reasons apply off - the server." + `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's + decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for + server egress do not concern a model that runs on the device. Resolved here so the client never decides + which reasons apply off the server." required: - available - reason @@ -55329,6 +55339,7 @@ components: - not_permitted_for_record - module_disabled - user_disabled + - usage_level - no_provider - consent_required required: @@ -80072,6 +80083,9 @@ components: medicationExtract: description: The "medicationExtract" capability. Covered by the operator's "documentAi" switch. $ref: "#/components/schemas/AiCapabilityState" + supplementLabel: + description: The "supplementLabel" capability. Covered by the operator's "documentAi" switch. + $ref: "#/components/schemas/AiCapabilityState" required: - coach - briefing @@ -80083,6 +80097,7 @@ components: - documentAi - labsOcr - medicationExtract + - supplementLabel additionalProperties: false description: Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted @@ -80091,7 +80106,8 @@ components: paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. - `medicationExtract` — turning a typed medication description into a schedule. + `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — + reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level. AiProviderState: type: object properties: diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index a097541e5..84e9d20f0 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -431,6 +431,31 @@ many documents against a generous provider budget; lower it on a tight one. The 429 names the actual wait (the window is an hour) and carries the reset instant in `X-RateLimit-Reset`. +`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is +the minimum time between two regenerations of a status card after a sync +at the `standard` AI usage level. A watch or an integration delivers +readings all day, and each delivery moves today's figures, so without a +gap every touched card regenerated about once an hour. A reading entered +by hand keeps a one-hour gap, so it is still narrated the same day. The +value set under Admin > Coach (AI usage levels) wins over the environment. + +Each account runs at one of three AI usage levels, chosen under +Settings > AI within the bounds the operator sets under Admin > Coach (the +highest level allowed and the default for accounts without a choice): + +| | `frugal` | `standard` | `full` | +| ------------------------------- | ------------------ | -------------------- | -------------------- | +| Status cards after a sync | at most once a day | the gap above | hourly | +| Status cards after a hand entry | every 6 hours | hourly | hourly | +| Snapshot size (compact JSON) | 10 000 characters | 14 000 | 14 000 | +| Background reasoning | off | job default | job default | +| Daily briefing | short form | full | full | +| Automatic document reading | on request only | the account's switch | the account's switch | +| Supplement label photo | not offered | offered | offered | + +The Coach is the same at every level. Settings shows an estimate of +tokens per day for each level, scaled from the account's last seven days. + `LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the same for lab-report scans. The photo or PDF scan and the browser-OCR text scan share one bucket per user, and a scan that fails before the diff --git a/messages/de.json b/messages/de.json index f2f5c30ba..22defd4fa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6258,7 +6258,8 @@ "subLabel": "Dein KI-Anbieter liest und indiziert neue Dokumente, ohne dass du jedes einzeln bestätigst.", "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden, ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen: Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.", "confirm": "Verstanden, aktivieren", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "restingFrugal": "Pausiert auf der sparsamen KI-Stufe: Dokumente werden nur auf Knopfdruck gelesen." }, "centralCodex": { "title": "Gemeinsamen KI-Zugang des Servers nutzen", @@ -6316,7 +6317,32 @@ "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen." }, "providerCardDescription": "Der Anbieter, den HealthLog zuerst fragt, mit Schlüssel und Modell.", - "runtimeTitle": "Prüfen und aktualisieren" + "runtimeTitle": "Prüfen und aktualisieren", + "usage": { + "title": "KI-Einsatz", + "description": "Wie viel KI-Arbeit im Hintergrund für dich läuft, während der Coach auf jeder Stufe gleich bleibt.", + "levels": { + "frugal": { + "label": "Sparsam", + "hint": "Karten erneuern sich nach einem Sync höchstens einmal am Tag, ein kurzes Tagesbriefing, Dokumente werden nur auf Knopfdruck gelesen." + }, + "standard": { + "label": "Standard", + "hint": "Karten erneuern sich nach einem Sync alle paar Stunden, das volle Tagesbriefing." + }, + "full": { + "label": "Voll", + "hint": "Karten erneuern sich nach neuen Daten bis zu stündlich, wie vor dieser Einstellung." + } + }, + "limited": "Vom Betreiber begrenzt.", + "estimate": "etwa {tokens} Tokens am Tag, geschätzt", + "measured": "Gemessen: {tokens} Tokens am Tag in den letzten {days} Tagen, Coach eingeschlossen. Die Werte je Stufe sind Schätzungen.", + "noEstimate": "Eine Schätzung je Stufe erscheint nach drei Tagen KI-Nutzung.", + "capped": "Du hast {choice} gewählt, das der Server gerade nicht erlaubt, deshalb gilt {level}. Deine Wahl bleibt gespeichert.", + "followsDefault": "Du folgst dem Standard des Servers ({level}).", + "useDefault": "Standard des Servers verwenden ({level})" + } }, "dashboard": { "units": { @@ -7353,7 +7379,8 @@ "spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).", "statusPaused": "Pausiert", "pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.", - "modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet." + "modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet.", + "usageLevels": "Konten je KI-Stufe: {frugal} sparsam, {standard} Standard, {full} voll." }, "aiQuality": { "title": "Auswertungs-Qualität", @@ -8140,6 +8167,15 @@ "copied": "Kopiert", "download": "Herunterladen", "loadError": "Der Support-Bericht konnte nicht erstellt werden." + }, + "aiUsage": { + "title": "KI-Einsatz-Stufen", + "description": "Die höchste Stufe, die Konten wählen dürfen, und die Stufe, mit der sie starten.", + "maxLabel": "Höchste Stufe", + "defaultLabel": "Standardstufe", + "defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.", + "gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)", + "gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h." } }, "achievements": { diff --git a/messages/en.json b/messages/en.json index 0d53008ba..874ee9856 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6258,7 +6258,8 @@ "subLabel": "Your AI provider reads and indexes each new document without a tap per document.", "honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search, with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply: subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.", "confirm": "I understand, turn on", - "cancel": "Cancel" + "cancel": "Cancel", + "restingFrugal": "Paused at the frugal AI usage level: documents are read only when you ask." }, "centralCodex": { "title": "Use the server's shared AI access", @@ -6316,7 +6317,32 @@ "coachNote": "In the Coach, the thinking depth from the Coach settings applies." }, "providerCardDescription": "The provider HealthLog asks first, and its key and model.", - "runtimeTitle": "Check and refresh" + "runtimeTitle": "Check and refresh", + "usage": { + "title": "AI usage", + "description": "How much AI work runs in the background for you, while the Coach stays the same at every level.", + "levels": { + "frugal": { + "label": "Frugal", + "hint": "Cards refresh at most once a day after a sync, a short daily briefing, documents are read only when you ask." + }, + "standard": { + "label": "Standard", + "hint": "Cards refresh every few hours after a sync, the full daily briefing." + }, + "full": { + "label": "Full", + "hint": "Cards refresh up to every hour after new data, as before this setting existed." + } + }, + "limited": "Limited by the server operator.", + "estimate": "about {tokens} tokens a day, estimated", + "measured": "Measured: {tokens} tokens a day over the last {days} days, the Coach included. The figures per level are estimates.", + "noEstimate": "An estimate per level appears after three days of AI use.", + "capped": "You chose {choice}, which the server does not allow at the moment, so {level} applies. Your choice is kept.", + "followsDefault": "You follow the server default ({level}).", + "useDefault": "Use the server default ({level})" + } }, "dashboard": { "units": { @@ -7353,7 +7379,8 @@ "spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).", "statusPaused": "Paused", "pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.", - "modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings." + "modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings.", + "usageLevels": "Accounts per AI usage level: {frugal} frugal, {standard} standard, {full} full." }, "aiQuality": { "title": "Insights Quality", @@ -8140,6 +8167,15 @@ "copied": "Copied", "download": "Download", "loadError": "The support report could not be created." + }, + "aiUsage": { + "title": "AI usage levels", + "description": "The highest level accounts may choose and the level they start at.", + "maxLabel": "Highest level", + "defaultLabel": "Default level", + "defaultAboveCeiling": "The default level cannot be above the highest level.", + "gapLabel": "Hours between card refreshes after a sync (Standard)", + "gapHint": "Whole hours from 1 to 48. Leave empty to use AI_SYNC_REGEN_GAP_HOURS, else 6. In effect now: {hours} h." } }, "achievements": { diff --git a/messages/es.json b/messages/es.json index 0b50e9a0f..e27e98043 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6258,7 +6258,8 @@ "subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.", "honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.", "confirm": "Entendido: activar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "restingFrugal": "En pausa en el nivel de ahorro de IA: los documentos solo se leen cuando lo pides." }, "centralCodex": { "title": "Usar el acceso de IA compartido del servidor", @@ -6316,7 +6317,32 @@ "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach." }, "providerCardDescription": "El proveedor al que HealthLog pregunta primero, con su clave y su modelo.", - "runtimeTitle": "Comprobar y actualizar" + "runtimeTitle": "Comprobar y actualizar", + "usage": { + "title": "Uso de la IA", + "description": "Cuánto trabajo de IA se hace en segundo plano para ti, mientras el Coach sigue igual en todos los niveles.", + "levels": { + "frugal": { + "label": "Ahorro", + "hint": "Las tarjetas se actualizan como mucho una vez al día tras una sincronización, un resumen diario breve, los documentos solo se leen cuando lo pides." + }, + "standard": { + "label": "Estándar", + "hint": "Las tarjetas se actualizan cada pocas horas tras una sincronización, el resumen diario completo." + }, + "full": { + "label": "Completo", + "hint": "Las tarjetas se actualizan hasta cada hora con datos nuevos, como antes de existir este ajuste." + } + }, + "limited": "Limitado por quien administra el servidor.", + "estimate": "unos {tokens} tokens al día, estimado", + "measured": "Medido: {tokens} tokens al día en los últimos {days} días, Coach incluido. Las cifras por nivel son estimaciones.", + "noEstimate": "La estimación por nivel aparece tras tres días de uso de la IA.", + "capped": "Elegiste {choice}, que el servidor no permite ahora mismo, así que se aplica {level}. Tu elección se conserva.", + "followsDefault": "Sigues el valor predeterminado del servidor ({level}).", + "useDefault": "Usar el valor predeterminado del servidor ({level})" + } }, "dashboard": { "units": { @@ -7353,7 +7379,8 @@ "spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).", "statusPaused": "En pausa", "pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.", - "modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca." + "modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca.", + "usageLevels": "Cuentas por nivel de uso de la IA: {frugal} ahorro, {standard} estándar, {full} completo." }, "aiQuality": { "title": "Calidad de los análisis", @@ -8140,6 +8167,15 @@ "copied": "Copiado", "download": "Descargar", "loadError": "No se pudo crear el informe de soporte." + }, + "aiUsage": { + "title": "Niveles de uso de la IA", + "description": "El nivel más alto que pueden elegir las cuentas y el nivel con el que empiezan.", + "maxLabel": "Nivel más alto", + "defaultLabel": "Nivel predeterminado", + "defaultAboveCeiling": "El nivel predeterminado no puede estar por encima del nivel más alto.", + "gapLabel": "Horas entre actualizaciones de tarjetas tras una sincronización (Estándar)", + "gapHint": "Horas enteras de 1 a 48. Déjalo vacío para usar AI_SYNC_REGEN_GAP_HOURS, si no 6. Vigente ahora: {hours} h." } }, "achievements": { diff --git a/messages/fr.json b/messages/fr.json index 72666ef78..2743484ab 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6258,7 +6258,8 @@ "subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.", "honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.", "confirm": "Compris, activer", - "cancel": "Annuler" + "cancel": "Annuler", + "restingFrugal": "En pause au niveau d'IA économe : les documents ne sont lus qu'à votre demande." }, "centralCodex": { "title": "Utiliser l'accès IA partagé du serveur", @@ -6316,7 +6317,32 @@ "coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique." }, "providerCardDescription": "Le fournisseur que HealthLog sollicite en premier, avec sa clé et son modèle.", - "runtimeTitle": "Vérifier et actualiser" + "runtimeTitle": "Vérifier et actualiser", + "usage": { + "title": "Usage de l'IA", + "description": "La quantité de travail d'IA effectuée en arrière-plan pour vous, le Coach restant le même à chaque niveau.", + "levels": { + "frugal": { + "label": "Économe", + "hint": "Les cartes se mettent à jour au plus une fois par jour après une synchronisation, un bilan quotidien court, les documents ne sont lus qu'à votre demande." + }, + "standard": { + "label": "Standard", + "hint": "Les cartes se mettent à jour toutes les quelques heures après une synchronisation, le bilan quotidien complet." + }, + "full": { + "label": "Complet", + "hint": "Les cartes se mettent à jour jusqu'à chaque heure avec de nouvelles données, comme avant ce réglage." + } + }, + "limited": "Limité par l'administrateur du serveur.", + "estimate": "environ {tokens} jetons par jour, estimation", + "measured": "Mesuré : {tokens} jetons par jour sur les {days} derniers jours, Coach compris. Les chiffres par niveau sont des estimations.", + "noEstimate": "Une estimation par niveau apparaît après trois jours d'utilisation de l'IA.", + "capped": "Vous avez choisi {choice}, que le serveur n'autorise pas pour le moment, donc {level} s'applique. Votre choix est conservé.", + "followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).", + "useDefault": "Utiliser le réglage par défaut du serveur ({level})" + } }, "dashboard": { "units": { @@ -7353,7 +7379,8 @@ "spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).", "statusPaused": "En pause", "pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.", - "modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose." + "modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose.", + "usageLevels": "Comptes par niveau d'usage de l'IA : {frugal} économe, {standard} standard, {full} complet." }, "aiQuality": { "title": "Qualité des analyses", @@ -8140,6 +8167,15 @@ "copied": "Copié", "download": "Télécharger", "loadError": "Le rapport d'assistance n'a pas pu être créé." + }, + "aiUsage": { + "title": "Niveaux d'usage de l'IA", + "description": "Le niveau le plus élevé que les comptes peuvent choisir et le niveau de départ.", + "maxLabel": "Niveau le plus élevé", + "defaultLabel": "Niveau par défaut", + "defaultAboveCeiling": "Le niveau par défaut ne peut pas dépasser le niveau le plus élevé.", + "gapLabel": "Heures entre deux mises à jour des cartes après une synchronisation (Standard)", + "gapHint": "Heures entières de 1 à 48. Laissez vide pour utiliser AI_SYNC_REGEN_GAP_HOURS, sinon 6. En vigueur : {hours} h." } }, "achievements": { diff --git a/messages/it.json b/messages/it.json index 2bb621f5c..80f4e0504 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6258,7 +6258,8 @@ "subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.", "honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.", "confirm": "Ho capito, attiva", - "cancel": "Annulla" + "cancel": "Annulla", + "restingFrugal": "In pausa al livello di IA a risparmio: i documenti vengono letti solo su richiesta." }, "centralCodex": { "title": "Usa l'accesso IA condiviso del server", @@ -6316,7 +6317,32 @@ "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach." }, "providerCardDescription": "Il fornitore che HealthLog interpella per primo, con chiave e modello.", - "runtimeTitle": "Verifica e aggiorna" + "runtimeTitle": "Verifica e aggiorna", + "usage": { + "title": "Uso dell'IA", + "description": "Quanto lavoro di IA viene svolto in background per te, mentre il Coach resta uguale a ogni livello.", + "levels": { + "frugal": { + "label": "Risparmio", + "hint": "Le schede si aggiornano al massimo una volta al giorno dopo una sincronizzazione, un riepilogo giornaliero breve, i documenti vengono letti solo su richiesta." + }, + "standard": { + "label": "Standard", + "hint": "Le schede si aggiornano ogni poche ore dopo una sincronizzazione, il riepilogo giornaliero completo." + }, + "full": { + "label": "Completo", + "hint": "Le schede si aggiornano fino a ogni ora con nuovi dati, come prima di questa impostazione." + } + }, + "limited": "Limitato dall'amministratore del server.", + "estimate": "circa {tokens} token al giorno, stima", + "measured": "Misurato: {tokens} token al giorno negli ultimi {days} giorni, Coach compreso. I valori per livello sono stime.", + "noEstimate": "La stima per livello compare dopo tre giorni di uso dell'IA.", + "capped": "Hai scelto {choice}, che il server al momento non consente, quindi vale {level}. La tua scelta resta salvata.", + "followsDefault": "Segui l'impostazione predefinita del server ({level}).", + "useDefault": "Usa l'impostazione predefinita del server ({level})" + } }, "dashboard": { "units": { @@ -7353,7 +7379,8 @@ "spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).", "statusPaused": "In pausa", "pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.", - "modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre." + "modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre.", + "usageLevels": "Account per livello di uso dell'IA: {frugal} risparmio, {standard} standard, {full} completo." }, "aiQuality": { "title": "Qualità delle analisi", @@ -8140,6 +8167,15 @@ "copied": "Copiato", "download": "Scarica", "loadError": "Non è stato possibile creare il rapporto di assistenza." + }, + "aiUsage": { + "title": "Livelli di uso dell'IA", + "description": "Il livello più alto che gli account possono scegliere e il livello di partenza.", + "maxLabel": "Livello più alto", + "defaultLabel": "Livello predefinito", + "defaultAboveCeiling": "Il livello predefinito non può superare il livello più alto.", + "gapLabel": "Ore tra due aggiornamenti delle schede dopo una sincronizzazione (Standard)", + "gapHint": "Ore intere da 1 a 48. Lascia vuoto per usare AI_SYNC_REGEN_GAP_HOURS, altrimenti 6. In vigore ora: {hours} h." } }, "achievements": { diff --git a/messages/ko.json b/messages/ko.json index 6129f6db7..29f8be638 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6258,7 +6258,8 @@ "subLabel": "문서마다 따로 누르지 않아도 설정한 AI 제공자가 문서를 읽고 색인해요.", "honesty": "켜 두면 올리는 문서마다 설정한 AI 제공자에게 전송되어 읽히고, 설명이 붙고, 검색용으로 색인돼요. 문서별 확인 절차는 없어요. 켜는 순간 과거 문서에도 적용돼요. 이미 올렸지만 아직 요약이 없는 문서도 켤 때마다 최대 200개까지 함께 전송돼요. 이 작업은 서버에서 이뤄지고 문서 내용은 이 기기를 떠나 그 제공자에게 전달돼요. 설정한 제공자가 구독 연결(API 키가 아니라 로그인한 AI 계정)이라면 그 제공자의 데이터 정책이 적용돼요. 구독 제공자는 내용을 모델 개선에 쓸 수 있고, 이를 보호하는 데이터 처리 계약도 없어요. 셀프 호스팅 로컬 모델이 읽은 문서는 서버 밖으로 나가지 않아요. 기본값은 꺼짐이고, 이 조건을 받아들일 때만 켜세요.", "confirm": "이해했어요, 켜기", - "cancel": "취소" + "cancel": "취소", + "restingFrugal": "AI 절약 단계에서는 일시 중지돼요. 문서는 요청할 때만 읽어요." }, "centralCodex": { "title": "서버의 공용 AI 연결 사용", @@ -6316,7 +6317,32 @@ "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다." }, "providerCardDescription": "HealthLog가 가장 먼저 쓰는 제공자와 그 키, 모델이에요.", - "runtimeTitle": "확인 및 새로고침" + "runtimeTitle": "확인 및 새로고침", + "usage": { + "title": "AI 사용량", + "description": "백그라운드에서 실행되는 AI 작업의 양이며, 코치는 모든 단계에서 똑같아요.", + "levels": { + "frugal": { + "label": "절약", + "hint": "동기화 후 카드는 하루에 최대 한 번 새로 고쳐지고, 일일 브리핑은 짧아지며, 문서는 요청할 때만 읽어요." + }, + "standard": { + "label": "표준", + "hint": "동기화 후 카드는 몇 시간마다 새로 고쳐지고, 일일 브리핑은 전체 형태예요." + }, + "full": { + "label": "전체", + "hint": "새 데이터가 들어오면 카드가 최대 한 시간마다 새로 고쳐져요. 이 설정이 생기기 전과 같아요." + } + }, + "limited": "서버 운영자가 제한했어요.", + "estimate": "하루 약 {tokens} 토큰, 추정치", + "measured": "측정값: 최근 {days}일 동안 하루 {tokens} 토큰이에요. 코치 사용량도 포함돼요. 단계별 수치는 추정치예요.", + "noEstimate": "AI를 3일 사용하면 단계별 추정치가 표시돼요.", + "capped": "{choice} 단계를 선택했지만 지금은 서버에서 허용하지 않아 {level} 단계가 적용돼요. 선택은 그대로 저장돼요.", + "followsDefault": "서버 기본값({level})을 따르고 있어요.", + "useDefault": "서버 기본값 사용({level})" + } }, "dashboard": { "units": { @@ -8072,7 +8098,8 @@ "spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).", "statusPaused": "일시 중지됨", "pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.", - "modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요." + "modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요.", + "usageLevels": "AI 사용량 단계별 계정 수: 절약 {frugal}, 표준 {standard}, 전체 {full}." }, "cardError": { "title": "페이지의 이 부분을 표시할 수 없습니다", @@ -8140,6 +8167,15 @@ "copied": "복사됨", "download": "다운로드", "loadError": "지원 보고서를 만들 수 없습니다." + }, + "aiUsage": { + "title": "AI 사용량 단계", + "description": "계정이 선택할 수 있는 최고 단계와 처음 시작하는 단계예요.", + "maxLabel": "최고 단계", + "defaultLabel": "기본 단계", + "defaultAboveCeiling": "기본 단계는 최고 단계보다 높을 수 없어요.", + "gapLabel": "동기화 후 카드 새로 고침 간격(시간, 표준 단계)", + "gapHint": "1에서 48 사이의 정수 시간이에요. 비워 두면 AI_SYNC_REGEN_GAP_HOURS를, 없으면 6을 사용해요. 현재 적용 값: {hours}시간." } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index 6d71a7276..8b9d69342 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6258,7 +6258,8 @@ "subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.", "honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania, bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane (do 200 przy każdym włączeniu). Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych: dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.", "confirm": "Rozumiem, włącz", - "cancel": "Anuluj" + "cancel": "Anuluj", + "restingFrugal": "Wstrzymane na oszczędnym poziomie AI: dokumenty są czytane tylko na żądanie." }, "centralCodex": { "title": "Użyj wspólnego dostępu AI serwera", @@ -6316,7 +6317,32 @@ "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha." }, "providerCardDescription": "Dostawca, którego HealthLog pyta najpierw, wraz z kluczem i modelem.", - "runtimeTitle": "Sprawdź i odśwież" + "runtimeTitle": "Sprawdź i odśwież", + "usage": { + "title": "Użycie AI", + "description": "Ile pracy AI wykonuje się dla Ciebie w tle, przy czym Coach działa tak samo na każdym poziomie.", + "levels": { + "frugal": { + "label": "Oszczędny", + "hint": "Karty odświeżają się po synchronizacji najwyżej raz dziennie, krótkie podsumowanie dnia, dokumenty są czytane tylko na żądanie." + }, + "standard": { + "label": "Standardowy", + "hint": "Karty odświeżają się po synchronizacji co kilka godzin, pełne podsumowanie dnia." + }, + "full": { + "label": "Pełny", + "hint": "Karty odświeżają się po nowych danych nawet co godzinę, jak przed wprowadzeniem tego ustawienia." + } + }, + "limited": "Ograniczone przez administratora serwera.", + "estimate": "około {tokens} tokenów dziennie, szacunkowo", + "measured": "Zmierzono: {tokens} tokenów dziennie w ostatnich {days} dniach, łącznie z Coachem. Wartości dla poziomów są szacunkami.", + "noEstimate": "Szacunek dla każdego poziomu pojawi się po trzech dniach korzystania z AI.", + "capped": "Wybrano poziom {choice}, na który serwer obecnie nie pozwala, więc obowiązuje {level}. Twój wybór zostaje zapisany.", + "followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).", + "useDefault": "Użyj ustawienia domyślnego serwera ({level})" + } }, "dashboard": { "units": { @@ -7353,7 +7379,8 @@ "spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).", "statusPaused": "Wstrzymany", "pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.", - "modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje." + "modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje.", + "usageLevels": "Konta według poziomu użycia AI: oszczędny {frugal}, standardowy {standard}, pełny {full}." }, "aiQuality": { "title": "Jakość analiz", @@ -8140,6 +8167,15 @@ "copied": "Skopiowano", "download": "Pobierz", "loadError": "Nie udało się utworzyć raportu dla pomocy." + }, + "aiUsage": { + "title": "Poziomy użycia AI", + "description": "Najwyższy poziom, jaki mogą wybrać konta, i poziom, od którego zaczynają.", + "maxLabel": "Najwyższy poziom", + "defaultLabel": "Poziom domyślny", + "defaultAboveCeiling": "Poziom domyślny nie może być wyższy niż najwyższy poziom.", + "gapLabel": "Godziny między odświeżeniami kart po synchronizacji (Standardowy)", + "gapHint": "Pełne godziny od 1 do 48. Zostaw puste, aby użyć AI_SYNC_REGEN_GAP_HOURS, w przeciwnym razie 6. Obecnie obowiązuje: {hours} h." } }, "achievements": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f423e3f64..9429abebe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -622,7 +622,7 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") - /// @pending-consumer(#v143-B1) The account's own AI usage level (#1217). + /// The account's own AI usage level (#1217). /// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`); /// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped /// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in @@ -5268,11 +5268,13 @@ model AppSettings { // `aiSyncRegenGapHours` the minimum gap between status-card regenerations // after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6). // Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`). - /// @pending-consumer(#v143-B1) Instance ceiling for the per-account AI usage level. + /// Instance ceiling for the per-account AI usage level (resolved in + /// `src/lib/ai/capabilities/usage.ts`). aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level") - /// @pending-consumer(#v143-B1) Level for accounts without their own choice. + /// Level for accounts without their own choice. aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level") - /// @pending-consumer(#v143-B1) Sync regeneration gap in hours (1..48); NULL = env or 6. + /// Sync regeneration gap in hours (1..48); NULL = env or 6. Read by the + /// status invalidator at the `standard` level. aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours") // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 08a6796ec..52b63fa38 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -142,6 +142,10 @@ "name": "DOCUMENT_AI_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000." }, + { + "name": "AI_SYNC_REGEN_GAP_HOURS", + "purpose": "Minimum hours between two status-card regenerations after a sync. Default 6, whole hours 1-48; hand entries keep one hour. The admin AI setting wins." + }, { "name": "DOCUMENT_UPLOAD_LIMIT_PER_HOUR", "purpose": "Hourly upload ceiling per document-upload token (Paperless workflow, import script). Default 120, clamped to 1-1000. Web and phone uploads keep their own bucket." diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 3b06191b0..7f411cc63 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types"; import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types"; import type { runStatusCompletion } from "@/lib/insights/status-provider"; @@ -283,3 +284,29 @@ describe("the status chokepoint's capability argument", () => { expect(withCapability.capability).toBe("statusText"); }); }); + +/** + * v1.43 (#1217) — capabilities whose model call has not landed yet. The + * capability is published (clients can render its state) before the route + * that reads a label photo exists; until then that route must not reach a + * provider at all. When it does, it becomes an egress site above, re-checking + * its capability at the wire like every document read, and leaves this list. + */ +const CAPABILITY_EGRESS_PENDING: Record = { + supplementLabel: "app/api/medications/[id]/ingredients/extract/route.ts", +}; + +describe("capabilities whose model call is still to come", () => { + it.each(Object.entries(CAPABILITY_EGRESS_PENDING))( + "%s is a published capability whose route does not call a model yet", + (key, rel) => { + expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key); + const text = code(rel); + expect( + EGRESS_CALL.test(text), + `${rel} calls a provider now: list it in EGRESS_SITES with its re-check and drop it here`, + ).toBe(false); + expect(/\bnotImplemented\s*\(/.test(text)).toBe(true); + }, + ); +}); diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index daf6c0df4..5d73a18ab 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -38,7 +38,6 @@ const CONTRACT_STUBS: Readonly> = { ], "anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"], "auth/claim/sso/route.ts": ["profile_claim.sso.start"], - "auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"], "dawarich/connect/route.ts": ["dawarich.connect"], "dawarich/disconnect/route.ts": ["dawarich.disconnect"], "dawarich/places/route.ts": ["dawarich.places.get"], @@ -62,7 +61,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 23; +const REGISTER_SIZE = 21; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 86828e5e6..72619b731 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -92,6 +92,8 @@ const PINNED_AFFORDANCES: Record< "text-success": 1, }, "src/components/admin/assistant-section.tsx": { "toast.success": 1 }, + // v1.43 — the AI usage bounds: a settings save, not a write with counts. + "src/components/admin/ai-usage-section.tsx": { "toast.success": 1 }, "src/components/admin/backups-section.tsx": { "toast.success": 4 }, "src/components/admin/central-codex-section.tsx": { "toast.success": 2 }, "src/components/admin/danger-zone-section.tsx": { "text-success": 1 }, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 48ffb6766..6579e9ca8 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -22,6 +22,7 @@ import { AiServerKeySection } from "@/components/admin/ai-server-key-section"; import { ProviderHealthSection } from "@/components/admin/provider-health-section"; import { CentralCodexSection } from "@/components/admin/central-codex-section"; import { AssistantSection } from "@/components/admin/assistant-section"; +import { AiUsageSection } from "@/components/admin/ai-usage-section"; import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section"; import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section"; @@ -105,6 +106,7 @@ export function AdminSectionRenderer({ return ( + diff --git a/src/app/api/__tests__/ai-capability-route-inventory.test.ts b/src/app/api/__tests__/ai-capability-route-inventory.test.ts index 2bb7afd8c..7e53af027 100644 --- a/src/app/api/__tests__/ai-capability-route-inventory.test.ts +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.ts @@ -165,6 +165,53 @@ describe("AI capability route inventory", () => { }); }); +/** + * Capabilities no route asks, each with its reason. Every other capability is + * asked by at least one route on `AI_ROUTES`, so a key cannot be added to the + * table and forgotten at the edge. + * + * - `reactionLines`: written by a job after a new reading arrives, never on + * request; the job asks `aiCapabilityForJob`. + * - `supplementLabel` (v1.43, #1217): its route + * (`medications/[id]/ingredients/extract`) is a contract stub until the + * supplement package fills it. The check below fails the moment the stub + * answers anything but 501, so the entry has to move to `AI_ROUTES` with + * the gate. + */ +const CAPABILITIES_WITHOUT_ROUTE: Readonly< + Partial> +> = { + reactionLines: "job only", + supplementLabel: "src/app/api/medications/[id]/ingredients/extract/route.ts", +}; + +describe("every capability reaches a route", () => { + it("is asked by an AI route, or named with its reason", () => { + const asked = new Set( + Object.values(AI_ROUTES).flatMap((entry) => [...entry.capabilities]), + ); + const orphaned = AI_CAPABILITY_KEYS.filter( + (key) => !asked.has(key) && !(key in CAPABILITIES_WITHOUT_ROUTE), + ); + expect(orphaned).toEqual([]); + const stale = Object.keys(CAPABILITIES_WITHOUT_ROUTE).filter((key) => + asked.has(key as (typeof AI_CAPABILITY_KEYS)[number]), + ); + expect(stale, "asked by a route now: drop it from the list").toEqual([]); + }); + + it("knows supplementLabel, and its route is still the stub", () => { + expect(AI_CAPABILITY_KEYS).toContain("supplementLabel"); + const path = CAPABILITIES_WITHOUT_ROUTE.supplementLabel!; + expect(ROUTES).toContain(path); + const text = code(path); + expect( + /\bnotImplemented\s*\(/.test(text) && !ANY_GATE.test(text), + `${path} is no longer a stub: gate it with requireAiCapability("supplementLabel") and list it on AI_ROUTES`, + ).toBe(true); + }); +}); + /** The route that queues each kind of background run. */ const RUN_QUEUED_BY: Record = { DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts", diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index fa9f4259e..75fb1b373 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({ }, // v1.42 — the operator key's model check reads the admin settings. appSettings: { findUnique: vi.fn(async () => null) }, + // v1.43 — accounts per effective AI usage level. + user: { groupBy: vi.fn(async () => []) }, }, })); @@ -378,3 +380,43 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", () expect(probeModelListing).not.toHaveBeenCalled(); }); }); + +// #1217 — the operator sees how many accounts run at each level, resolved +// against the current bounds, so lowering the ceiling moves the counts. +describe("usage levels", () => { + const userGroups = vi.mocked(prisma.user.groupBy); + const settingsRow = vi.mocked(prisma.appSettings.findUnique); + + beforeEach(() => { + groupBy.mockResolvedValue([] as never); + usageRows.mockResolvedValue([] as never); + }); + + it("counts each account at its effective level", async () => { + settingsRow.mockResolvedValue({ + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + } as never); + userGroups.mockResolvedValue([ + { aiUsageLevel: null, _count: { _all: 5 } }, + { aiUsageLevel: "FRUGAL", _count: { _all: 2 } }, + // Above the ceiling: capped to standard. + { aiUsageLevel: "FULL", _count: { _all: 3 } }, + ] as never); + const res = await GET(); + const body = await res.json(); + expect(body.data.usageLevels).toEqual({ frugal: 2, standard: 8, full: 0 }); + }); + + it("reports zero everywhere on an empty instance", async () => { + settingsRow.mockResolvedValue(null); + userGroups.mockResolvedValue([] as never); + const res = await GET(); + expect((await res.json()).data.usageLevels).toEqual({ + frugal: 0, + standard: 0, + full: 0, + }); + }); +}); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 8d35cada5..38a761efe 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -12,6 +12,13 @@ import { import { decrypt } from "@/lib/crypto"; import { isAnthropicBaseUrl } from "@/lib/ai/provider"; import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities"; +import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage"; +import { + AI_USAGE_LEVELS, + resolveAiUsageLevel, + usageLevelFromDb, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; export const dynamic = "force-dynamic"; @@ -186,9 +193,37 @@ export const GET = apiHandler(async () => { operatorTokens: spend.operator, accounts: spend.accounts, }, + usageLevels: await countAccountsByUsageLevel(), }); }); +/** + * v1.43 (#1217) — accounts per EFFECTIVE AI usage level: each stored choice + * (or none) resolved against the operator's current bounds, so the readout + * moves the moment the operator lowers the ceiling or the default. One + * grouped read over the users' stored choices. + */ +async function countAccountsByUsageLevel(): Promise< + Record +> { + const [settings, groups] = await Promise.all([ + loadAiUsageSettings(), + prisma.user.groupBy({ by: ["aiUsageLevel"], _count: { _all: true } }), + ]); + const counts = Object.fromEntries( + AI_USAGE_LEVELS.map((level) => [level, 0]), + ) as Record; + for (const group of groups) { + const { level } = resolveAiUsageLevel({ + preference: usageLevelFromDb(group.aiUsageLevel), + defaultLevel: settings.defaultLevel, + maxLevel: settings.maxLevel, + }); + counts[level] += group._count._all; + } + return counts; +} + /** * The model check for the operator's key. Never fails the readout: a missing * settings row, a key that does not decrypt, or a probe error is simply no diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 89f60dd8b..350c54de8 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -48,6 +48,9 @@ const ALL_ON = { assistantDocumentAiEnabled: true, aiReasoningEnabled: true, aiReasoningMaxEffort: "high", + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, }; beforeEach(() => { @@ -211,6 +214,75 @@ describe("PUT /api/admin/settings/assistant-flags", () => { }, ); + // #1217 — the operator's usage bounds. + it("stores the usage bounds and echoes them with the gap in effect", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + ...ALL_ON, + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: 12, + } as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "full", + defaultLevel: "frugal", + syncRegenGapHours: 12, + }, + }), + ); + expect(res.status).toBe(200); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect(upsert.update).toEqual({ + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: 12, + }); + const body = (await res.json()) as { data: { aiUsage: unknown } }; + expect(body.data.aiUsage).toEqual({ + maxLevel: "full", + defaultLevel: "frugal", + syncRegenGapHours: 12, + effectiveSyncRegenGapHours: 12, + }); + }); + + it("refuses a default above the ceiling", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "standard", + defaultLevel: "full", + syncRegenGapHours: null, + }, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta: { errorCode: string } }; + expect(body.meta.errorCode).toBe("ai.usage.defaultAboveCeiling"); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it.each([0, 49, 2.5])( + "refuses %s as the sync gap", + async (syncRegenGapHours) => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "standard", + defaultLevel: "standard", + syncRegenGapHours, + }, + }), + ); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }, + ); + it("rejects an empty body", async () => { vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); const res = await PUT(putReq({})); diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index 8190a70ba..a372b5e88 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -36,6 +36,16 @@ * switching reasoning off stops no AI capability, it only stops the model * thinking longer before it answers. * + * v1.43 (#1217) — and the AI usage bounds, optional `aiUsage`: + * `{ maxLevel, defaultLevel, syncRegenGapHours }`. `maxLevel` is the highest + * level an account may choose, `defaultLevel` the level an account without a + * choice runs at (never above `maxLevel`: 422 `ai.usage.defaultAboveCeiling`), + * `syncRegenGapHours` the minimum gap between status-card regenerations after + * a sync at `standard` (1..48, `null` follows `AI_SYNC_REGEN_GAP_HOURS`, else + * six). Lowering `maxLevel` caps every account above it at once; their stored + * choice is kept. Echoed as `aiUsage` with the resolved gap beside the stored + * one. + * * `requireAdmin()` gates the route — non-admins get 403. */ import type { NextRequest } from "next/server"; @@ -55,6 +65,13 @@ import { resolveAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve"; +import { aiUsageSettingsFromRow } from "@/lib/ai/capabilities/usage"; +import { + usageLevelRank, + usageLevelToDb, + type AiUsageLevelDb, +} from "@/lib/ai/usage-level"; +import { adminAiUsageSchema } from "@/lib/validations/ai-usage"; const assistantFlagsSchema = z .object({ @@ -65,6 +82,7 @@ const assistantFlagsSchema = z assistantDocumentAiEnabled: z.boolean().optional(), aiReasoningEnabled: z.boolean().optional(), aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), + aiUsage: adminAiUsageSchema.optional(), }) .strict(); @@ -76,6 +94,9 @@ type AssistantFlagsRow = { assistantDocumentAiEnabled: boolean; aiReasoningEnabled: boolean; aiReasoningMaxEffort: string; + aiUsageMaxLevel: AiUsageLevelDb; + aiUsageDefaultLevel: AiUsageLevelDb; + aiSyncRegenGapHours: number | null; }; const ROW_SELECT = { @@ -86,6 +107,9 @@ const ROW_SELECT = { assistantDocumentAiEnabled: true, aiReasoningEnabled: true, aiReasoningMaxEffort: true, + aiUsageMaxLevel: true, + aiUsageDefaultLevel: true, + aiSyncRegenGapHours: true, } as const; function buildResponseShape(row: AssistantFlagsRow) { @@ -109,6 +133,18 @@ function buildResponseShape(row: AssistantFlagsRow) { enabled: row.aiReasoningEnabled, maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort), }, + aiUsage: aiUsageShape(row), + }; +} + +/** The usage bounds as the admin panel edits them, the gap in effect beside. */ +function aiUsageShape(row: AssistantFlagsRow) { + const settings = aiUsageSettingsFromRow(row); + return { + maxLevel: settings.maxLevel, + defaultLevel: settings.defaultLevel, + syncRegenGapHours: settings.storedSyncRegenGapHours, + effectiveSyncRegenGapHours: settings.syncRegenGapHours, }; } @@ -132,6 +168,9 @@ export const GET = apiHandler(async () => { assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true, aiReasoningEnabled: settings?.aiReasoningEnabled ?? true, aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high", + aiUsageMaxLevel: settings?.aiUsageMaxLevel ?? "STANDARD", + aiUsageDefaultLevel: settings?.aiUsageDefaultLevel ?? "STANDARD", + aiSyncRegenGapHours: settings?.aiSyncRegenGapHours ?? null, }; return apiSuccess(buildResponseShape(row)); @@ -154,13 +193,28 @@ export const PUT = apiHandler(async (request: NextRequest) => { // Field by field from the parsed body; the schema is strict, so every key // here is one the route owns. + const { aiUsage, ...flags } = parsed.data; + if ( + aiUsage && + usageLevelRank(aiUsage.defaultLevel) > usageLevelRank(aiUsage.maxLevel) + ) { + return apiError("The default level is above the highest level", 422, { + errorCode: "ai.usage.defaultAboveCeiling", + }); + } const updates: Partial = {}; - for (const [key, value] of Object.entries(parsed.data)) { + for (const [key, value] of Object.entries(flags)) { if (value !== undefined) { (updates as Record)[key] = value; } } const auditDetails: Record = { ...updates }; + if (aiUsage) { + updates.aiUsageMaxLevel = usageLevelToDb(aiUsage.maxLevel); + updates.aiUsageDefaultLevel = usageLevelToDb(aiUsage.defaultLevel); + updates.aiSyncRegenGapHours = aiUsage.syncRegenGapHours; + auditDetails.aiUsage = aiUsage; + } if (Object.keys(updates).length === 0) { return apiError("No valid fields", 422); @@ -187,6 +241,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled, aiReasoningEnabled: settings.aiReasoningEnabled, aiReasoningMaxEffort: settings.aiReasoningMaxEffort, + aiUsageMaxLevel: settings.aiUsageMaxLevel, + aiUsageDefaultLevel: settings.aiUsageDefaultLevel, + aiSyncRegenGapHours: settings.aiSyncRegenGapHours, }; return apiSuccess(buildResponseShape(row)); diff --git a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts new file mode 100644 index 000000000..71eb1b3af --- /dev/null +++ b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts @@ -0,0 +1,206 @@ +/** + * `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the + * operator's ceiling. A level above it is refused with 422 + * `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null` + * returns to the instance default; the answer is the resolved `ai.usage`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + stored: null as string | null, + settings: { + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + } as Record, + usage: [] as Array<{ totalTokens: number }>, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(async () => ({ + aiUsageLevel: db.stored, + timezone: "UTC", + })), + update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => { + db.stored = args.data.aiUsageLevel; + return {}; + }), + }, + appSettings: { findUnique: vi.fn(async () => db.settings) }, + coachUsage: { findMany: vi.fn(async () => db.usage) }, + }, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const put = PUT as unknown as (req: Request) => Promise; + +function req(body: unknown): Request { + return new Request("http://localhost/api/auth/me/ai-usage", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + db.stored = null; + db.settings = { + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + }; + db.usage = []; + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 3_600_000, + } as never); +}); + +describe("PUT /api/auth/me/ai-usage", () => { + it("stores a level within the ceiling and answers the resolved state", async () => { + db.usage = [ + { totalTokens: 90_000 }, + { totalTokens: 110_000 }, + { totalTokens: 100_000 }, + ]; + const res = await put(req({ level: "frugal" })); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({ + where: { id: "user-1" }, + data: { aiUsageLevel: "FRUGAL" }, + }); + const { data } = await res.json(); + expect(data).toMatchObject({ + level: "frugal", + preference: "frugal", + defaultLevel: "standard", + maxLevel: "standard", + source: "user", + measuredTokensPerDay: 100_000, + measuredDays: 3, + }); + expect(data.options).toEqual([ + { level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 }, + { level: "standard", allowed: true, estimatedTokensPerDay: 200_000 }, + { level: "full", allowed: false, estimatedTokensPerDay: 666_667 }, + ]); + expect(auditLog).toHaveBeenCalledWith( + "user.aiUsage.update", + expect.objectContaining({ + userId: "user-1", + details: { previous: null, next: "frugal" }, + }), + ); + }); + + it("refuses a level above the ceiling with the ceiling named", async () => { + const res = await put(req({ level: "full" })); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta).toMatchObject({ + errorCode: "ai.usage.aboveCeiling", + maxLevel: "standard", + }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("accepts full once the operator allows it", async () => { + db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" }; + const res = await put(req({ level: "full" })); + expect(res.status).toBe(200); + expect((await res.json()).data.level).toBe("full"); + }); + + it("returns to the instance default on null", async () => { + db.stored = "FRUGAL"; + const res = await put(req({ level: null })); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({ + aiUsageLevel: null, + }); + const { data } = await res.json(); + expect(data).toMatchObject({ + level: "standard", + preference: null, + source: "default", + }); + // Fewer than three days of history: no estimate. + expect( + data.options.every( + (o: { estimatedTokensPerDay: number | null }) => + o.estimatedTokensPerDay === null, + ), + ).toBe(true); + }); + + it.each([ + [{ level: "lavish" }], + [{}], + [{ level: "frugal", extra: true }], + [{ level: 1 }], + ])("refuses %j as invalid", async (body) => { + const res = await put(req(body)); + expect(res.status).toBe(422); + expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid"); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses the 31st change in an hour", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const res = await put(req({ level: "frugal" })); + expect(res.status).toBe(429); + expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited"); + expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([ + 30, + 60 * 60 * 1000, + ]); + }); + + it("refuses a caller without a session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await put(req({ level: "frugal" })); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/auth/me/ai-usage/route.ts b/src/app/api/auth/me/ai-usage/route.ts index 1ceeb2d7d..7d5fd3d57 100644 --- a/src/app/api/auth/me/ai-usage/route.ts +++ b/src/app/api/auth/me/ai-usage/route.ts @@ -10,9 +10,22 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + loadAiUsageSettings, + loadAiUsageState, +} from "@/lib/ai/capabilities/usage"; +import { usageLevelRank, usageLevelToDb } from "@/lib/ai/usage-level"; import { aiUsagePutSchema } from "@/lib/validations/ai-usage"; export const dynamic = "force-dynamic"; @@ -38,5 +51,43 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); } - return notImplemented("auth.me.ai_usage.put"); + const { level } = parsed.data; + const settings = await loadAiUsageSettings(); + if ( + level !== null && + usageLevelRank(level) > usageLevelRank(settings.maxLevel) + ) { + annotate({ + action: { name: "auth.me.ai_usage.put.above_ceiling" }, + meta: { level, maxLevel: settings.maxLevel }, + }); + return apiError( + "This level is above the highest one the server allows", + 422, + { errorCode: "ai.usage.aboveCeiling", maxLevel: settings.maxLevel }, + ); + } + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { aiUsageLevel: true }, + }); + await prisma.user.update({ + where: { id: user.id }, + data: { aiUsageLevel: level === null ? null : usageLevelToDb(level) }, + }); + await auditLog("user.aiUsage.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + previous: previous?.aiUsageLevel?.toLowerCase() ?? null, + next: level, + }, + }); + annotate({ + action: { name: "auth.me.ai_usage.put" }, + meta: { level: level ?? "default" }, + }); + + return apiSuccess(await loadAiUsageState(user.id, { withMeasurement: true })); }); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index e6990a216..d39ccc3e4 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -443,7 +443,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks // — and the status re-warm for the touched types. Best-effort: a // populator hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import.csv"); + await afterMeasurementMutation(userId, touchedMeasurements, { + source: "manual", + label: "import.csv", + }); } await auditLog("import.csv.upload", { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e827cc674..b64ff48ce 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -483,7 +483,12 @@ export const POST = apiHandler(async (request: NextRequest) => { // (type, day) the import touched (collapsed, so a 10 000-row restore pays // at most ~N recomputes) and re-warm the touched types' status // assessments. Best-effort: a populator hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import"); + await afterMeasurementMutation(userId, touchedMeasurements, { + // A file the person chose to import: a deliberate act, narrated like a + // hand entry rather than held to the sync gap. + source: "manual", + label: "import", + }); annotate({ meta: { diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts index c9b2dc079..b1f2cc60c 100644 --- a/src/app/api/insights/chat/__tests__/dialog-harness.ts +++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts @@ -68,6 +68,18 @@ export interface ProviderCall { } export const providerCalls: ProviderCall[] = []; +/** + * Everything one provider call carried on the user side. Since #1217 a turn's + * own lines (a resolved follow-up, an answered clarification, a continuation) + * travel here, never in the system prompt, so the cached prefix holds. + */ +export function userSide(call: ProviderCall): string { + return call.messages + .filter((msg) => msg.role === "user") + .map((msg) => msg.content) + .join("\n\n"); +} + /** The last assistant message of the stored conversation. */ export const LAST_ASSISTANT_ID = "m-a1"; export const CONVERSATION_ID = "c-dialog"; diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index 1703970b6..821db4dee 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -113,6 +113,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: h.m.getCoachSystemPrompt, + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: h.m.getSelfContextTextForUser, @@ -652,21 +653,34 @@ describe("coach chat golden transcripts", () => { expect(transcript.thrown).toBeNull(); expect(m.runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1); const { params } = m.runStreamingRawCompletionWithFallback.mock - .calls[0][0] as { params: { system: string } }; - expect(params.system).toContain(CLARIFIED); + .calls[0][0] as { + params: { system: string; messages: Array<{ content: string }> }; + }; + // #1217 — on the user side, never in the cached system prompt. + const user = params.messages.map((msg) => msg.content).join("\n"); + expect(user).toContain(CLARIFIED); + expect(params.system).not.toContain(CLARIFIED); // Server-authored: the request's choice id picks the stored value, // the label the model once wrote never rides along. - expect(params.system).not.toContain("Resting pulse"); + const line = user + .split("\n") + .find((text) => text.startsWith("CLARIFIED:")); + expect(line).toContain(CLARIFIED); + expect(line).not.toContain("Resting pulse"); }); it("the tool-mode prompt carries the same line", async () => { reply("Your resting pulse averaged 62 bpm."); const transcript = await runTurn(post, body, {}); expect(transcript.thrown).toBeNull(); - const { system } = m.runCoachToolLoop.mock.calls[0][0] as { + const { system, messages } = m.runCoachToolLoop.mock.calls[0][0] as { system: string; + messages: Array<{ content: string }>; }; - expect(system).toContain(CLARIFIED); + expect(messages.map((msg) => msg.content).join("\n")).toContain( + CLARIFIED, + ); + expect(system).not.toContain(CLARIFIED); }); it("an unanswered turn sends neither prompt the line", async () => { @@ -680,8 +694,13 @@ describe("coach chat golden transcripts", () => { {}, ); const { params } = m.runStreamingRawCompletionWithFallback.mock - .calls[0][0] as { params: { system: string } }; + .calls[0][0] as { + params: { system: string; messages: Array<{ content: string }> }; + }; expect(params.system).not.toContain("CLARIFIED:"); + expect( + params.messages.map((msg) => msg.content).join("\n"), + ).not.toContain("CLARIFIED:"); }); }); }); diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts index e76d8a5e2..a945487be 100644 --- a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts +++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts @@ -118,6 +118,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts index ea52a5815..18ca5598c 100644 --- a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts @@ -116,6 +116,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(), diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts index 203fde324..f966c86c3 100644 --- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-followups.test.ts b/src/app/api/insights/chat/__tests__/route-followups.test.ts index fbfc92f84..b4490a3d5 100644 --- a/src/app/api/insights/chat/__tests__/route-followups.test.ts +++ b/src/app/api/insights/chat/__tests__/route-followups.test.ts @@ -265,7 +265,7 @@ describe("a reuse chip", () => { const run = await tap("f3"); expect(run.status).toBe(200); expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1); - expect(providerCalls[0].system).toContain( + expect(h.userSide(providerCalls[0])).toContain( "Call show_result with ref m1.r1 and view chart", ); }); @@ -282,15 +282,16 @@ describe("a model chip", () => { anchor: { domain: "glucose", window: "allTime" }, }); expect(run.status).toBe(200); - const system = providerCalls[0].system; - expect(system).toContain( + const user = h.userSide(providerCalls[0]); + expect(user).toContain( "FOLLOW-UP: the person tapped the previous_period chip", ); - expect(system).toContain( + expect(user).toContain( "get_metric_table metric=bp window=last30days period=previous granularity=week", ); - expect(system).toContain("m1.r1"); - expect(system).not.toContain("glucose window=allTime"); + expect(user).toContain("m1.r1"); + expect(user).not.toContain("glucose window=allTime"); + expect(providerCalls[0].system).not.toContain("FOLLOW-UP:"); }); it("degrades a chip that is not on the latest reply to a plain message", async () => { @@ -303,7 +304,7 @@ describe("a model chip", () => { }); expect(run.status).toBe(200); expect(providerCalls).toHaveLength(1); - expect(providerCalls[0].system).not.toContain("FOLLOW-UP:"); + expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:"); expect(annotated("coach.followUp.stale")).toEqual([ expect.objectContaining({ meta: { reason: "not_latest" } }), ]); @@ -313,7 +314,7 @@ describe("a model chip", () => { conversationWithChips(); world.script = [{ text: "Here is what I see." }]; await tap("f9"); - expect(providerCalls[0].system).not.toContain("FOLLOW-UP:"); + expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:"); expect(annotated("coach.followUp.stale")).toEqual([ expect.objectContaining({ meta: { reason: "unknown_chip" } }), ]); @@ -343,16 +344,17 @@ describe("a model chip", () => { { text: "Having looked further, the readings stay level." }, ]; await tap("f1"); - const system = providerCalls[0].system; - expect(system).toContain( + const user = h.userSide(providerCalls[0]); + expect(user).toContain( "CONTINUE: the person asked you to keep looking. The unfinished question is their message before that request, in CONVERSATION.", ); - // The question stays the person's own turn; it never reaches the - // system role. - expect(system).not.toContain("How was my blood pressure last month?"); - expect(system).toContain( + expect(user).toContain( "Already fetched: get_metric_table(bp, last30days, week) → m1.r1", ); + // The system role carries neither the line nor the question (#1217). + const { system } = providerCalls[0]; + expect(system).not.toContain("CONTINUE:"); + expect(system).not.toContain("How was my blood pressure last month?"); }); }); @@ -373,7 +375,7 @@ describe("an answered question", () => { clarification: { messageId: h.LAST_ASSISTANT_ID, choiceId: "c2" }, }); expect(run.status).toBe(200); - expect(providerCalls[0].system).toContain( + expect(h.userSide(providerCalls[0])).toContain( "CLARIFIED: the person answered your clarifying question by choosing metric=resting_hr.", ); }); @@ -385,7 +387,7 @@ describe("an answered question", () => { message: "The one from my watch", clarification: { messageId: h.LAST_ASSISTANT_ID }, }); - expect(providerCalls[0].system).toContain( + expect(h.userSide(providerCalls[0])).toContain( "CLARIFIED: the person answered your clarifying question in their own words", ); }); @@ -397,7 +399,7 @@ describe("an answered question", () => { message: "Resting heart rate", clarification: { messageId: "m-older", choiceId: "c2" }, }); - expect(providerCalls[0].system).not.toContain("CLARIFIED:"); + expect(h.userSide(providerCalls[0])).not.toContain("CLARIFIED:"); expect(annotated("coach.clarification.stale")).toHaveLength(1); }); diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index d63bc8a7f..182861065 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -154,6 +154,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index 4f455a742..c692f5205 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -147,6 +147,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index b47f19163..97d5c793f 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); // NB: the outbound guard + numeric verifier are REAL — that is the point. vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts index 59beef5cf..7a221034f 100644 --- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(), 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 afe28acfc..713dfcc5c 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 @@ -120,6 +120,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), 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 19749f312..fcb3028b6 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 @@ -153,6 +153,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts index 10081bc48..28a98783e 100644 --- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts +++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts @@ -193,6 +193,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 40a00ebb8..9d7e61548 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -94,6 +94,11 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { + applyBriefingShortForm, + briefingFormForRecord, + buildBriefingShortFormBlock, +} from "@/lib/insights/briefing-short-form"; import { featuresInReaderUnits } from "@/lib/insights/features-units"; import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; @@ -671,7 +676,9 @@ export const POST = apiHandler((request: NextRequest) => const compactFeatures = compactSections( features as unknown as Record, ); - const featuresJson = JSON.stringify(compactFeatures, null, 2); + // Compact on the wire: indentation is about 40% of a pretty-printed + // payload, and the model reads the structure without it (#1217). + const featuresJson = JSON.stringify(compactFeatures); if (payloadDowngraded) { annotate({ meta: { @@ -751,6 +758,10 @@ export const POST = apiHandler((request: NextRequest) => locale, await resolveUserTimezone(userId), ); + // v1.43 (#1217) — the short form on the `frugal` usage level; the full + // contract on every other level. + const briefingForm = await briefingFormForRecord(userId); + userPrompt += buildBriefingShortFormBlock(locale, briefingForm); // 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 @@ -1081,6 +1092,9 @@ export const POST = apiHandler((request: NextRequest) => errorCode: "insights.generate.profileScopeChanged", }); } + if (briefingForm === "short") { + insights = applyBriefingShortForm(insights); + } const stored = withBriefingGeneratedAt(insights, new Date()); const committed = await prisma.user.updateMany({ where: { @@ -1102,6 +1116,7 @@ export const POST = apiHandler((request: NextRequest) => aboutMe: aboutMe ?? null, comparisonBaseline: comparisonSnapshot?.baseline ?? "none", generationLocale: locale, + ...(briefingForm === "short" ? { briefingForm } : {}), }), }, }); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index d1a29a746..5b0431332 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -248,10 +248,14 @@ export const PUT = apiHandler( // NEW identity ride the list: an edit can move the row across day // boundaries or re-type it, and the helper collapses identical // (type, day) pairs so the common in-place edit fires once. - await afterMeasurementMutation(user.id, [ - { type: measurement.type, measuredAt: measurement.measuredAt }, - { type: existing.type, measuredAt: existing.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [ + { type: measurement.type, measuredAt: measurement.measuredAt }, + { type: existing.type, measuredAt: existing.measuredAt }, + ], + { source: "manual" }, + ); return apiSuccess(shapeMeasurementNotes(measurement)); }, @@ -312,9 +316,11 @@ export const DELETE = apiHandler( // v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops // the rollup row when the day's measurement count goes to zero). - await afterMeasurementMutation(user.id, [ - { type: existing.type, measuredAt: existing.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [{ type: existing.type, measuredAt: existing.measuredAt }], + { source: "manual" }, + ); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 299b0ff1b..43de22c21 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -1209,7 +1209,12 @@ async function postBatch(request: NextRequest): Promise { // v1.37.19 (C2-F1) — shared post-mutation tail for every distinct // (type, day) the batch touched. Updates participate as well as // inserts, so this deliberately retains the prepared-row scope. - await afterMeasurementMutation(user.id, writtenIdentities); + // A batch is the phone's sync, held to the sync gap, unless it wrote one + // of the person's own hand-entered readings, which is narrated like any + // other hand entry. + await afterMeasurementMutation(user.id, writtenIdentities, { + source: wroteManualRow ? "manual" : "sync", + }); // S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION // row for last night just landed, kick the debounced morning refresh so the diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 32ff2f171..3a3438a12 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -154,10 +154,12 @@ describe("POST /api/measurements/bulk-delete", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); // And one status re-warm over the distinct types. expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1); - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - "PULSE", - ]); + // A delete is the person's own act: narrated like a hand entry. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT", "PULSE"], + { source: "manual" }, + ); }); it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => { diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 7d341b65c..fd378ea9d 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -123,7 +123,7 @@ async function postBulkDelete(request: NextRequest): Promise { type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "measurements bulk-delete", + { source: "manual", label: "measurements bulk-delete" }, ); } diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts index 072608711..afb091c8e 100644 --- a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -118,9 +118,12 @@ describe("DELETE /api/measurements/by-external-ids", () => { // The deleted row's type is re-warmed; the zero-row case below is the // twin that must not reach it. - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - ]); + // The phone's HealthKit deletions arrive with its sync: the sync gap. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT"], + { source: "sync" }, + ); }); it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => { diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 387db7ed4..3a1caf88d 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -169,7 +169,7 @@ async function deleteByExternalIds(request: NextRequest): Promise { // v1.37.19 (C2-F1) — shared post-mutation tail (identities collected // BEFORE the destructive statement, while readable). - await afterMeasurementMutation(user.id, affectedRows); + await afterMeasurementMutation(user.id, affectedRows, { source: "sync" }); } return apiSuccess({ deletedCount: result.count }); diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts index dcd8a0f15..8ef10c8c3 100644 --- a/src/app/api/measurements/restore/__tests__/route.test.ts +++ b/src/app/api/measurements/restore/__tests__/route.test.ts @@ -149,10 +149,12 @@ describe("POST /api/measurements/restore", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); // And one status re-warm over the distinct types. expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1); - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - "PULSE", - ]); + // A restore is the person's own act: narrated like a hand entry. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT", "PULSE"], + { source: "manual" }, + ); }); it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => { diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index cbef0acdf..be8a4321a 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -122,7 +122,7 @@ async function postRestore(request: NextRequest): Promise { type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "measurements restore", + { source: "manual", label: "measurements restore" }, ); } diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 278ddb899..fa46fa1f7 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -963,6 +963,7 @@ async function postMeasurement(request: NextRequest) { await afterMeasurementMutation( user.id, results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), + { source: "manual" }, ); return apiSuccess(shapedResults, 201); @@ -1116,9 +1117,11 @@ async function postMeasurement(request: NextRequest) { // pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the // hood. Best-effort — a populator hiccup never fails the user's // write. - await afterMeasurementMutation(user.id, [ - { type: measurement.type, measuredAt: measurement.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [{ type: measurement.type, measuredAt: measurement.measuredAt }], + { source: "manual" }, + ); return apiSuccess(shapeMeasurementNotes(measurement), 201); } @@ -1259,10 +1262,11 @@ async function postNamedSleepStage( ).catch(() => {}); void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {}); } - await afterMeasurementMutation(userId, [ - { type, measuredAt: segment.end }, - ...(verdict.dirtyIdentities ?? []), - ]); + await afterMeasurementMutation( + userId, + [{ type, measuredAt: segment.end }, ...(verdict.dirtyIdentities ?? [])], + { source: "manual" }, + ); } return namedSleepStageResponse(verdict.row.id, status, { diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 74c81f181..c1ad8cc72 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -374,12 +374,16 @@ async function postAssessment(request: NextRequest): Promise { // route it through the shared post-mutation tail so the (type, day) // rollup bucket and the cached status assessment converge without // waiting for a nightly discovery pass. - await afterMeasurementMutation(user.id, [ - { - type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, - measuredAt: when, - }, - ]); + await afterMeasurementMutation( + user.id, + [ + { + type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, + measuredAt: when, + }, + ], + { source: "manual" }, + ); // v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on // PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing diff --git a/src/components/admin/ai-usage-section.tsx b/src/components/admin/ai-usage-section.tsx new file mode 100644 index 000000000..b949e5710 --- /dev/null +++ b/src/components/admin/ai-usage-section.tsx @@ -0,0 +1,228 @@ +"use client"; + +/** + * v1.43 (#1217) — the operator's AI usage bounds. + * + * The operator pays for the shared key, so the operator sets the highest + * level an account may choose and the level an account without a choice runs + * at; each person picks within that under Settings > AI. Beside them, the + * gap between two status-card regenerations after a sync at `standard` + * (whole hours, 1 to 48; empty follows `AI_SYNC_REGEN_GAP_HOURS`, else six). + * + * Reads and writes the `aiUsage` block of the assistant-flags route, the same + * query the assistant switches use. Lowering the ceiling caps every account + * above it at once; their stored choice is kept. + */ +import { useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Gauge } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { + AI_USAGE_LEVELS, + SYNC_REGEN_GAP_HOURS_MAX, + SYNC_REGEN_GAP_HOURS_MIN, + usageLevelRank, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; + +interface AiUsageBounds { + maxLevel: AiUsageLevel; + defaultLevel: AiUsageLevel; + syncRegenGapHours: number | null; + effectiveSyncRegenGapHours: number; +} + +const LEVEL_LABEL_KEYS = { + frugal: "settings.ai.usage.levels.frugal.label", + standard: "settings.ai.usage.levels.standard.label", + full: "settings.ai.usage.levels.full.label", +} as const satisfies Record; + +export function AiUsageSection() { + const { t } = useTranslations(); + const { data } = useQuery({ + queryKey: queryKeys.adminAssistantFlags(), + queryFn: () => + apiGet<{ aiUsage?: AiUsageBounds }>( + "/api/admin/settings/assistant-flags", + ), + }); + const bounds = data?.aiUsage; + if (!bounds) { + return ( + + + + ); + } + // Keyed on the stored values, so a save (or another operator's) re-seeds + // the form instead of leaving it on stale edits. + return ( + + ); +} + +function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) { + const { t } = useTranslations(); + const client = useQueryClient(); + const [maxLevel, setMaxLevel] = useState(bounds.maxLevel); + const [defaultLevel, setDefaultLevel] = useState( + bounds.defaultLevel, + ); + const [gap, setGap] = useState( + bounds.syncRegenGapHours === null ? "" : String(bounds.syncRegenGapHours), + ); + + const gapValue = gap.trim() === "" ? null : Number(gap); + const gapValid = + gapValue === null || + (Number.isInteger(gapValue) && + gapValue >= SYNC_REGEN_GAP_HOURS_MIN && + gapValue <= SYNC_REGEN_GAP_HOURS_MAX); + const defaultAbove = usageLevelRank(defaultLevel) > usageLevelRank(maxLevel); + + const save = useMutation({ + mutationFn: () => + apiPut<{ aiUsage: AiUsageBounds }>( + "/api/admin/settings/assistant-flags", + { + aiUsage: { maxLevel, defaultLevel, syncRegenGapHours: gapValue }, + }, + ), + onSuccess: (result) => { + client.setQueryData(queryKeys.adminAssistantFlags(), result); + void client.invalidateQueries({ + queryKey: queryKeys.adminProviderHealth(), + }); + void invalidateKeys(client, aiInputDependentKeys); + toast.success(t("common.saved")); + }, + onError: (err) => { + toast.error( + err instanceof Error && err.message + ? err.message + : t("admin.settingsSaveError"), + ); + }, + }); + + function onSubmit(event: FormEvent) { + event.preventDefault(); + if (!gapValid || defaultAbove) return; + save.mutate(); + } + + return ( + + +
+
+
+ + setMaxLevel(e.target.value as AiUsageLevel)} + > + {AI_USAGE_LEVELS.map((level) => ( + + ))} + +
+
+ + setDefaultLevel(e.target.value as AiUsageLevel)} + > + {AI_USAGE_LEVELS.map((level) => ( + + ))} + +
+
+ {defaultAbove ? ( +

+ {t("admin.aiUsage.defaultAboveCeiling")} +

+ ) : null} +
+ + setGap(e.target.value)} + /> +

+ {t("admin.aiUsage.gapHint", { + hours: bounds.effectiveSyncRegenGapHours, + })} +

+
+ + + +
+
+ ); +} diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index c54a88b65..4b4c8c185 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -69,9 +69,11 @@ export function ProviderHealthSection() { const { data, isPending, isError } = useQuery({ queryKey: queryKeys.adminProviderHealth(), queryFn: () => - apiGet<{ providers: ProviderHealthRow[]; spendToday: SpendToday }>( - "/api/admin/provider-health", - ), + apiGet<{ + providers: ProviderHealthRow[]; + spendToday: SpendToday; + usageLevels?: { frugal: number; standard: number; full: number }; + }>("/api/admin/provider-health"), }); const typeLabel = (type: string): string => { @@ -97,6 +99,7 @@ export function ProviderHealthSection() { const providers = data?.providers ?? []; const spendToday = data?.spendToday ?? null; + const usageLevels = data?.usageLevels ?? null; return ( @@ -114,6 +117,18 @@ export function ProviderHealthSection() { })}

) : null} + {usageLevels ? ( +

+ {t("admin.providerHealth.usageLevels", { + frugal: groupDigits(usageLevels.frugal), + standard: groupDigits(usageLevels.standard), + full: groupDigits(usageLevels.full), + })} +

+ ) : null} {isPending ? (

{t("admin.providerHealth.loading")} diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index 6c645bde9..d8b62cc74 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({ onDeviceAllowed: true, }), useAiCapabilityMap: () => null, + // v1.43 — no usage level published: the setting stays out of the way. + useAiUsage: () => null, useAiProviderState: () => ({ configured: true, managedBy: "user", diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index ce6e1f557..7fbfd7891 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({ onDeviceAllowed: true, }), useAiCapabilityMap: () => null, + // v1.43 — no usage level published: the setting stays out of the way. + useAiUsage: () => null, useAiProviderState: () => ({ configured: true, managedBy: "user", diff --git a/src/components/settings/ai/__tests__/ai-usage-card.test.tsx b/src/components/settings/ai/__tests__/ai-usage-card.test.tsx new file mode 100644 index 000000000..99cf881fd --- /dev/null +++ b/src/components/settings/ai/__tests__/ai-usage-card.test.tsx @@ -0,0 +1,120 @@ +/** + * #1217 — the AI usage setting renders what the server resolved: three + * options, the ones above the operator's ceiling disabled with their reason, + * each estimate labelled as one, and nothing computed on the client. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AiUsageState } from "@/lib/ai/usage-level"; + +const state = vi.hoisted(() => ({ + usage: null as AiUsageState | null, + canConfigure: true, +})); + +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiUsage: () => state.usage, + useAiProviderState: () => ({ + configured: true, + managedBy: "user", + canConfigure: state.canConfigure, + responseTimeoutMs: 60_000, + }), +})); + +import { AiUsageCard } from "../ai-usage-card"; + +function render(usage: AiUsageState | null, canConfigure = true) { + state.usage = usage; + state.canConfigure = canConfigure; + const client = new QueryClient(); + return renderToStaticMarkup( + + + + + , + ); +} + +const CAPPED: AiUsageState = { + level: "standard", + preference: "full", + defaultLevel: "standard", + maxLevel: "standard", + source: "admin_cap", + options: [ + { level: "frugal", allowed: true, estimatedTokensPerDay: 48_000 }, + { level: "standard", allowed: true, estimatedTokensPerDay: 100_000 }, + { level: "full", allowed: false, estimatedTokensPerDay: 400_000 }, + ], + measuredTokensPerDay: 100_000, + measuredDays: 7, +}; + +describe("AiUsageCard", () => { + it("renders nothing on a server that publishes no level", () => { + expect(render(null)).toBe(""); + }); + + it("checks the level in effect and disables the ones above the ceiling", () => { + const html = render(CAPPED); + expect(html.match(/role="radio"/g)).toHaveLength(3); + expect(html).toMatch(/aria-checked="true"[^>]*data-level="standard"/); + expect(html.match(/aria-checked="true"/g)).toHaveLength(1); + expect(html).toContain('data-level="full" data-allowed="false"'); + expect(html).toContain("Limited by the server operator."); + // The capped choice is named and kept. + expect(html).toContain('data-slot="ai-usage-capped"'); + expect(html).toContain("You chose Full"); + }); + + it("shows the server's estimates, labelled, in the reader's number format", () => { + const html = render(CAPPED); + expect(html).toContain("about 48,000 tokens a day, estimated"); + expect(html).toContain("about 400,000 tokens a day, estimated"); + expect(html).toContain( + "Measured: 100,000 tokens a day over the last 7 days", + ); + }); + + it("says when there is no estimate yet instead of inventing one", () => { + const html = render({ + ...CAPPED, + source: "default", + preference: null, + options: CAPPED.options.map((o) => ({ + ...o, + estimatedTokensPerDay: null, + })), + measuredTokensPerDay: null, + measuredDays: 0, + }); + expect(html).not.toContain("tokens a day, estimated"); + expect(html).toContain("An estimate per level appears after three days"); + expect(html).toContain("You follow the server default (Standard)."); + // No stored choice: nothing to return to. + expect(html).not.toContain('data-slot="ai-usage-use-default"'); + }); + + it("offers the way back to the default when a choice is stored", () => { + const html = render({ + ...CAPPED, + source: "user", + preference: "frugal", + level: "frugal", + }); + expect(html).toContain('data-slot="ai-usage-use-default"'); + expect(html).toContain("Use the server default (Standard)"); + }); + + it("locks the options where the account cannot set its own level", () => { + const html = render(CAPPED, false); + expect(html).toContain( + 'role="radiogroup" aria-label="AI usage" aria-disabled="true"', + ); + }); +}); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index bc2769846..e6e0ebb5b 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -36,6 +36,7 @@ import { import { AdminOpenAIProviderForm } from "./admin-openai-provider-form"; import { AiConsentCard } from "./ai-consent-card"; +import { AiUsageCard } from "./ai-usage-card"; import { AnthropicProviderForm } from "./anthropic-provider-form"; import { AutoReadCard } from "./auto-read-card"; import { CentralCodexSwitch } from "./central-codex-switch"; @@ -233,6 +234,10 @@ export function AiInsightsCard({ onSelect={pickProvider} /> + {/* v1.43 (#1217) — how much background AI work the account + asks for: one setting, three levels. */} + + {/* v1.22 (#89) — per-user response timeout (mainly for slow local/self-hosted backends). */} diff --git a/src/components/settings/ai/ai-usage-card.tsx b/src/components/settings/ai/ai-usage-card.tsx new file mode 100644 index 000000000..a7817ab78 --- /dev/null +++ b/src/components/settings/ai/ai-usage-card.tsx @@ -0,0 +1,233 @@ +"use client"; + +/* ──────────────────────────────────────────────────────────────── + * v1.43 (#1217) — AI usage: one setting, three levels. + * + * The level decides how much background AI work the account asks for: how + * often status cards regenerate after a sync, the snapshot size, background + * reasoning, the length of the daily briefing, the automatic document read + * and the label photo. The Coach is the same at every level. + * + * Everything shown comes off `ai.usage` on `/api/auth/me`, resolved on the + * server: which options the operator allows, the estimate per option (only + * ever labelled as one), the measured use. The card computes nothing. A pick + * is written at once through `PUT /api/auth/me/ai-usage`; the account payload + * is refetched so every surface that reads a capability sees the new level. + * ──────────────────────────────────────────────────────────────── */ + +import { useRef } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Gauge } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useAiProviderState, useAiUsage } from "@/hooks/use-ai-capability"; +import { apiPut } from "@/lib/api/api-fetch"; +import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level"; +import { useTranslations } from "@/lib/i18n/context"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +const LEVEL_KEYS = { + frugal: { + label: "settings.ai.usage.levels.frugal.label", + hint: "settings.ai.usage.levels.frugal.hint", + }, + standard: { + label: "settings.ai.usage.levels.standard.label", + hint: "settings.ai.usage.levels.standard.hint", + }, + full: { + label: "settings.ai.usage.levels.full.label", + hint: "settings.ai.usage.levels.full.hint", + }, +} as const satisfies Record; + +export function AiUsageCard() { + const { t, locale } = useTranslations(); + const queryClient = useQueryClient(); + const usage = useAiUsage(); + const provider = useAiProviderState(); + const refs = useRef>([]); + + const save = useMutation({ + mutationFn: (level) => + apiPut("/api/auth/me/ai-usage", { level }), + onSuccess: () => { + void invalidateKeys(queryClient, aiInputDependentKeys); + }, + }); + + // An older server publishes no level: there is nothing to set. + if (!usage) return null; + + // Only the account itself sets its level, and only while the operator's + // master switch is on (`canConfigure` is false inside somebody else's + // record and while AI is off on the server). + const editable = provider.canConfigure && !save.isPending; + const tokens = (value: number) => new Intl.NumberFormat(locale).format(value); + const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label); + const allowedLevels = usage.options + .filter((option) => option.allowed) + .map((option) => option.level); + + function pick(level: AiUsageLevel) { + if (!editable || level === usage?.level) return; + save.mutate(level); + } + + function onKeyDown( + event: React.KeyboardEvent, + level: AiUsageLevel, + ) { + const from = allowedLevels.indexOf(level); + const next = nextChoiceIndex(event.key, from, allowedLevels.length); + if (next === null) return; + event.preventDefault(); + const target = allowedLevels[next]!; + pick(target); + refs.current[usage!.options.findIndex((o) => o.level === target)]?.focus(); + } + + return ( + + + +

+ {usage.options.map((option, i) => { + const checked = option.level === usage.level; + const disabled = !option.allowed || !editable; + return ( + + ); + })} +
+ +

+ {usage.measuredTokensPerDay === null || usage.measuredDays < 3 + ? t("settings.ai.usage.noEstimate") + : t("settings.ai.usage.measured", { + tokens: tokens(usage.measuredTokensPerDay), + days: usage.measuredDays, + })} +

+ + {usage.source === "admin_cap" && usage.preference ? ( +

+ {t("settings.ai.usage.capped", { + choice: levelName(usage.preference), + level: levelName(usage.level), + })} +

+ ) : usage.source === "default" ? ( +

+ {t("settings.ai.usage.followsDefault", { + level: levelName(usage.defaultLevel), + })} +

+ ) : null} + + {save.isError ? ( +

+ {t("settings.ai.errorGeneric")} +

+ ) : null} + + {usage.preference !== null ? ( + + + + ) : null} +
+ ); +} diff --git a/src/components/settings/ai/auto-read-card.tsx b/src/components/settings/ai/auto-read-card.tsx index 0df49eaed..e8262693b 100644 --- a/src/components/settings/ai/auto-read-card.tsx +++ b/src/components/settings/ai/auto-read-card.tsx @@ -24,7 +24,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Switch } from "@/components/ui/switch"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; -import { useAiCapability } from "@/hooks/use-ai-capability"; +import { useAiCapability, useAiUsage } from "@/hooks/use-ai-capability"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -41,6 +41,9 @@ export function AutoReadCard() { // already-on preference off stays possible. const documentAi = useAiCapability("documentAi"); const operatorOff = documentAi.reason === "operator_disabled"; + // v1.43 (#1217) — on the frugal AI usage level the switch rests: documents + // are read only on request. The choice itself is kept. + const restingFrugal = useAiUsage()?.level === "frugal"; // Whether the honesty confirm is currently shown (user flipped the switch ON // but has not yet acknowledged the trade). Off→on reveals it; the write only @@ -115,6 +118,15 @@ export function AutoReadCard() {

) : null} + {restingFrugal && enabled && !operatorOff ? ( +

+ {t("settings.ai.autoRead.restingFrugal")} +

+ ) : null} + {pendingEnable ? (
= { not_permitted_for_record: [403, "ai.record.notPermitted"], module_disabled: [403, "module.disabled"], user_disabled: [403, "module.disabled"], + usage_level: [403, "ai.usage.levelTooLow"], no_provider: [422, "ai.provider.none"], consent_required: [403, "consent.ai.required"], }; @@ -75,6 +76,18 @@ describe("the refusal table", () => { } }); + it("names the level a usage refusal needs (#1217)", () => { + expect(aiRefusal("supplementLabel", "usage_level")).toEqual({ + status: 403, + meta: { + errorCode: "ai.usage.levelTooLow", + capability: "supplementLabel", + reason: "usage_level", + requiredLevel: "standard", + }, + }); + }); + it("keeps the code shipped clients know for the Coach", () => { expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe( "assistant.disabled.coach", diff --git a/src/lib/ai/__tests__/usage-level.test.ts b/src/lib/ai/__tests__/usage-level.test.ts new file mode 100644 index 000000000..7b06d9c0c --- /dev/null +++ b/src/lib/ai/__tests__/usage-level.test.ts @@ -0,0 +1,204 @@ +/** + * #1217 — the AI usage level: resolution, effects and the estimate. + * + * The resolution matrix is exhaustive over every stored choice (three levels + * and none), every default and every ceiling: 4 x 3 x 3 = 36 cases, each + * checked against a rule written out independently of the resolver. + */ +import { describe, expect, it } from "vitest"; + +import { + AI_USAGE_LEVELS, + AI_USAGE_LEVEL_EFFECTS, + buildAiUsageState, + capUsageLevel, + estimateTokensPerDay, + parseSyncRegenGapHours, + resolveAiUsageLevel, + statusRegenGapHours, + usageLevelFromDb, + usageLevelToDb, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; + +const RANK: Record = { frugal: 0, standard: 1, full: 2 }; + +describe("resolveAiUsageLevel — every choice x default x ceiling", () => { + const choices: Array = [null, ...AI_USAGE_LEVELS]; + let cases = 0; + for (const preference of choices) { + for (const defaultLevel of AI_USAGE_LEVELS) { + for (const maxLevel of AI_USAGE_LEVELS) { + cases++; + it(`choice ${preference ?? "none"}, default ${defaultLevel}, ceiling ${maxLevel}`, () => { + const got = resolveAiUsageLevel({ + preference, + defaultLevel, + maxLevel, + }); + if (preference === null) { + // No choice: the default, never above the ceiling. + const expected = + RANK[defaultLevel] > RANK[maxLevel] ? maxLevel : defaultLevel; + expect(got).toEqual({ level: expected, source: "default" }); + } else if (RANK[preference] > RANK[maxLevel]) { + // A choice above the ceiling is capped, and says so. + expect(got).toEqual({ level: maxLevel, source: "admin_cap" }); + } else { + expect(got).toEqual({ level: preference, source: "user" }); + } + // Whatever the inputs, the result never passes the ceiling. + expect(RANK[got.level]).toBeLessThanOrEqual(RANK[maxLevel]); + }); + } + } + } + it("covered every combination", () => { + expect(cases).toBe(36); + }); +}); + +describe("what each level does", () => { + it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => { + expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24); + expect(statusRegenGapHours("standard", "sync", 6)).toBe(6); + expect(statusRegenGapHours("standard", "sync", 12)).toBe(12); + expect(statusRegenGapHours("full", "sync", 12)).toBe(1); + }); + + it("regenerates after a hand entry: six hours on frugal, the hour otherwise", () => { + expect(statusRegenGapHours("frugal", "manual", 6)).toBe(6); + expect(statusRegenGapHours("standard", "manual", 24)).toBe(1); + expect(statusRegenGapHours("full", "manual", 24)).toBe(1); + }); + + it("caps the status snapshot at 10 000 on frugal and 14 000 above", () => { + expect( + AI_USAGE_LEVELS.map((l) => AI_USAGE_LEVEL_EFFECTS[l].statusSnapshotChars), + ).toEqual([10_000, 14_000, 14_000]); + }); + + it("switches background reasoning and the automatic document read off on frugal only", () => { + for (const level of AI_USAGE_LEVELS) { + const effect = AI_USAGE_LEVEL_EFFECTS[level]; + expect(effect.backgroundReasoning, level).toBe(level !== "frugal"); + expect(effect.documentsAutoRead, level).toBe(level !== "frugal"); + } + }); + + it("orders the token factors with the levels, full at one", () => { + const factors = AI_USAGE_LEVELS.map( + (l) => AI_USAGE_LEVEL_EFFECTS[l].tokenFactor, + ); + expect(factors).toEqual([...factors].sort((a, b) => a - b)); + expect(AI_USAGE_LEVEL_EFFECTS.full.tokenFactor).toBe(1); + // Calibrated by the watch-day simulation: standard ~0.31 of full, + // frugal ~0.15, the nightly warm included. + expect(AI_USAGE_LEVEL_EFFECTS.standard.tokenFactor).toBe(0.3); + expect(AI_USAGE_LEVEL_EFFECTS.frugal.tokenFactor).toBe(0.15); + }); +}); + +describe("the estimate", () => { + it("is null without three days of history", () => { + for (const measuredDays of [0, 1, 2]) { + expect( + estimateTokensPerDay({ + measuredTokensPerDay: 100_000, + measuredDays, + from: "standard", + target: "full", + }), + ).toBeNull(); + } + expect( + estimateTokensPerDay({ + measuredTokensPerDay: null, + measuredDays: 7, + from: "standard", + target: "full", + }), + ).toBeNull(); + }); + + it("scales the measured use by the factor ratio, whole tokens", () => { + const at = (target: AiUsageLevel) => + estimateTokensPerDay({ + measuredTokensPerDay: 100_000, + measuredDays: 5, + from: "standard", + target, + }); + expect(at("standard")).toBe(100_000); + expect(at("full")).toBe(333_333); + expect(at("frugal")).toBe(50_000); + }); +}); + +describe("buildAiUsageState", () => { + it("publishes three options in ceiling order with allowance and estimate", () => { + const state = buildAiUsageState({ + preference: "full", + defaultLevel: "standard", + maxLevel: "standard", + measuredTokensPerDay: 50_000, + measuredDays: 7, + }); + expect(state.level).toBe("standard"); + expect(state.source).toBe("admin_cap"); + expect(state.preference).toBe("full"); + expect(state.options.map((o) => [o.level, o.allowed])).toEqual([ + ["frugal", true], + ["standard", true], + ["full", false], + ]); + expect(state.options.map((o) => o.estimatedTokensPerDay)).toEqual([ + 25_000, 50_000, 166_667, + ]); + expect(state.measuredTokensPerDay).toBe(50_000); + expect(state.measuredDays).toBe(7); + }); + + it("publishes no estimate without history", () => { + const state = buildAiUsageState({ + preference: null, + defaultLevel: "standard", + maxLevel: "full", + measuredTokensPerDay: null, + measuredDays: 0, + }); + expect(state.options.every((o) => o.estimatedTokensPerDay === null)).toBe( + true, + ); + expect(state.source).toBe("default"); + }); +}); + +describe("spelling and parsing", () => { + it("round-trips the database spelling", () => { + for (const level of AI_USAGE_LEVELS) { + expect(usageLevelFromDb(usageLevelToDb(level))).toBe(level); + } + expect(usageLevelFromDb(null)).toBeNull(); + expect(usageLevelFromDb("LAVISH")).toBeNull(); + }); + + it("caps a level at a ceiling", () => { + expect(capUsageLevel("full", "standard")).toBe("standard"); + expect(capUsageLevel("frugal", "standard")).toBe("frugal"); + }); + + it.each([ + ["6", 6], + [12, 12], + ["48", 48], + ["0", null], + ["49", null], + ["2.5", null], + ["", null], + [undefined, null], + ["abc", null], + ])("parses the sync gap %s as %s", (raw, expected) => { + expect(parseSyncRegenGapHours(raw)).toBe(expected); + }); +}); diff --git a/src/lib/ai/capabilities/__tests__/resolve.test.ts b/src/lib/ai/capabilities/__tests__/resolve.test.ts index e631014bd..5a250991d 100644 --- a/src/lib/ai/capabilities/__tests__/resolve.test.ts +++ b/src/lib/ai/capabilities/__tests__/resolve.test.ts @@ -58,6 +58,7 @@ function baseline(): AiCapabilityInputs { }, activeConsentKinds: new Set(), recordKind: "self", + usageLevel: "full", }; } @@ -103,6 +104,11 @@ const PERTURB: Record, Perturb> = { inputs.moduleAccess = access; return true; }, + usage_level: (inputs, key) => { + if (AI_CAPABILITIES[key].minUsageLevel === undefined) return false; + inputs.usageLevel = "frugal"; + return true; + }, no_provider: (inputs) => { inputs.provider = { ...inputs.provider, entries: [], managedBy: null }; return true; @@ -429,16 +435,18 @@ describe("on-device permission", () => { not_permitted_for_record: false, module_disabled: false, user_disabled: false, + usage_level: false, no_provider: true, consent_required: true, }; for (const reason of PERTURBABLE) { const inputs = baseline(); - PERTURB[reason](inputs, "briefing"); - expect( - resolveAiCapability("briefing", inputs).onDeviceAllowed, - reason, - ).toBe(expected[reason]); + // `usage_level` only applies to a capability with a minimum level. + const key = reason === "usage_level" ? "supplementLabel" : "briefing"; + PERTURB[reason](inputs, key); + expect(resolveAiCapability(key, inputs).onDeviceAllowed, reason).toBe( + expected[reason], + ); } expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe( expected.check_failed, @@ -486,3 +494,43 @@ describe("the provider block", () => { }); }); }); + +describe("the AI usage level (#1217)", () => { + it("closes only the capabilities that need a higher level", () => { + const inputs = baseline(); + inputs.usageLevel = "frugal"; + const resolved = resolveAiCapabilities(inputs); + for (const key of AI_CAPABILITY_KEYS) { + const needsMore = AI_CAPABILITIES[key].minUsageLevel !== undefined; + expect(resolved[key].reason, key).toBe(needsMore ? "usage_level" : null); + } + }); + + it("offers the label photo from standard up", () => { + for (const [level, available] of [ + ["frugal", false], + ["standard", true], + ["full", true], + ] as const) { + const inputs = baseline(); + inputs.usageLevel = level; + expect( + resolveAiCapability("supplementLabel", inputs).available, + level, + ).toBe(available); + } + }); + + it("ranks after the person's opt-out and before the provider", () => { + const inputs = baseline(); + inputs.usageLevel = "frugal"; + inputs.provider = { ...inputs.provider, entries: [], managedBy: null }; + expect(resolveAiCapability("supplementLabel", inputs).reason).toBe( + "usage_level", + ); + inputs.moduleAccess = { ...inputs.moduleAccess, nutrients: "disabled" }; + expect(resolveAiCapability("supplementLabel", inputs).reason).toBe( + "module_disabled", + ); + }); +}); diff --git a/src/lib/ai/capabilities/load.ts b/src/lib/ai/capabilities/load.ts index 9e43dc842..e277667ea 100644 --- a/src/lib/ai/capabilities/load.ts +++ b/src/lib/ai/capabilities/load.ts @@ -9,7 +9,9 @@ * memoised reads the account payload and every module gate use; * - provider presence: the record's credential row and the operator's * provider columns (`probeProviderChain`), presence only; - * - consent: the kinds of the record's active receipts, one indexed read. + * - consent: the kinds of the record's active receipts, one indexed read; + * - the record's AI usage level (v1.43): the operator's bounds (memoised) + * and one point read of the record's choice. * * Any failure resolves to `null`, which the resolver turns into * `check_failed` for every capability. Failing closed is safe because no data @@ -38,6 +40,7 @@ import { type AiRecordKind, } from "./resolve"; import type { AiCapabilities } from "./types"; +import { loadAiUsageState, loadRecordUsageLevel } from "./usage"; /** Whose record, seen by whom, through which grant. */ export interface AiCapabilityScope { @@ -89,14 +92,21 @@ export function loadAiCapabilityInputs( `ai-capability-inputs:${scopeKey(scope)}`, async () => { try { - const [switches, modules, availability, provider, activeConsentKinds] = - await Promise.all([ - loadAssistantSwitches(), - resolveModuleMap(scope.recordId), - getOperatorModuleAvailability(), - probeProviderChain(scope.recordId, scope.authority), - loadActiveConsentKinds(scope.recordId), - ]); + const [ + switches, + modules, + availability, + provider, + activeConsentKinds, + usageLevel, + ] = await Promise.all([ + loadAssistantSwitches(), + resolveModuleMap(scope.recordId), + getOperatorModuleAvailability(), + probeProviderChain(scope.recordId, scope.authority), + loadActiveConsentKinds(scope.recordId), + loadRecordUsageLevel(scope.recordId), + ]); if (switches === null) return null; const { moduleAccess } = buildModuleDisclosure( modules, @@ -110,6 +120,7 @@ export function loadAiCapabilityInputs( provider, activeConsentKinds, recordKind: scope.recordKind, + usageLevel, }; } catch { getEvent()?.addWarning( @@ -122,9 +133,22 @@ export function loadAiCapabilityInputs( ); } -/** The whole `ai` block for one scope. */ +/** + * The whole `ai` block for one scope, the record's AI usage level included + * (v1.43). The measured use and the estimates are published for one's own + * record only: a delegate sees which level a record runs at, not what its + * owner spends. A usage read that fails leaves `usage` out rather than + * failing the block; the capabilities already carry the level they resolved. + */ export async function loadAiCapabilities( scope: AiCapabilityScope, ): Promise { - return resolveAiBlock(await loadAiCapabilityInputs(scope)); + const [inputs, usage] = await Promise.all([ + loadAiCapabilityInputs(scope), + loadAiUsageState(scope.recordId, { + withMeasurement: scope.recordKind === "self", + }).catch(() => undefined), + ]); + const block = resolveAiBlock(inputs); + return usage ? { ...block, usage } : block; } diff --git a/src/lib/ai/capabilities/refusal.ts b/src/lib/ai/capabilities/refusal.ts index 0f6a44639..01c21d44f 100644 --- a/src/lib/ai/capabilities/refusal.ts +++ b/src/lib/ai/capabilities/refusal.ts @@ -8,12 +8,14 @@ */ import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate"; import type { ModuleKey } from "@/lib/modules/registry"; +import type { AiUsageLevel } from "@/lib/ai/usage-level"; import { AI_CAPABILITIES, AI_PROVIDER_NONE_ERROR_CODE, AI_RECORD_NOT_PERMITTED_ERROR_CODE, AI_UNAVAILABLE_ERROR_CODE, + AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE, type AiCapabilityKey, type AiUnavailableReason, } from "./types"; @@ -33,6 +35,8 @@ export interface AiRefusalMeta { capability: AiCapabilityKey; reason: AiUnavailableReason; module?: ModuleKey; + /** Beside `ai.usage.levelTooLow`: the lowest level the capability needs. */ + requiredLevel?: AiUsageLevel; } /** The HTTP shape of one refusal. Pure; exported for the envelope test. */ @@ -72,6 +76,17 @@ export function aiRefusal( ...(module === null ? {} : { module }), }, }; + case "usage_level": + return { + status: 403, + meta: { + ...base, + errorCode: AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE, + ...(AI_CAPABILITIES[capability].minUsageLevel + ? { requiredLevel: AI_CAPABILITIES[capability].minUsageLevel } + : {}), + }, + }; case "no_provider": return { status: noProvider?.status ?? 422, @@ -99,6 +114,7 @@ const REFUSAL_MESSAGE: Record = { not_permitted_for_record: "AI work is not available for this record", module_disabled: "This AI feature's module is turned off for this record", user_disabled: "This AI feature is turned off in your settings", + usage_level: "This AI feature is not available at the current AI usage level", no_provider: "No AI provider is set up for this feature", consent_required: "AI consent is required for this feature", }; diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 0152a7ac7..93a6f2341 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -21,6 +21,7 @@ import { isExternalProvider, isOperatorHeldProvider, } from "@/lib/ai/provider-egress"; +import { usageLevelAtLeast, type AiUsageLevel } from "@/lib/ai/usage-level"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; @@ -88,6 +89,11 @@ export interface AiCapabilityInputs { /** Kinds of the record's active (non-revoked) consent receipts. */ activeConsentKinds: ReadonlySet; recordKind: AiRecordKind; + /** + * v1.43 (#1217) — the record's AI usage level in effect. The loader always + * sets it; absent (a hand-built set of inputs) means no level restriction. + */ + usageLevel?: AiUsageLevel; } // Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`, @@ -271,6 +277,15 @@ function consentReason( : "consent_required"; } +/** Whether the record's usage level is below what the capability needs. */ +function usageReason( + def: AiCapabilityDefinition, + level: AiUsageLevel | undefined, +): AiUnavailableReason | null { + if (def.minUsageLevel === undefined || level === undefined) return null; + return usageLevelAtLeast(level, def.minUsageLevel) ? null : "usage_level"; +} + function stateFor(reason: AiUnavailableReason | null): AiCapabilityState { if (reason === null) { return { available: true, reason: null, onDeviceAllowed: true }; @@ -314,6 +329,7 @@ export function explainAiCapability( switchReason, inputs.providerWorkAdmitted ? null : "not_permitted_for_record", modules.reason, + usageReason(def, inputs.usageLevel), providerReason(def, inputs.provider), consentReason(def, inputs.provider, inputs.activeConsentKinds), ]); diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index 1dc6274df..1acb92931 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -15,6 +15,7 @@ * contract, the structural guards — can depend on it without pulling a * database client along. */ +import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level"; import type { ModuleKey } from "@/lib/modules/registry"; /** Every AI capability, in publication order. */ @@ -29,6 +30,7 @@ export const AI_CAPABILITY_KEYS = [ "documentAi", "labsOcr", "medicationExtract", + "supplementLabel", ] as const; export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number]; @@ -49,6 +51,9 @@ export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number]; * record's own switch or by the edge of the active grant. * - `user_disabled`: the record's own AI opt-out: `disableCoach` for the * Coach, the `insights` module ("AI analysis") for everything else. + * - `usage_level`: the record's AI usage level (v1.43, #1217) is below the + * level the capability needs (`minUsageLevel`). A choice, like the + * opt-out, so it ranks right after it and before the provider layers. * - `no_provider`: no configured provider can serve this capability's input. * - `consent_required`: the provider chain needs a consent receipt and none * is active. @@ -59,6 +64,7 @@ export const AI_UNAVAILABLE_REASONS = [ "not_permitted_for_record", "module_disabled", "user_disabled", + "usage_level", "no_provider", "consent_required", ] as const; @@ -166,6 +172,11 @@ export interface AiCapabilityDefinition { consent: { rule: AiConsentRule; kinds: readonly AiConsentKind[] }; modality: AiModality; providerOrder: AiProviderOrder; + /** + * v1.43 (#1217) — the lowest AI usage level at which the capability is + * offered. Absent: every level. + */ + minUsageLevel?: AiUsageLevel; } /** @@ -289,6 +300,18 @@ export const AI_CAPABILITIES: Readonly< modality: "text", providerOrder: "chain", }, + // v1.43 — reading a supplement's label photo into its ingredients. A photo + // is a document, and an extra model run per label, so it is not offered on + // the `frugal` usage level. + supplementLabel: { + key: "supplementLabel", + operatorSwitch: "documentAi", + modules: { mode: "all", keys: ["medications", "nutrients"] }, + consent: { rule: "document", kinds: EXTRACTION_KINDS }, + modality: "document", + providerOrder: "document", + minUsageLevel: "standard", + }, }); /** @@ -328,6 +351,11 @@ export interface AiProviderState { export interface AiCapabilities { capabilities: Record; provider: AiProviderState; + /** + * v1.43 (#1217) — the record's AI usage level, its bounds and the estimate. + * Absent on servers before v1.43 and on blocks resolved without it. + */ + usage?: AiUsageState; } // ── Refusal codes ────────────────────────────────────────────────────────── @@ -345,3 +373,5 @@ export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted"; export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none"; /** The capability's inputs could not be loaded; the answer failed closed. */ export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable"; +/** v1.43 — the record's AI usage level is below what the capability needs. */ +export const AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE = "ai.usage.levelTooLow"; diff --git a/src/lib/ai/capabilities/usage.ts b/src/lib/ai/capabilities/usage.ts new file mode 100644 index 000000000..e3eda8f27 --- /dev/null +++ b/src/lib/ai/capabilities/usage.ts @@ -0,0 +1,222 @@ +/** + * v1.43 (#1217) — the reads behind an AI usage level. + * + * The vocabulary, the resolver and the estimate are pure and live in + * `@/lib/ai/usage-level`; this file gathers their inputs: + * + * - the operator's bounds and sync gap: the `AppSettings` singleton, one + * memoised read per request (read afresh inside a background run, so a + * changed ceiling reaches the next account of a nightly pass); + * - the record's stored choice: one point read of `users.ai_usage_level`; + * - the measured use: the record's `coach_usage` rows of the last seven + * full days in its own time zone. + * + * A level is resolved for the RECORD, like every AI capability: a background + * job runs per record, and a delegate or guardian inside somebody else's + * record sees that record's level. Only the account itself can change it + * (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own + * and follows the instance default. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { + AI_USAGE_LEVEL_EFFECTS, + AI_USAGE_MEASURE_DAYS, + DEFAULT_AI_USAGE_DEFAULT_LEVEL, + DEFAULT_AI_USAGE_MAX_LEVEL, + DEFAULT_SYNC_REGEN_GAP_HOURS, + buildAiUsageState, + parseSyncRegenGapHours, + resolveAiUsageLevel, + usageLevelFromDb, + type AiUsageLevel, + type AiUsageResolution, + type AiUsageState, +} from "@/lib/ai/usage-level"; + +/** The operator's side of the usage levels. */ +export interface AiUsageSettings { + maxLevel: AiUsageLevel; + defaultLevel: AiUsageLevel; + /** The stored sync gap (1..48), `null` when the operator set none. */ + storedSyncRegenGapHours: number | null; + /** The sync gap in effect: the stored one, else the environment, else six. */ + syncRegenGapHours: number; +} + +/** The environment's sync gap, else six. */ +export function envSyncRegenGapHours(): number { + return ( + parseSyncRegenGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ?? + DEFAULT_SYNC_REGEN_GAP_HOURS + ); +} + +/** Pure: the operator's settings from the raw row (or its absence). */ +export function aiUsageSettingsFromRow( + row: { + aiUsageMaxLevel?: string | null; + aiUsageDefaultLevel?: string | null; + aiSyncRegenGapHours?: number | null; + } | null, +): AiUsageSettings { + const stored = parseSyncRegenGapHours(row?.aiSyncRegenGapHours); + return { + maxLevel: + usageLevelFromDb(row?.aiUsageMaxLevel) ?? DEFAULT_AI_USAGE_MAX_LEVEL, + defaultLevel: + usageLevelFromDb(row?.aiUsageDefaultLevel) ?? + DEFAULT_AI_USAGE_DEFAULT_LEVEL, + storedSyncRegenGapHours: stored, + syncRegenGapHours: stored ?? envSyncRegenGapHours(), + }; +} + +/** + * The operator's bounds and sync gap. An unreadable row reads as the + * instance defaults (`standard` / `standard`, the environment's gap): the + * levels bound spend, and the default is what an untouched instance runs at. + */ +export function loadAiUsageSettings(): Promise { + const read = async (): Promise => { + try { + const row = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + aiUsageMaxLevel: true, + aiUsageDefaultLevel: true, + aiSyncRegenGapHours: true, + }, + }); + return aiUsageSettingsFromRow(row); + } catch { + getEvent()?.addWarning( + "Failed to load the AI usage settings; the instance defaults apply", + ); + return aiUsageSettingsFromRow(null); + } + }; + try { + return memoizePerRequest("ai-usage-settings", read, { + freshInBackground: true, + }); + } catch { + return read(); + } +} + +/** One record's level: its stored choice against the operator's bounds. */ +export interface RecordAiUsage extends AiUsageResolution { + preference: AiUsageLevel | null; + settings: AiUsageSettings; +} + +/** + * The level in effect for one record. A missing row (an account deleted + * under a running job) reads as no choice, so the default applies. + */ +export function loadRecordAiUsage(recordId: string): Promise { + const read = async (): Promise => { + const [settings, row] = await Promise.all([ + loadAiUsageSettings(), + prisma.user.findUnique({ + where: { id: recordId }, + select: { aiUsageLevel: true }, + }), + ]); + const preference = usageLevelFromDb(row?.aiUsageLevel); + return { + ...resolveAiUsageLevel({ + preference, + defaultLevel: settings.defaultLevel, + maxLevel: settings.maxLevel, + }), + preference, + settings, + }; + }; + try { + return memoizePerRequest(`ai-usage:${recordId}`, read, { + freshInBackground: true, + }); + } catch { + return read(); + } +} + +/** Just the effective level, for a job or a gate. */ +export async function loadRecordUsageLevel( + recordId: string, +): Promise { + return (await loadRecordAiUsage(recordId)).level; +} + +/** + * The status snapshot's char cap for this record's level. An unreadable level + * reads as the instance default's cap, never as an uncapped snapshot. + */ +export async function statusSnapshotCharsForRecord( + recordId: string, +): Promise { + try { + const level = await loadRecordUsageLevel(recordId); + return AI_USAGE_LEVEL_EFFECTS[level].statusSnapshotChars; + } catch { + return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL] + .statusSnapshotChars; + } +} + +/** + * The record's measured AI use: the mean of its `coach_usage` totals over the + * last seven full days in its own time zone (today is still running and does + * not count). `coach_usage` counts the Coach and the background work + * together. A day without a row had no AI use recorded and is not counted, so + * `days` says how many days stand behind the mean. + */ +export async function loadMeasuredAiUsage( + recordId: string, + now: Date = new Date(), +): Promise<{ tokensPerDay: number | null; days: number }> { + const today = userDayKey(now, await resolveUserTimezone(recordId)); + const keys = Array.from({ length: AI_USAGE_MEASURE_DAYS }, (_, i) => + shiftDateKey(today, -(i + 1)), + ); + const rows = await prisma.coachUsage.findMany({ + where: { userId: recordId, dateKey: { in: keys } }, + select: { totalTokens: true }, + }); + if (rows.length === 0) return { tokensPerDay: null, days: 0 }; + const total = rows.reduce((sum, row) => sum + row.totalTokens, 0); + return { tokensPerDay: Math.round(total / rows.length), days: rows.length }; +} + +/** + * `ai.usage` for one record. `withMeasurement: false` publishes the levels + * without the measured use or the estimates: a delegate sees which level the + * record runs at, never how much the owner spends. + */ +export async function loadAiUsageState( + recordId: string, + options: { withMeasurement: boolean }, +): Promise { + const [usage, measured] = await Promise.all([ + loadRecordAiUsage(recordId), + options.withMeasurement + ? loadMeasuredAiUsage(recordId).catch(() => ({ + tokensPerDay: null, + days: 0, + })) + : Promise.resolve({ tokensPerDay: null, days: 0 }), + ]); + return buildAiUsageState({ + preference: usage.preference, + defaultLevel: usage.settings.defaultLevel, + maxLevel: usage.settings.maxLevel, + measuredTokensPerDay: measured.tokensPerDay, + measuredDays: measured.days, + }); +} diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 7b9dfd64f..447dee86a 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -225,7 +225,10 @@ describe("buildCoachSnapshot", () => { ], }); - expect(out.snapshotJson).toBe(JSON.stringify(out.sections, null, 2)); + // The prompt carries the snapshot compact (#1217): the same content, + // without the indentation. + expect(out.snapshotJson).toBe(JSON.stringify(out.sections)); + expect(JSON.parse(out.snapshotJson)).toEqual(out.sections); expect(out).toMatchInlineSnapshot(` { "provenance": { @@ -468,199 +471,7 @@ describe("buildCoachSnapshot", () => { "unit": "kg", }, }, - "snapshotJson": "{ - "bloodPressure": { - "aggregate": { - "avgSys30": 125, - "avgDia30": 80, - "coverage": { - "count": 4 - } - }, - "timeline": { - "recent": [ - { - "date": "2026-07-10", - "weekday": "Fri", - "sys": 128, - "dia": 82 - } - ], - "weeklySys": [ - { - "weekISO": "2026-W27", - "mean": 122, - "count": 1 - } - ], - "weeklyDia": [ - { - "weekISO": "2026-W27", - "mean": 78, - "count": 1 - } - ] - } - }, - "weight": { - "unit": "kg", - "aggregate": { - "latest": 80.8, - "bmi": 24.7, - "coverage": { - "count": 2 - }, - "unit": "kg" - }, - "timeline": { - "recent": [ - { - "date": "2026-07-16", - "weekday": "Thu", - "value": 80.8 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 81.4, - "count": 1 - } - ] - } - }, - "pulse": { - "aggregate": { - "avg7": 63, - "coverage": { - "count": 2 - } - }, - "timeline": { - "recent": [ - { - "date": "2026-07-17", - "weekday": "Fri", - "value": 63 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 67, - "count": 1 - } - ] - } - }, - "mood": { - "aggregate": { - "avg30": 4, - "coverage": { - "count": 2 - } - }, - "timeline": { - "recent": [ - { - "date": "2026-07-18", - "weekday": "Sat", - "value": 5 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 3, - "count": 1 - } - ] - } - }, - "bodyMassIndex": { - "unit": "kg/m²", - "timeline": { - "recent": [ - { - "date": "2026-07-08", - "weekday": "Wed", - "value": 24.8 - }, - { - "date": "2026-07-15", - "weekday": "Wed", - "value": 24.6 - } - ], - "weekly": [] - } - }, - "steps": { - "unit": "steps", - "timeline": { - "recent": [ - { - "date": "2026-07-18", - "weekday": "Sat", - "value": 8400 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 7200, - "count": 1 - } - ] - } - }, - "skinTemperature": { - "unit": "°C", - "timeline": { - "recent": [ - { - "date": "2026-07-19", - "weekday": "Sun", - "value": 33.5 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 33.2, - "count": 1 - } - ] - } - }, - "anthropometrics": { - "heightCm": 181, - "ageYears": 41, - "gender": "MALE" - }, - "dayStrain": { - "source": "WHOOP-native", - "scale": "0-21", - "latest": 13.9, - "recentMean": 12.6, - "days": 2, - "note": "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)." - }, - "scope": { - "window": "last30days", - "sources": [ - "bp", - "weight", - "pulse", - "mood", - "steps", - "bmi", - "skin_temp", - "hrv" - ], - "timelineRecentDays": 14 - } - }", + "snapshotJson": "{"bloodPressure":{"aggregate":{"avgSys30":125,"avgDia30":80,"coverage":{"count":4}},"timeline":{"recent":[{"date":"2026-07-10","weekday":"Fri","sys":128,"dia":82}],"weeklySys":[{"weekISO":"2026-W27","mean":122,"count":1}],"weeklyDia":[{"weekISO":"2026-W27","mean":78,"count":1}]}},"weight":{"unit":"kg","aggregate":{"latest":80.8,"bmi":24.7,"coverage":{"count":2},"unit":"kg"},"timeline":{"recent":[{"date":"2026-07-16","weekday":"Thu","value":80.8}],"weekly":[{"weekISO":"2026-W27","mean":81.4,"count":1}]}},"pulse":{"aggregate":{"avg7":63,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-17","weekday":"Fri","value":63}],"weekly":[{"weekISO":"2026-W27","mean":67,"count":1}]}},"mood":{"aggregate":{"avg30":4,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":5}],"weekly":[{"weekISO":"2026-W27","mean":3,"count":1}]}},"bodyMassIndex":{"unit":"kg/m²","timeline":{"recent":[{"date":"2026-07-08","weekday":"Wed","value":24.8},{"date":"2026-07-15","weekday":"Wed","value":24.6}],"weekly":[]}},"steps":{"unit":"steps","timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":8400}],"weekly":[{"weekISO":"2026-W27","mean":7200,"count":1}]}},"skinTemperature":{"unit":"°C","timeline":{"recent":[{"date":"2026-07-19","weekday":"Sun","value":33.5}],"weekly":[{"weekISO":"2026-W27","mean":33.2,"count":1}]}},"anthropometrics":{"heightCm":181,"ageYears":41,"gender":"MALE"},"dayStrain":{"source":"WHOOP-native","scale":"0-21","latest":13.9,"recentMean":12.6,"days":2,"note":"Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."},"scope":{"window":"last30days","sources":["bp","weight","pulse","mood","steps","bmi","skin_temp","hrv"],"timelineRecentDays":14}}", "units": { "glucoseUnit": "mg/dL", "system": "metric", diff --git a/src/lib/ai/coach/__tests__/stable-prefix.test.ts b/src/lib/ai/coach/__tests__/stable-prefix.test.ts new file mode 100644 index 000000000..dcc7a7eb3 --- /dev/null +++ b/src/lib/ai/coach/__tests__/stable-prefix.test.ts @@ -0,0 +1,108 @@ +/** + * #1217 — the Coach's cached prefix (tools plus system prompt) is the same + * string on every turn of a conversation. What changes per turn (the opener + * hint, the name clause, a resolved follow-up, the table rules once a table + * exists) travels in the user message, so a provider's prompt cache holds + * from one turn to the next instead of being rewritten every time. + */ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { buildCoachToolRequest } from "@/lib/ai/coach/chat-request-builder"; +import { + buildCoachTurnNotes, + getCoachSystemPrompt, +} from "@/lib/ai/coach/system-prompt"; +import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum"; +import { + buildDialogAddenda, + buildTableRulesAddendum, +} from "@/lib/ai/coach/turn/addenda"; +import { + openerArchetypeHint, + shouldUseNameForTurn, +} from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; + +const sha = (text: string) => createHash("sha256").update(text).digest("hex"); + +/** One turn's request, assembled the way the turn model assembles it. */ +function turnRequest(locale: Locale, turnIndex: number, hints: string[]) { + const systemPrompt = getCoachSystemPrompt(locale, undefined, "I run."); + const turnNotes = buildCoachTurnNotes(locale, { + firstName: "Alex", + mayUseName: shouldUseNameForTurn(`u1:${turnIndex}`), + openerHint: openerArchetypeHint(`u1:${turnIndex}`, locale), + }); + const tables = turnIndex > 1 ? [buildTableRulesAddendum(locale)] : []; + return buildCoachToolRequest({ + systemPrompt, + toolModeAddendum: `${buildToolModeAddendum(locale)}\n\n${buildDialogAddenda( + locale, + { tableRules: false }, + )}`, + focusHint: "", + workoutEvidence: null, + dataInventory: "DATA INVENTORY\n- pulse", + guidedBlock: "", + transcript: `user: question ${turnIndex}`, + languageName: "English", + turnNotes: [...tables, turnNotes, ...hints] + .filter((block) => block.length > 0) + .join("\n\n"), + }); +} + +describe("stable Coach prefix (#1217)", () => { + it.each(["en", "de", "fr", "ko"] as Locale[])( + "hashes the system prompt identically on every turn (%s)", + (locale) => { + const hashes = new Set(); + for (let turn = 0; turn < 12; turn++) { + const request = turnRequest( + locale, + turn, + turn % 3 === 0 ? [`FOLLOW-UP: earlier answer ${turn}`] : [], + ); + hashes.add(sha(request.system)); + } + expect(hashes.size).toBe(1); + }, + ); + + it("carries the per-turn lines in the user message instead", () => { + const opener = openerArchetypeHint("u1:4", "en"); + const request = turnRequest("en", 4, ["FOLLOW-UP: the pulse table"]); + const user = String(request.messages[0]!.content); + expect(user).toContain(opener); + expect(user).toContain("FOLLOW-UP: the pulse table"); + expect(user).toContain(buildTableRulesAddendum("en")); + expect(request.system).not.toContain(opener); + expect(request.system).not.toContain("FOLLOW-UP"); + expect(request.system).not.toContain(buildTableRulesAddendum("en")); + }); + + it("keeps the opener hint and the name out of the system prompt", () => { + for (const locale of ["en", "de"] as Locale[]) { + const system = getCoachSystemPrompt(locale, undefined, null); + expect(system).not.toMatch( + /OPENER HINT for this turn|OPENER-HINWEIS für diesen Turn/, + ); + expect(system).not.toContain("Alex"); + } + }); + + it("names the turn's opener and, on a gated turn, the first name", () => { + const notes = buildCoachTurnNotes("en", { + firstName: "Alex", + mayUseName: true, + openerHint: "Lead with the finding.", + }); + expect(notes).toContain( + "OPENER HINT for this turn: Lead with the finding.", + ); + expect(notes).toContain('the user\'s first name is "Alex"'); + expect(buildCoachTurnNotes("en", {})).toBe(""); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts index d208abff1..c39c6f4b5 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts @@ -105,11 +105,24 @@ function lastParams() { const calls = runRawCompletionWithFallback.mock.calls; return calls[calls.length - 1][0].params as { system: string; + messages: Array<{ role: string; content: unknown }>; toolChoice?: string; tools?: unknown[]; }; } +/** + * The final round's reason, as the model reads it: the last user-side line. + * #1217 — it no longer rides the system prompt, which stays the cached + * prefix every round of the turn shares. + */ +function finalRoundLine(): string { + const last = lastParams().messages.at(-1); + expect(last?.role).toBe("user"); + expect(lastParams().system).toBe(baseArgs.system); + return String(last?.content); +} + beforeEach(() => { emitSignal.mockReset(); executeCoachTool.mockReset(); @@ -138,9 +151,7 @@ describe("stop reasons", () => { // The tools stay on the final round: the history holds the calls, and // Anthropic refuses tool_use/tool_result blocks without `tools` (400). expect(lastParams().tools).toEqual(COACH_TOOL_DEFS); - expect(lastParams().system).toMatch( - /FINAL ROUND: this turn's token budget/, - ); + expect(finalRoundLine()).toMatch(/FINAL ROUND: this turn's token budget/); expect(out.result.content).toBe("Answer from what I have."); }); @@ -209,7 +220,7 @@ describe("stop reasons", () => { expect(out.stop).toEqual({ reason: "no_progress", rounds: 3 }); expect(out.forcedFinal).toBe(false); expectFinalRoundKeepsTools(); - expect(lastParams().system).toMatch(/brought nothing new/); + expect(finalRoundLine()).toMatch(/brought nothing new/); }); }); diff --git a/src/lib/ai/coach/chat-request-builder.ts b/src/lib/ai/coach/chat-request-builder.ts index 1a892b46e..91ea7e03a 100644 --- a/src/lib/ai/coach/chat-request-builder.ts +++ b/src/lib/ai/coach/chat-request-builder.ts @@ -148,6 +148,13 @@ export function buildCoachToolRequest(args: { guidedBlock: string; transcript: string; languageName: string; + /** + * v1.43 (#1217) — what changes from one turn to the next: the opener hint, + * the name clause, a resolved follow-up, the table rules once a table + * exists. Carried here, on the user side, so `system` is the same string + * on every turn and every round. Empty or absent: nothing is added. + */ + turnNotes?: string; }): { system: string; messages: AiMessage[] } { const focusBlock = args.focusHint ? `${args.focusHint}\n\n` : ""; const workoutDataBlock = @@ -160,7 +167,7 @@ ${fenceHealthData(JSON.stringify({ thisWorkout: args.workoutEvidence }))} const messages: AiMessage[] = [ { role: "user", - content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock} + content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock}${args.turnNotes ? `\n\n${args.turnNotes}` : ""} CONVERSATION ${args.transcript} diff --git a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts index 780b1281f..1e392f059 100644 --- a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts +++ b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts @@ -99,7 +99,7 @@ describe("runCoachToolLoop — result tables", () => { expect(JSON.stringify(out.toolResults)).not.toContain("987654"); }); - it("adds the table rules to the rounds after the first table, not before", async () => { + it("adds the table rules to the rounds after the first table, not before, without touching the system prompt", async () => { executeCoachTool .mockResolvedValueOnce({ present: true, data: { n: 1 } }) .mockResolvedValueOnce({ @@ -113,16 +113,27 @@ describe("runCoachToolLoop — result tables", () => { name, arguments: '{"metric":"pulse"}', }); - runRawCompletionWithFallback - .mockResolvedValueOnce(round("", [call("a", "get_metric_series")])) - .mockResolvedValueOnce(round("", [call("b", "get_metric_table")])) - .mockResolvedValueOnce(round("answer")); + // The loop hands every round the same growing message array, so record + // what each round saw at the moment it was called. + const seen: Array<{ system: string; messages: unknown[] }> = []; + const replies = [ + round("", [call("a", "get_metric_series")]), + round("", [call("b", "get_metric_table")]), + round("answer"), + ]; + runRawCompletionWithFallback.mockImplementation(async (arg: unknown) => { + const { params } = arg as { + params: { system: string; messages: unknown[] }; + }; + seen.push({ system: params.system, messages: [...params.messages] }); + return replies[seen.length - 1]; + }); await runCoachToolLoop({ userId: "u1", providers: [], system: "sys", - systemOnceTableShown: "sys+tables", + tableRulesOnceShown: "TABLE RULES", messages: [{ role: "user", content: "q" }], tools: COACH_TOOL_DEFS, reach: UNBOUNDED_REACH, @@ -133,9 +144,26 @@ describe("runCoachToolLoop — result tables", () => { refs: createResultRefAllocator(), }, }); - const systems = runRawCompletionWithFallback.mock.calls.map( - ([arg]) => (arg as { params: { system: string } }).params.system, + // #1217 — one system prompt for every round, so the cached prefix holds. + expect(seen.map((round) => round.system)).toEqual(["sys", "sys", "sys"]); + const rulesIn = seen.map((round) => + round.messages.some( + (m) => + (m as { role: string; content: unknown }).role === "user" && + (m as { content: unknown }).content === "TABLE RULES", + ), + ); + expect(rulesIn).toEqual([false, false, true]); + // The rules follow the tool results that produced the table. + const last = seen[2]!.messages as Array<{ role: string }>; + expect(last.at(-1)).toEqual({ role: "user", content: "TABLE RULES" }); + expect(last.at(-2)?.role).toBe("tool"); + // Each round's messages extend the previous round's: a stable prefix. + expect(seen[1]!.messages.slice(0, seen[0]!.messages.length)).toEqual( + seen[0]!.messages, + ); + expect(seen[2]!.messages.slice(0, seen[1]!.messages.length)).toEqual( + seen[1]!.messages, ); - expect(systems).toEqual(["sys", "sys", "sys+tables"]); }); }); diff --git a/src/lib/ai/coach/snapshot-prelude.ts b/src/lib/ai/coach/snapshot-prelude.ts index 5acdedc8f..8a6b8ac30 100644 --- a/src/lib/ai/coach/snapshot-prelude.ts +++ b/src/lib/ai/coach/snapshot-prelude.ts @@ -36,16 +36,19 @@ import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types"; export const DAILY_TIMELINE_DAYS = 14; /** - * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is - * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per- - * token proxy and, if it exceeds this cap, progressively degrade the - * lowest-priority clusters (drop `timeline.recent`, then collapse the - * weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which - * sits comfortably inside every provider's context alongside the system - * prompt + history window. The daily token ledger (`budget.ts`) stays - * the per-day cost backstop; this is the per-prompt shape backstop. + * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is built we + * measure its compact JSON (`JSON.stringify(snapshot).length`, the form the + * prompt and every tool result ship) and, if it exceeds this cap, + * progressively degrade the lowest-priority clusters (drop + * `timeline.recent`, then collapse the weekly buckets) until it fits. The + * daily token ledger (`budget.ts`) stays the per-day cost backstop; this is + * the per-prompt shape backstop. + * + * v1.43 (#1217) — 24 000 pretty-printed characters became 14 000 compact + * ones. Compact JSON is ~0.42 to ~0.5 of the pretty form for these blocks, so + * everything that fitted before still fits: the same data, fewer tokens. */ -export const MAX_SNAPSHOT_CHARS = 24_000; +export const MAX_SNAPSHOT_CHARS = 14_000; /** * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 2dd46bbc6..04e37f92b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1128,7 +1128,7 @@ async function buildCoachSnapshotImpl( } return { - snapshotJson: JSON.stringify(compactSnapshot, null, 2), + snapshotJson: JSON.stringify(compactSnapshot), // v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by // domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool // executor slices a single domain block out of this so a retrieval tool @@ -1175,10 +1175,9 @@ export function degradeToBudget( cluster: CoachDataCluster; pass: number; }> = []; - // Measure against the SAME pretty-printed form the prompt ships - // (`JSON.stringify(snapshot, null, 2)`), not the compact form — - // otherwise the cap under-counts by ~2× and the prompt overflows. - const size = () => JSON.stringify(snapshot, null, 2).length; + // Measure against the SAME compact form the prompt ships + // (`JSON.stringify(snapshot)`), so the cap counts what the model reads. + const size = () => JSON.stringify(snapshot).length; if (size() <= MAX_SNAPSHOT_CHARS) return degraded; // Build a degrade order: blocks grouped by cluster, lowest priority diff --git a/src/lib/ai/coach/source-snapshot.ts b/src/lib/ai/coach/source-snapshot.ts index 723b94be5..ce9ae0bda 100644 --- a/src/lib/ai/coach/source-snapshot.ts +++ b/src/lib/ai/coach/source-snapshot.ts @@ -78,7 +78,7 @@ import type { } from "./types"; /** - * Above this pretty-printed size the section goes to the full builder, whose + * Above this compact size the section goes to the full builder, whose * budget pass may trim it (see the module comment). A 30-day section is a few * thousand characters; a year of blood pressure with its coarse tail is the * kind of section that crosses it. @@ -240,7 +240,7 @@ export async function buildCoachSourceSnapshot( const sections = compactSections(snapshot); annotateSnapshotFreshness(sections); - const size = JSON.stringify(sections, null, 2).length; + const size = JSON.stringify(sections).length; if (size > SOURCE_SNAPSHOT_FALLBACK_CHARS) { annotate({ action: { name: "coach.source_snapshot.full" }, diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 5ee5600fe..ee5a21713 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -1249,8 +1249,12 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record< /** * v1.22 (W6) — per-turn personalization passed by the chat route. All fields - * are optional and the addendum is omitted when none apply, so the prompt is - * byte-identical to the pre-feature one for the coverage test + unnamed users. + * are optional and the notes are empty when none apply. + * + * v1.43 (#1217) — these lines change every turn, so they no longer ride the + * system prompt: a system prompt that changed per turn rewrote the provider's + * cached prefix (tools plus system, 14 to 19 K tokens) on every turn. They + * are composed by {@link buildCoachTurnNotes} and travel in the user message. */ export interface CoachPersonalization { /** The user's first name; null when no display name is set. */ @@ -1261,11 +1265,17 @@ export interface CoachPersonalization { openerHint?: string | null; } +/** + * The Coach system prompt. Stable for one person across every turn and every + * round of a turn: it depends on the locale, the Coach preferences and the + * about-me text only, so the provider's prompt cache holds. Anything that + * changes per turn (the opener hint, the name clause, a resolved follow-up) + * goes into the user message instead. + */ export function getCoachSystemPrompt( locale: Locale, prefs: CoachPrefs = DEFAULT_COACH_PREFS, aboutMe: string | null = null, - personalization: CoachPersonalization = {}, ): string { let base: string; if (locale === "de") { @@ -1313,11 +1323,11 @@ export function getCoachSystemPrompt( // may only link a slug it lists, so a fabricated /learn URL is impossible. const withLearn = `${withPrefix}\n\n${learnCatalogPromptBlock()}`; // v1.22 (W6) — the narrative-quality addendum: the context-layer rule, the - // single-chart-token exception, the BP usual-range verbatim clause, plus the - // per-turn opener hint and sparse first-name personalization. Composed after - // the shared contracts so the chart-token exception can carve out the one - // place a `metric:` token is permitted. - const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale, personalization)}`; + // single-chart-token exception, the BP usual-range verbatim clause. Composed + // after the shared contracts so the chart-token exception can carve out the + // one place a `metric:` token is permitted. The per-turn opener hint + // and name clause are in `buildCoachTurnNotes`. + const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale)}`; // v1.15.20 — the user-authored "about me" self-description rides the // system prompt as a delimited, user-provided block (Settings → AI). const suffix = aboutMe ? buildAboutMeBlock(locale, aboutMe) : ""; @@ -1338,14 +1348,11 @@ export function getCoachSystemPrompt( * - BP USUAL RANGE — when the snapshot's blood-pressure block carries a * `usualRange`, quote THOSE bounds verbatim; never invent a band from window * means (kills the fabricated "153–150" range). - * - OPENER HINT — vary the turn's opening so a multi-turn session does not open - * identically every time. - * - NAME — sparse, anti-formulaic first-name address on the gated turns only. + * + * The per-turn opener hint and first-name clause that used to close this + * addendum are {@link buildCoachTurnNotes} now. */ -function buildCoachV122Addendum( - locale: Locale, - personalization: CoachPersonalization, -): string { +function buildCoachV122Addendum(locale: Locale): string { const isDe = instructionLocale(locale) === "de"; const parts: string[] = []; parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)"); @@ -1392,6 +1399,21 @@ function buildCoachV122Addendum( : `EXPERIMENT REVIEW — when the snapshot carries an "experimentOutcomes" block, read the result back in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`, ); + return parts.join("\n\n"); +} + +/** + * v1.43 (#1217) — the lines that change every Coach turn: the hash-seeded + * opener hint and, on the gated turns, the first-name clause. They travel in + * the user message so the system prompt stays byte-stable across turns and + * the provider's prompt cache holds. Empty when neither applies. + */ +export function buildCoachTurnNotes( + locale: Locale, + personalization: CoachPersonalization, +): string { + const isDe = instructionLocale(locale) === "de"; + const parts: string[] = []; if (personalization.openerHint) { parts.push( isDe @@ -1409,7 +1431,8 @@ function buildCoachV122Addendum( ); } - return parts.join("\n\n"); + if (parts.length === 0) return ""; + return [isDe ? "FÜR DIESEN TURN" : "FOR THIS TURN", ...parts].join("\n\n"); } /** diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 2a0c40a82..5fe032389 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -194,10 +194,13 @@ export async function runCoachToolLoop(args: { providers: ProviderChainResolved[]; system: string; /** - * v1.39.4 — the system prompt for the rounds after a call produced a - * table, when it differs (the table rules ride it). + * v1.39.4 — the table rules, for the rounds after a call produced a table. + * v1.43 (#1217) — they join the conversation as a user-side note after + * that round's tool results instead of switching the system prompt, so the + * cached prefix (tools plus system) survives the switch. Absent when the + * turn already carries them. */ - systemOnceTableShown?: string; + tableRulesOnceShown?: string; /** The conversation messages (history + the new user turn). */ messages: AiMessage[]; tools: AiToolDef[]; @@ -284,7 +287,12 @@ export async function runCoachToolLoop(args: { const cacheKey = coachPromptCacheKey(turn?.conversationId); const messages: AiMessage[] = [...args.messages]; - let system = args.system; + // v1.43 (#1217) — the system prompt never changes inside a turn: what used + // to be swapped or appended into it (the table rules, the final-round line) + // goes on the user side, so every round reads the same cached prefix. + const system = args.system; + let tableRulesPending = args.tableRulesOnceShown; + let tableShownThisRound = false; let totalTokens = 0; let cachedTokens = 0; let rounds = 0; @@ -352,10 +360,15 @@ export async function runCoachToolLoop(args: { surface: "coach", ledger, params: { - system: stopReason - ? `${system}\n\n${FINAL_ROUND_LINE[stopReason]}` - : system, - messages, + system, + // The final round says why it is final as the last user-side line, + // after everything the earlier rounds cached. + messages: stopReason + ? [ + ...messages, + { role: "user", content: FINAL_ROUND_LINE[stopReason] }, + ] + : messages, temperature, maxTokens, signal, @@ -522,9 +535,7 @@ export async function runCoachToolLoop(args: { // prose against that same summary. notify(() => onCallSettled?.(call, toolResult, index, round)); const { table, ...forModel } = toolResult; - if (table && args.systemOnceTableShown) { - system = args.systemOnceTableShown; - } + if (table) tableShownThisRound = true; const validArgs = call.name === COMPARE_SERIES_TOOL_NAME ? compareArgs(call.arguments) @@ -551,6 +562,14 @@ export async function runCoachToolLoop(args: { budget.addInput(textTokens(serialised)); messages.push({ role: "tool", toolCallId: call.id, content: serialised }); } + // The first table of the turn brings the table rules along, once, right + // after the results that produced it. + if (tableShownThisRound && tableRulesPending) { + messages.push({ role: "user", content: tableRulesPending }); + budget.addInput(textTokens(tableRulesPending)); + tableRulesPending = undefined; + } + tableShownThisRound = false; budget.addInput(textTokens(result.content ?? "")); notify(() => onRoundSettled?.(round)); diff --git a/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts new file mode 100644 index 000000000..1a90f4b14 --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts @@ -0,0 +1,136 @@ +/** + * #1217 — the turn model sends the same system prompt on every turn. The + * per-turn lines (opener hint, name clause, a resolved follow-up) reach the + * model on the user side, on the tool path and on the streaming no-tools + * path alike, so the provider's cached prefix survives from turn to turn. + */ +import { createHash } from "node:crypto"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + loop: vi.fn(), + stream: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn() })); +vi.mock("@/lib/ai/coach/memory/contract", () => ({ + buildMemoryContextBlock: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/tools/executor", async (importOriginal) => ({ + ...(await importOriginal()), + admittedPriorResults: vi.fn(async () => []), +})); +vi.mock("@/lib/ai/coach/tools", async (importOriginal) => ({ + ...(await importOriginal()), + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + probeScope: undefined, + })), + renderDataInventory: () => "DATA INVENTORY", + runCoachToolLoop: (args: unknown) => m.loop(args), +})); +vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({ + ...(await importOriginal()), + runStreamingRawCompletionWithFallback: (args: unknown) => m.stream(args), +})); + +import { runTurnModel } from "../model"; +import type { TurnContext } from "../context"; + +const sha = (text: string) => createHash("sha256").update(text).digest("hex"); + +function ctx(turnNotes: string): TurnContext { + return { + coachPrefs: {}, + reach: { kind: "unbounded" }, + aiResponseTimeoutMs: 60_000, + effectiveScope: undefined, + snapshot: { + snapshotJson: "{}", + sections: {}, + provenance: { windows: [], metrics: [], counts: {} }, + }, + aboutMe: null, + scheduleDoses: [], + medicationNames: [], + turnContext: { + allTurns: [], + window: [], + transcript: "user: hi", + guidedBlock: "", + historyElided: false, + isFirstTurn: true, + includeFullSnapshot: true, + }, + workoutEvidence: null, + systemPrompt: "STABLE SYSTEM", + userPrompt: "SNAPSHOT AND CONVERSATION", + turnNotes, + } as unknown as TurnContext; +} + +function run(turnNotes: string, turnHints: string[], toolMode: boolean) { + return runTurnModel({ + userId: "u1", + locale: "en", + signal: new AbortController().signal, + conversationId: "c1", + ctx: ctx(turnNotes), + chain: [] as never, + toolMode, + ledger: { + settleRound: vi.fn(async () => {}), + reserveRound: vi.fn(async () => true), + close: vi.fn(async () => {}), + } as never, + emitter: { emit: () => {}, aborted: () => false }, + turnHints, + message: "hi", + payer: "user", + conversationWindowSet: false, + }).catch(() => null); +} + +beforeEach(() => { + m.loop.mockReset().mockRejectedValue(new Error("stop after capture")); + m.stream.mockReset().mockRejectedValue(new Error("stop after capture")); +}); + +describe("runTurnModel keeps the system prompt stable (#1217)", () => { + it("on the tool path", async () => { + await run("OPENER HINT for this turn: A", [], true); + await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], true); + const [first, second] = m.loop.mock.calls.map( + ([args]) => + args as { system: string; messages: Array<{ content: string }> }, + ); + expect(sha(first!.system)).toBe(sha(second!.system)); + expect(first!.system).not.toContain("OPENER HINT"); + expect(second!.system).not.toContain("FOLLOW-UP"); + expect(first!.messages[0]!.content).toContain( + "OPENER HINT for this turn: A", + ); + expect(second!.messages[0]!.content).toContain("FOLLOW-UP: the table"); + }); + + it("on the streaming no-tools path", async () => { + await run("OPENER HINT for this turn: A", [], false); + await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], false); + const params = m.stream.mock.calls.map( + ([args]) => + ( + args as { + params: { system: string; messages: Array<{ content: string }> }; + } + ).params, + ); + expect(params[0]!.system).toBe("STABLE SYSTEM"); + expect(params[1]!.system).toBe("STABLE SYSTEM"); + const users = params.map((p) => JSON.stringify(p.messages)); + expect(users[0]).toContain("OPENER HINT for this turn: A"); + expect(users[1]).toContain("FOLLOW-UP: the table"); + }); +}); diff --git a/src/lib/ai/coach/turn/addenda/index.ts b/src/lib/ai/coach/turn/addenda/index.ts index 20f70cf04..6efb8ce3d 100644 --- a/src/lib/ai/coach/turn/addenda/index.ts +++ b/src/lib/ai/coach/turn/addenda/index.ts @@ -31,3 +31,16 @@ export function buildDialogAddenda( .filter((block) => block.length > 0) .join("\n\n"); } + +/** + * v1.43 (#1217) — the table rules alone (how a table was fetched, how to + * follow up on one). They apply once the conversation holds a table, which can + * change mid-turn, so they travel as a user-side note rather than switching the + * system prompt: a switched system prompt rewrites the provider's cached + * prefix on the costliest round of the turn. + */ +export function buildTableRulesAddendum(locale: Locale): string { + return [methodAddendum(locale), followUpsAddendum(locale)] + .filter((block) => block.length > 0) + .join("\n\n"); +} diff --git a/src/lib/ai/coach/turn/context.ts b/src/lib/ai/coach/turn/context.ts index b7e3ce1c0..d6dd8584e 100644 --- a/src/lib/ai/coach/turn/context.ts +++ b/src/lib/ai/coach/turn/context.ts @@ -13,7 +13,10 @@ import type { CoachScope } from "@/lib/ai/coach/types"; import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared"; import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { storeDeterministicFacts } from "@/lib/ai/coach/facts"; -import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { + buildCoachTurnNotes, + getCoachSystemPrompt, +} from "@/lib/ai/coach/system-prompt"; import { openerArchetypeHint, shouldUseNameForTurn, @@ -69,6 +72,11 @@ export interface TurnContext { workoutEvidence: Record | null; systemPrompt: string; userPrompt: string; + /** + * v1.43 (#1217) — the lines that change every turn (opener hint, name + * clause), for the user message. Empty when none apply. + */ + turnNotes: string; } export async function assembleTurnContext(args: { @@ -150,12 +158,11 @@ export async function assembleTurnContext(args: { firstName != null && shouldUseNameForTurn(`${userId}:${turnIndex}`), openerHint: openerArchetypeHint(`${userId}:${turnIndex}`, locale), }; - const baseSystemPrompt = getCoachSystemPrompt( - locale, - coachPrefs, - aboutMe, - coachPersonalization, - ); + // v1.43 (#1217) — the system prompt is stable per person; the per-turn + // personalization travels in the user message (`turnNotes`), so the + // provider's cached prefix survives from one turn to the next. + const baseSystemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); + const turnNotes = buildCoachTurnNotes(locale, coachPersonalization); const turnContext = buildCoachTurnContext({ priorTurns, priorSummary, @@ -240,5 +247,6 @@ export async function assembleTurnContext(args: { workoutEvidence, systemPrompt, userPrompt, + turnNotes, }; } diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index f8c7996f7..b39c3ffb1 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -80,7 +80,7 @@ import { type PriorResultTurn, } from "@/lib/ai/coach/results/refs"; -import { buildDialogAddenda } from "./addenda"; +import { buildDialogAddenda, buildTableRulesAddendum } from "./addenda"; import type { TurnLedger } from "./budget"; import type { TurnReasoning } from "./reasoning"; import type { TurnChain } from "./chain"; @@ -228,7 +228,7 @@ export function buildTurnResults( } } -/** v1.39.4 — server-authored lines appended to the system prompt. */ +/** v1.39.4 — server-authored lines appended to a prompt block. */ function appendBlocks(base: string, blocks: string[]): string { const extra = blocks.filter((block) => block.length > 0); return extra.length > 0 ? [base, ...extra].join("\n\n") : base; @@ -374,28 +374,35 @@ export async function runTurnModel(args: { reach: ctx.reach, prior: args.priorResults ?? [], }); - const requestWith = (tableRules: boolean) => - buildCoachToolRequest({ - systemPrompt: ctx.systemPrompt, - toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [ - buildDialogAddenda(locale, { tableRules }), - ...args.turnHints, - ]), - focusHint: renderFocusHint(effectiveScope?.sources), - workoutEvidence, - dataInventory: renderDataInventory(inventory), - priorResults: renderPriorResultRefs(priorResults), - guidedBlock: turnContext.guidedBlock, - transcript: turnContext.transcript, - languageName: LANGUAGE_NAMES[locale], - ...(memory ? { memoryBlock: memory.text } : {}), - }); - // The recheck and follow-up rules are about tables: they ride the - // prompt once the conversation holds one, or from the round after this - // turn produced its first. - const withTables = requestWith(true); - const toolRequest = - priorResults.length > 0 ? withTables : requestWith(false); + // The recheck and follow-up rules are about tables: they ride the turn + // once the conversation holds one, or from the round after this turn + // produced its first. v1.43 (#1217) — like every other line that can + // change from one turn or round to the next (the opener hint, the name + // clause, a resolved follow-up), they travel on the user side, so the + // system prompt and the tools stay one cached prefix for the whole + // conversation. + const tableRules = buildTableRulesAddendum(locale); + const toolRequest = buildCoachToolRequest({ + systemPrompt: ctx.systemPrompt, + toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [ + buildDialogAddenda(locale, { tableRules: false }), + ]), + focusHint: renderFocusHint(effectiveScope?.sources), + workoutEvidence, + dataInventory: renderDataInventory(inventory), + priorResults: renderPriorResultRefs(priorResults), + guidedBlock: turnContext.guidedBlock, + transcript: turnContext.transcript, + languageName: LANGUAGE_NAMES[locale], + turnNotes: [ + ...(priorResults.length > 0 ? [tableRules] : []), + ctx.turnNotes, + ...args.turnHints, + ] + .filter((block) => block.length > 0) + .join("\n\n"), + ...(memory ? { memoryBlock: memory.text } : {}), + }); // v1.39.4 — every call this turn settled, by its turn-wide index, for // the result tables. Only this turn's own calls ever reach them. const settled: SettledToolCall[] = []; @@ -420,7 +427,7 @@ export async function runTurnModel(args: { userId, providers: chain, system: toolRequest.system, - systemOnceTableShown: withTables.system, + ...(priorResults.length > 0 ? {} : { tableRulesOnceShown: tableRules }), messages: toolRequest.messages, tools: COACH_TURN_TOOL_DEFS, temperature: AI_BUDGETS.coach.temperature, @@ -631,10 +638,18 @@ export async function runTurnModel(args: { }); const summaries: string[] = []; // v1.41 — the memory block rides the user turn here too, ahead of the - // snapshot, fenced like the rest of the person's own context. - const user = memory - ? `${memory.text}\n\n${ctx.userPrompt}` - : ctx.userPrompt; + // snapshot, fenced like the rest of the person's own context. v1.43 + // (#1217) — so do this turn's own lines (opener hint, name clause, a + // resolved follow-up), which used to ride the system prompt and rewrote + // its cached prefix on every turn. + const user = [ + memory?.text, + ctx.turnNotes, + ...args.turnHints, + ctx.userPrompt, + ] + .filter((block): block is string => Boolean(block)) + .join("\n\n"); const fallback = await runStreamingRawCompletionWithFallback({ surface: "coach", userId, @@ -644,7 +659,7 @@ export async function runTurnModel(args: { }, params: { ...singleUserTurn({ - system: appendBlocks(ctx.systemPrompt, args.turnHints), + system: ctx.systemPrompt, user, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, diff --git a/src/lib/ai/reasoning/__tests__/controls.test.ts b/src/lib/ai/reasoning/__tests__/controls.test.ts index 11f6a196f..3e574a3c7 100644 --- a/src/lib/ai/reasoning/__tests__/controls.test.ts +++ b/src/lib/ai/reasoning/__tests__/controls.test.ts @@ -5,8 +5,9 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { row } = vi.hoisted(() => ({ +const { row, user } = vi.hoisted(() => ({ row: { current: null as unknown, fail: false }, + user: { level: null as string | null }, })); vi.mock("@/lib/db", () => ({ @@ -17,6 +18,9 @@ vi.mock("@/lib/db", () => ({ return row.current; }), }, + user: { + findUnique: vi.fn(async () => ({ aiUsageLevel: user.level })), + }, }, })); vi.mock("@/lib/logging/context", () => ({ @@ -27,11 +31,13 @@ vi.mock("@/lib/logging/context", () => ({ import { loadReasoningControls, resolveCoachTurnReasoningLevel, + resolveJobReasoning, } from "../controls"; beforeEach(() => { row.current = null; row.fail = false; + user.level = null; }); describe("loadReasoningControls", () => { @@ -88,3 +94,41 @@ describe("resolveCoachTurnReasoningLevel", () => { ); }); }); + +// #1217 — background reasoning per AI usage level. +describe("resolveJobReasoning by usage level", () => { + const chain = [{ providerType: "openai" as const }]; + + it("asks for nothing on frugal", async () => { + user.level = "FRUGAL"; + row.current = { aiUsageMaxLevel: "FULL", aiUsageDefaultLevel: "STANDARD" }; + expect( + await resolveJobReasoning("daily_briefing", chain, "u1"), + ).toBeUndefined(); + }); + + it.each(["STANDARD", "FULL"])( + "keeps the job's level on %s", + async (level) => { + user.level = level; + row.current = { + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "STANDARD", + }; + expect(await resolveJobReasoning("daily_briefing", chain, "u1")).toEqual({ + effort: "medium", + summaries: false, + }); + }, + ); + + it("follows a frugal instance default for an account without a choice", async () => { + row.current = { + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "FRUGAL", + }; + expect( + await resolveJobReasoning("daily_briefing", chain, "u1"), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/ai/reasoning/controls.ts b/src/lib/ai/reasoning/controls.ts index 9751573ec..7b1420c36 100644 --- a/src/lib/ai/reasoning/controls.ts +++ b/src/lib/ai/reasoning/controls.ts @@ -13,6 +13,11 @@ import { isOperatorFundedProvider } from "@/lib/ai/coach/budget"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; import type { CompletionParams } from "@/lib/ai/types"; +import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage"; +import { + AI_USAGE_LEVEL_EFFECTS, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; import { coachReasoningLevel, type CoachPrefs, @@ -167,9 +172,33 @@ export async function loadCoachReasoningState(args: { export async function resolveJobReasoning( job: BackgroundReasoningJob, chain: ReadonlyArray<{ providerType: ProviderChainType }>, + /** + * v1.43 (#1217) — the record the job runs for. On the `frugal` AI usage + * level background jobs do not reason at all; the call goes out as it did + * before reasoning existed. Absent: no level applies. + */ + recordId?: string, ): Promise { // Reasoning is an improvement to a job, never a precondition for it: any // failure here sends the call exactly as it went before reasoning existed. + if (recordId !== undefined) { + let level: AiUsageLevel; + try { + level = await loadRecordUsageLevel(recordId); + } catch { + return undefined; + } + if (!AI_USAGE_LEVEL_EFFECTS[level].backgroundReasoning) { + annotate({ + meta: { + ai_reasoning_job: job, + ai_reasoning_effort: "off", + ai_reasoning_usage_level: level, + }, + }); + return undefined; + } + } let resolved; try { resolved = resolveReasoning({ diff --git a/src/lib/ai/usage-level.ts b/src/lib/ai/usage-level.ts new file mode 100644 index 000000000..e81673b15 --- /dev/null +++ b/src/lib/ai/usage-level.ts @@ -0,0 +1,311 @@ +/** + * v1.43 (#1217) — AI usage in three levels. + * + * One setting per account instead of many knobs. The operator sets the highest + * level an account may choose (`maxLevel`) and the level an account without a + * choice runs at (`defaultLevel`), since the operator pays for the shared key; + * each person picks within that. A level decides how much background model + * work the account asks for. It never decides what data is stored or shown, + * and the interactive Coach is the same at every level (its own budget caps + * apply as before). + * + * This file is the vocabulary: the levels, what each one does, the resolver + * and the estimate. Pure, no I/O, so the account payload, the jobs, the + * routes, the admin readout and the tests all read the same table. The reads + * live in `./capabilities/usage.ts`. + * + * Wire spelling is lower case (`frugal` / `standard` / `full`); the database + * enum (`AiUsageLevel`) is upper case. {@link AI_USAGE_LEVELS} is the ceiling + * order: a level is allowed when it is at or below `maxLevel`. + */ +import { + AI_USAGE_LEVELS, + type AiUsageLevelWire, +} from "@/lib/validations/ai-usage"; + +export { AI_USAGE_LEVELS }; +export type AiUsageLevel = AiUsageLevelWire; + +/** + * Where the effective level comes from: + * + * - `user`: the account's own stored choice; + * - `default`: no stored choice, so the operator's default; + * - `admin_cap`: a stored choice above the operator's ceiling, capped at + * read time. The choice itself is kept, so raising the ceiling again + * restores it. + */ +export const AI_USAGE_SOURCES = ["user", "default", "admin_cap"] as const; +export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number]; + +/** What one level does. Every effect a level has is a row here. */ +export interface AiUsageLevelEffect { + /** + * Minimum hours between two status-card regenerations of one scope after a + * sync (a watch, an integration). `"instance"` reads the operator's sync + * gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`, + * else six). + */ + syncRegenGapHours: number | "instance"; + /** The same gap after a hand entry. */ + manualRegenGapHours: number; + /** + * The status snapshot's char cap, measured on the compact JSON the prompt + * ships (`degradeStatusSnapshotToBudget`). Fourteen thousand holds every + * snapshot that fitted the old 24 000 pretty-printed characters. + */ + statusSnapshotChars: number; + /** + * Whether the background jobs that may reason (the briefing, the period + * narratives, the Coach memory) ask for it. When true the job's own level + * applies (`BACKGROUND_REASONING_LEVEL`), still capped by the operator. + */ + backgroundReasoning: boolean; + /** + * The daily briefing's form. `short`: the lead plus today, at most one + * recommendation; every field of the contract stays present, so a client + * decodes it unchanged. + */ + briefing: "full" | "short"; + /** + * Whether the account's own "read documents automatically" setting may + * act. When false, documents are read on request only. + */ + documentsAutoRead: boolean; + /** + * Relative token use, `full` = 1. Scales the measured use of the effective + * level into an estimate for another level. Calibrated with the generation + * simulation of a watch-synced account + * (`src/lib/insights/__tests__/usage-level-simulation.test.ts`): status-card + * generations a day, the nightly warm included, are 141 on `full`, 44 on + * `standard` (0.31) and 21 on `frugal` (0.15, before its shorter briefing + * and switched-off reasoning). The Coach is part of the measured use and + * does not scale, so the result is published and shown as an estimate. + */ + tokenFactor: number; +} + +export const AI_USAGE_LEVEL_EFFECTS: Readonly< + Record> +> = Object.freeze({ + frugal: { + syncRegenGapHours: 24, + manualRegenGapHours: 6, + statusSnapshotChars: 10_000, + backgroundReasoning: false, + briefing: "short", + documentsAutoRead: false, + tokenFactor: 0.15, + }, + standard: { + syncRegenGapHours: "instance", + manualRegenGapHours: 1, + statusSnapshotChars: 14_000, + backgroundReasoning: true, + briefing: "full", + documentsAutoRead: true, + tokenFactor: 0.3, + }, + full: { + syncRegenGapHours: 1, + manualRegenGapHours: 1, + statusSnapshotChars: 14_000, + backgroundReasoning: true, + briefing: "full", + documentsAutoRead: true, + tokenFactor: 1, + }, +}); + +/** The instance's own defaults, before an operator changes anything. */ +export const DEFAULT_AI_USAGE_MAX_LEVEL: AiUsageLevel = "standard"; +export const DEFAULT_AI_USAGE_DEFAULT_LEVEL: AiUsageLevel = "standard"; + +// ── Order ─────────────────────────────────────────────────────────────────── + +export function usageLevelRank(level: AiUsageLevel): number { + return AI_USAGE_LEVELS.indexOf(level); +} + +/** Whether `level` is at or above `min`. */ +export function usageLevelAtLeast( + level: AiUsageLevel, + min: AiUsageLevel, +): boolean { + return usageLevelRank(level) >= usageLevelRank(min); +} + +/** `level`, lowered to `max` when it lies above it. */ +export function capUsageLevel( + level: AiUsageLevel, + max: AiUsageLevel, +): AiUsageLevel { + return usageLevelRank(level) > usageLevelRank(max) ? max : level; +} + +export function isAiUsageLevel(value: unknown): value is AiUsageLevel { + return ( + typeof value === "string" && + (AI_USAGE_LEVELS as readonly string[]).includes(value) + ); +} + +// ── Database spelling ─────────────────────────────────────────────────────── + +export type AiUsageLevelDb = "FRUGAL" | "STANDARD" | "FULL"; + +export function usageLevelFromDb( + value: string | null | undefined, +): AiUsageLevel | null { + if (value == null) return null; + const lower = value.toLowerCase(); + return isAiUsageLevel(lower) ? lower : null; +} + +export function usageLevelToDb(level: AiUsageLevel): AiUsageLevelDb { + return level.toUpperCase() as AiUsageLevelDb; +} + +// ── Resolution ────────────────────────────────────────────────────────────── + +export interface AiUsageBounds { + /** The account's stored choice; `null` follows the default. */ + preference: AiUsageLevel | null; + defaultLevel: AiUsageLevel; + maxLevel: AiUsageLevel; +} + +export interface AiUsageResolution { + level: AiUsageLevel; + source: AiUsageSource; +} + +/** + * The level in effect. A stored choice at or below the ceiling wins; above it + * the ceiling applies and the choice is kept (`admin_cap`). Without a choice + * the default applies, held to the ceiling too, so a default stored above + * the ceiling (the admin route refuses one, an old row might hold one) can + * never lift an account over it. + */ +export function resolveAiUsageLevel(bounds: AiUsageBounds): AiUsageResolution { + const { preference, defaultLevel, maxLevel } = bounds; + if (preference === null) { + return { level: capUsageLevel(defaultLevel, maxLevel), source: "default" }; + } + if (usageLevelRank(preference) > usageLevelRank(maxLevel)) { + return { level: maxLevel, source: "admin_cap" }; + } + return { level: preference, source: "user" }; +} + +// ── Effects ───────────────────────────────────────────────────────────────── + +/** Where a batch of fresh measurements came from (`status-invalidation.ts`). */ +export type IngestSource = "manual" | "sync"; + +/** Default sync gap when neither the operator nor the environment sets one. */ +export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6; +export const SYNC_REGEN_GAP_HOURS_MIN = 1; +export const SYNC_REGEN_GAP_HOURS_MAX = 48; + +/** A sync gap in whole hours, or `null` when the value is not a usable one. */ +export function parseSyncRegenGapHours(value: unknown): number | null { + const n = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : NaN; + if (!Number.isInteger(n)) return null; + if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null; + return n; +} + +/** + * Hours a scope waits after its last regeneration before an ingest of this + * source may regenerate it again. + * + * @param instanceSyncGapHours The operator's resolved sync gap, read by + * `standard`. + */ +export function statusRegenGapHours( + level: AiUsageLevel, + source: IngestSource, + instanceSyncGapHours: number, +): number { + const effect = AI_USAGE_LEVEL_EFFECTS[level]; + if (source === "manual") return effect.manualRegenGapHours; + return effect.syncRegenGapHours === "instance" + ? instanceSyncGapHours + : effect.syncRegenGapHours; +} + +// ── Estimate ──────────────────────────────────────────────────────────────── + +/** Full days behind the measured mean, at most. */ +export const AI_USAGE_MEASURE_DAYS = 7; +/** Days of history an estimate needs. */ +export const AI_USAGE_ESTIMATE_MIN_DAYS = 3; + +/** + * Tokens per day at `target`, scaled from what the account used at `from`. + * `null` without enough history. + */ +export function estimateTokensPerDay(args: { + measuredTokensPerDay: number | null; + measuredDays: number; + from: AiUsageLevel; + target: AiUsageLevel; +}): number | null { + if (args.measuredTokensPerDay === null) return null; + if (args.measuredDays < AI_USAGE_ESTIMATE_MIN_DAYS) return null; + const ratio = + AI_USAGE_LEVEL_EFFECTS[args.target].tokenFactor / + AI_USAGE_LEVEL_EFFECTS[args.from].tokenFactor; + return Math.round(args.measuredTokensPerDay * ratio); +} + +/** `ai.usage` on `GET /api/auth/me` (OpenAPI `AiUsageState`). */ +export interface AiUsageState { + level: AiUsageLevel; + preference: AiUsageLevel | null; + defaultLevel: AiUsageLevel; + maxLevel: AiUsageLevel; + source: AiUsageSource; + options: Array<{ + level: AiUsageLevel; + allowed: boolean; + estimatedTokensPerDay: number | null; + }>; + measuredTokensPerDay: number | null; + measuredDays: number; +} + +/** The whole published state, from the bounds and the measured use. */ +export function buildAiUsageState( + args: AiUsageBounds & { + measuredTokensPerDay: number | null; + measuredDays: number; + }, +): AiUsageState { + const { level, source } = resolveAiUsageLevel(args); + return { + level, + preference: args.preference, + defaultLevel: args.defaultLevel, + maxLevel: args.maxLevel, + source, + options: AI_USAGE_LEVELS.map((option) => ({ + level: option, + allowed: usageLevelRank(option) <= usageLevelRank(args.maxLevel), + estimatedTokensPerDay: estimateTokensPerDay({ + measuredTokensPerDay: args.measuredTokensPerDay, + measuredDays: args.measuredDays, + from: level, + target: option, + }), + })), + measuredTokensPerDay: args.measuredTokensPerDay, + measuredDays: args.measuredDays, + }; +} diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d2af8a35f..2d46fa5c1 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -180,6 +180,9 @@ const fakeDb = vi.hoisted(() => { encounter: { findMany: none }, // The AI capability loader's consent read: no receipts. consentReceipt: { findMany: none }, + // v1.43 — the loader's usage-level read: no stored choice, so the + // instance default applies. + user: { findUnique: async () => ({ aiUsageLevel: null }) }, }; }); diff --git a/src/lib/documents/__tests__/document-settings.test.ts b/src/lib/documents/__tests__/document-settings.test.ts new file mode 100644 index 000000000..52ab86b53 --- /dev/null +++ b/src/lib/documents/__tests__/document-settings.test.ts @@ -0,0 +1,64 @@ +/** + * #1217 — the automatic document read acts only while both the person's + * switch is on and their AI usage level allows it. On `frugal` documents are + * read on request only; the stored switch is left alone. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + user: { documentsAutoAiRead: true, aiUsageLevel: null as string | null }, + settings: { + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "STANDARD", + } as Record | null, + failSettings: false, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => db.user) }, + appSettings: { + findUnique: vi.fn(async () => { + if (db.failSettings) throw new Error("db down"); + return db.settings; + }), + }, + }, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => undefined, +})); + +import { documentAutoReadEnabled } from "../document-settings"; + +beforeEach(() => { + db.user = { documentsAutoAiRead: true, aiUsageLevel: null }; + db.settings = { aiUsageMaxLevel: "FULL", aiUsageDefaultLevel: "STANDARD" }; + db.failSettings = false; +}); + +describe("documentAutoReadEnabled", () => { + it.each([ + ["FRUGAL", false], + ["STANDARD", true], + ["FULL", true], + [null, true], + ])("level %s with the switch on reads %s", async (level, expected) => { + db.user = { documentsAutoAiRead: true, aiUsageLevel: level }; + expect(await documentAutoReadEnabled("u1")).toBe(expected); + }); + + it("stays off with the switch off, whatever the level", async () => { + db.user = { documentsAutoAiRead: false, aiUsageLevel: "FULL" }; + expect(await documentAutoReadEnabled("u1")).toBe(false); + }); + + it("rests on a frugal instance default", async () => { + db.settings = { + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "FRUGAL", + }; + expect(await documentAutoReadEnabled("u1")).toBe(false); + }); +}); diff --git a/src/lib/documents/document-settings.ts b/src/lib/documents/document-settings.ts index e07aeafd5..44731faaf 100644 --- a/src/lib/documents/document-settings.ts +++ b/src/lib/documents/document-settings.ts @@ -12,13 +12,21 @@ * - the summary job and its catch-up pass run only while it is ON; * - the auto-index-on-upload job stays strictly local when it is OFF and uses * the document-order external pick when it is ON. + * + * v1.43 (#1217) — on the `frugal` AI usage level the setting rests: documents + * are read on request only, whatever the switch says, and the switch acts + * again as soon as the level allows it. The stored choice is never changed. */ import { prisma } from "@/lib/db"; +import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage"; +import { AI_USAGE_LEVEL_EFFECTS } from "@/lib/ai/usage-level"; /** - * True when the user has opted into automatic AI reading of uploaded documents. - * A missing row (deleted account raced against a job) resolves to `false` — - * fail-closed to the local-first posture, never to external egress. + * True when the user has opted into automatic AI reading of uploaded documents + * and their AI usage level lets the opt-in act. A missing row (deleted account + * raced against a job) resolves to `false` — fail-closed to the local-first + * posture, never to external egress; so does a usage level that cannot be + * read. */ export async function documentAutoReadEnabled( userId: string, @@ -27,5 +35,11 @@ export async function documentAutoReadEnabled( where: { id: userId }, select: { documentsAutoAiRead: true }, }); - return row?.documentsAutoAiRead ?? false; + if (!(row?.documentsAutoAiRead ?? false)) return false; + try { + const level = await loadRecordUsageLevel(userId); + return AI_USAGE_LEVEL_EFFECTS[level].documentsAutoRead; + } catch { + return false; + } } diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index d25ac2487..c636b3071 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -900,6 +900,7 @@ export async function upsertFitbitMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `fitbit: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 449efbff2..84f1f84e5 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -129,7 +129,9 @@ export async function syncUserFitbit( // the aggregator's `< to` upper bound covers it. const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: "sync", + }).catch((err) => { getEvent()?.addWarning( `fitbit: status-insight invalidate failed for ${userId}: ${err}`, ); diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 9bf3bf29c..4940e43ea 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -1606,6 +1606,7 @@ export async function upsertGoogleHealthMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `google-health: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 85ba41422..0caff675a 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -370,7 +370,9 @@ async function runGoogleHealthSyncCycle( const from = new Date(sorted[0]!); const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: "sync", + }).catch((err) => { getEvent()?.addWarning( `google-health: status-insight invalidate failed for ${userId}: ${err}`, ); diff --git a/src/lib/insights/__tests__/briefing-short-form.test.ts b/src/lib/insights/__tests__/briefing-short-form.test.ts new file mode 100644 index 000000000..394757fa6 --- /dev/null +++ b/src/lib/insights/__tests__/briefing-short-form.test.ts @@ -0,0 +1,123 @@ +/** + * #1217 — the briefing's short form on the `frugal` usage level. The contract + * keeps its shape on every level: `recommendations` stays present (the + * native client decodes it as a required array), only shorter on `frugal`, + * and untouched on `standard` and `full`. + */ +import { describe, expect, it } from "vitest"; + +import { aiInsightResponseSchema } from "@/lib/ai/schema"; +import { AI_USAGE_LEVEL_EFFECTS, AI_USAGE_LEVELS } from "@/lib/ai/usage-level"; +import { + SHORT_FORM_LIMITS, + applyBriefingShortForm, + buildBriefingShortFormBlock, +} from "@/lib/insights/briefing-short-form"; + +function rec(id: string) { + return { + id, + text: `Recommendation ${id}`, + severity: "suggestion", + metricSource: { + type: "bloodPressure", + timeRange: "last30days", + summary: "avg 128/82", + }, + rationale: { + dataWindow: "last30days", + comparedTo: "your 90-day median", + deviation: "+4 mmHg", + }, + }; +} + +function finding(n: number) { + return { + tone: "info", + headline: `Finding ${n}`, + detail: "One sentence.", + delta: null, + sourceWindow: "30d", + sourceMetric: "bp", + }; +} + +const PAYLOAD = { + summary: "A steady day.", + recommendations: [rec("rec-1"), rec("rec-2"), rec("rec-3")], + citations: [ + { type: "bloodPressure", timeRange: "last30days", summary: "avg 128/82" }, + ], + warnings: [], + dailyBriefing: { + paragraph: "Today reads like a steady day.", + keyFindings: [finding(1), finding(2), finding(3), finding(4)], + }, + trendAnnotations: { bp: "Steady over the month." }, + storyboardAnnotations: [], +}; + +describe("briefing form per usage level", () => { + it("keeps the full contract on standard and full, the short form on frugal", () => { + expect( + AI_USAGE_LEVELS.map((level) => AI_USAGE_LEVEL_EFFECTS[level].briefing), + ).toEqual(["short", "full", "full"]); + }); + + it("adds nothing to the prompt for the full form", () => { + expect(buildBriefingShortFormBlock("en", "full")).toBe(""); + expect(buildBriefingShortFormBlock("de", "full")).toBe(""); + }); + + it("asks for at most one recommendation on the short form, field kept", () => { + const en = buildBriefingShortFormBlock("en", "short"); + expect(en).toContain('"recommendations": at most 1'); + expect(en).toContain("every required field"); + expect(buildBriefingShortFormBlock("de", "short")).toContain( + '"recommendations": höchstens 1', + ); + }); +}); + +describe("applyBriefingShortForm", () => { + it("validates against the briefing schema before and after", () => { + expect(aiInsightResponseSchema.safeParse(PAYLOAD).success).toBe(true); + const short = applyBriefingShortForm(PAYLOAD); + const parsed = aiInsightResponseSchema.safeParse(short); + expect(parsed.success).toBe(true); + }); + + it("keeps recommendations present, at most one", () => { + const short = applyBriefingShortForm(PAYLOAD); + expect(Array.isArray(short.recommendations)).toBe(true); + expect(short.recommendations).toHaveLength( + SHORT_FORM_LIMITS.recommendations, + ); + expect(short.recommendations[0]!.id).toBe("rec-1"); + expect(short.dailyBriefing.keyFindings).toHaveLength( + SHORT_FORM_LIMITS.keyFindings, + ); + expect(short).not.toHaveProperty("trendAnnotations"); + expect(short).not.toHaveProperty("storyboardAnnotations"); + // Every required field of the contract is still there. + for (const key of ["summary", "recommendations", "citations", "warnings"]) { + expect(short).toHaveProperty(key); + } + }); + + it("keeps an empty recommendation list an empty list", () => { + const short = applyBriefingShortForm({ ...PAYLOAD, recommendations: [] }); + expect(short.recommendations).toEqual([]); + }); + + it("leaves the input untouched", () => { + const before = structuredClone(PAYLOAD); + applyBriefingShortForm(PAYLOAD); + expect(PAYLOAD).toEqual(before); + }); + + it("passes a non-object through", () => { + expect(applyBriefingShortForm(null)).toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/compact-snapshot.test.ts b/src/lib/insights/__tests__/compact-snapshot.test.ts new file mode 100644 index 000000000..3dbfd2c86 --- /dev/null +++ b/src/lib/insights/__tests__/compact-snapshot.test.ts @@ -0,0 +1,165 @@ +/** + * #1217 — the status snapshots travel as compact JSON, and the char cap was + * lowered with the switch so the model sees at least the same data for fewer + * tokens, never less. + * + * Two properties pin that: + * 1. compact JSON parses back to exactly the object the pretty form did, so + * dropping the indentation changes no content; + * 2. every snapshot (and every shed stage of one) that fitted the old cap, + * 24 000 pretty-printed characters, fits the new compact cap, so the + * shedding never starts earlier than it used to. + */ +import { describe, expect, it } from "vitest"; + +import { + MAX_SNAPSHOT_CHARS, + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; + +/** The cap before v1.43, measured on `JSON.stringify(snapshot, null, 2)`. */ +const PRETTY_CAP_BEFORE = 24_000; + +const NOW = new Date("2026-10-15T12:00:00Z"); + +function series(days: number, perDay: number, base: number, seed: number) { + const points: Array<{ measuredAt: Date; value: number }> = []; + let x = seed; + for (let d = 0; d < days; d++) { + for (let k = 0; k < perDay; k++) { + x = (x * 9301 + 49297) % 233280; + points.push({ + measuredAt: new Date(NOW.getTime() - d * 86_400_000 - k * 3_600_000), + value: Math.round((base + (x / 233280) * base * 0.2) * 10) / 10, + }); + } + } + return buildGradedSeriesFromPoints(points, NOW, "Europe/Berlin"); +} + +/** A general-status-shaped snapshot over `metrics` series of `days` days. */ +function snapshot(metrics: number, days: number): Record { + const measurementSeries: Record = {}; + for (let i = 0; i < metrics; i++) { + measurementSeries[`METRIC_${i}`] = { + unit: i % 2 === 0 ? "bpm" : "kg", + summary: { points: days * 2, mean: 61.2, min: 50, max: 80, latest: 63 }, + series: series(days, 2, 50 + i * 10, i + 1), + }; + } + return { + locale: "de", + generatedForDay: "2026-10-15", + interpretationHint: + "Use trend direction and deltas. Prioritize the newest data if trends conflict.", + dataCoverage: { + totalMeasurements: days * 2 * metrics, + totalSpanDays: days, + newestMeasurementDaysAgo: 0, + }, + measurementSeries, + correlations: Array.from({ length: 40 }, (_, i) => ({ + a: `METRIC_${i % metrics}`, + b: `METRIC_${(i + 1) % metrics}`, + r: Math.round(((i % 7) / 10 - 0.3) * 100) / 100, + n: 30 + i, + })), + }; +} + +const FIXTURES: Array<[number, number]> = []; +for (const metrics of [1, 2, 3, 4, 6, 8, 10, 16]) { + for (const days of [14, 30, 90, 200, 400, 1000]) { + FIXTURES.push([metrics, days]); + } +} + +/** The shed stages `degradeStatusSnapshotToBudget` walks, in order. */ +function stages(snap: Record): Record[] { + const out: Record[] = [structuredClone(snap)]; + const drop = (node: unknown, field: string): void => { + if (Array.isArray(node)) return node.forEach((n) => drop(n, field)); + if (node && typeof node === "object") { + const obj = node as Record; + for (const key of Object.keys(obj)) { + if (key === field && Array.isArray(obj[key])) delete obj[key]; + else drop(obj[key], field); + } + } + }; + const tails = (node: unknown): void => { + if (Array.isArray(node)) return node.forEach(tails); + if (node && typeof node === "object") { + const obj = node as Record; + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (Array.isArray(value) && key !== "recent" && value.length > 30) { + obj[key] = value.slice(-30); + } + tails(obj[key]); + } + } + }; + let current = structuredClone(snap); + drop(current, "yearly"); + out.push(structuredClone(current)); + drop(current, "weekly"); + out.push(structuredClone(current)); + tails(current); + out.push(structuredClone(current)); + current = structuredClone(current); + drop(current, "recent"); + out.push(current); + return out; +} + +describe("compact status snapshots (#1217)", () => { + it.each(FIXTURES)( + "parses back to the same object (%i metrics, %i days)", + (metrics, days) => { + const snap = snapshot(metrics, days); + const compact = JSON.stringify(snap); + expect(JSON.parse(compact)).toEqual( + JSON.parse(JSON.stringify(snap, null, 2)), + ); + expect(compact.length).toBeLessThan(JSON.stringify(snap, null, 2).length); + }, + ); + + it("fits every stage that fitted the old pretty cap under the new compact cap", () => { + let checked = 0; + for (const [metrics, days] of FIXTURES) { + for (const stage of stages(snapshot(metrics, days))) { + if (JSON.stringify(stage, null, 2).length > PRETTY_CAP_BEFORE) continue; + checked += 1; + expect(JSON.stringify(stage).length).toBeLessThanOrEqual( + MAX_SNAPSHOT_CHARS, + ); + } + } + // The property is only worth something if it saw real cases near the cap. + expect(checked).toBeGreaterThan(40); + }); + + it("sheds nothing from a snapshot that fitted before", () => { + let fitted = 0; + for (const [metrics, days] of FIXTURES) { + const snap = snapshot(metrics, days); + if (JSON.stringify(snap, null, 2).length > PRETTY_CAP_BEFORE) continue; + fitted += 1; + const before = structuredClone(snap); + expect(degradeStatusSnapshotToBudget(snap)).toEqual([]); + expect(snap).toEqual(before); + } + expect(fitted).toBeGreaterThan(5); + }); + + it("still sheds a snapshot far over the cap, coarsest-last", () => { + const snap = snapshot(16, 1000); + const size = JSON.stringify(snap).length; + const shed = degradeStatusSnapshotToBudget(snap); + expect(shed[0]).toBe("yearly"); + expect(JSON.stringify(snap).length).toBeLessThan(size); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 5eb22ab81..55a5865fd 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -350,7 +350,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(outcome).toEqual({ status: "generated", providerType: "openai" }); const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]); - expect(prompt).toContain('\\"unit\\": \\"lb\\"'); + expect(prompt).toContain('\\"unit\\":\\"lb\\"'); expect(prompt).toContain("179.5"); expect(prompt).not.toContain("81.4"); @@ -495,3 +495,83 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); }); }); + +// #1217 — the frugal usage level asks for the short briefing and holds its +// counts; the fingerprint carries the form so a level change regenerates. +describe("generateComprehensiveInsight — usage level", () => { + const reply = { + summary: "s", + recommendations: ["a", "b", "c"].map((id) => ({ + id, + text: `Keep walking ${id}`, + severity: "info", + })), + citations: [], + warnings: [], + dailyBriefing: { paragraph: "p" }, + }; + + function arrange(aiUsageLevel: string | null) { + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, + aiUsageLevel, + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify(reply), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + } + + function storedPayload() { + const write = userUpdate.mock.calls.find( + (c) => + (c[0] as { data: Record }).data.insightsCachedText !== + undefined, + ); + const data = (write![0] as { data: Record }).data; + return { + payload: JSON.parse(data.insightsCachedText as string), + hash: data.insightsSnapshotHash, + }; + } + + it("asks a frugal account for the short form and keeps one recommendation", async () => { + arrange("FRUGAL"); + const outcome = await generateComprehensiveInsight("u1", { + locale: "en", + force: true, + }); + expect(outcome.status).toBe("generated"); + const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]); + expect(prompt).toContain("SHORT FORM"); + const { payload, hash } = storedPayload(); + expect(payload.recommendations.map((r: { id: string }) => r.id)).toEqual([ + "a", + ]); + expect(hash).not.toBe(FEATURES_HASH); + }); + + it.each(["STANDARD", "FULL", null])( + "keeps the full contract on %s", + async (level) => { + arrange(level); + await generateComprehensiveInsight("u1", { locale: "en", force: true }); + const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]); + expect(prompt).not.toContain("SHORT FORM"); + expect(storedPayload().payload.recommendations).toHaveLength(3); + }, + ); +}); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index e47f34ee0..ca0325658 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -19,6 +19,7 @@ const noteFindMany = vi.fn(); const aiCapabilityForRecord = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); +const appSettingsFindUnique = vi.fn(); const measurementFindMany = vi.fn(); // v1.42 — type discovery goes through the loose-index-scan helper; the // rows it answers with are the types themselves. @@ -40,6 +41,9 @@ vi.mock("@/lib/db", () => ({ findMany: (...a: unknown[]) => noteFindMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + appSettings: { + findUnique: (...a: unknown[]) => appSettingsFindUnique(...a), + }, measurement: { findMany: (...a: unknown[]) => measurementFindMany(...a), }, @@ -85,10 +89,19 @@ beforeEach(() => { }); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); + appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: null }); measurementFindMany.mockResolvedValue([]); measurementGroupBy.mockResolvedValue([]); + delete process.env.AI_SYNC_REGEN_GAP_HOURS; }); +/** How far in the past the freshness probe's cutoff sat, in hours. */ +function probedGapHours(before: number): number { + const where = noteFindMany.mock.calls[0][0].where; + const cutoff = (where.generatedAt.gte as Date).getTime(); + return Math.round((before - cutoff) / (60 * 60 * 1000)); +} + /** Distinct scopes the invalidator enqueued a regenerate for. */ function enqueuedScopes(): string[] { return [ @@ -102,7 +115,9 @@ function enqueuedScopes(): string[] { describe("invalidateStatusInsightsForTypes", () => { it("maps WEIGHT to weight + bmi + general", async () => { - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect( enqueueStatusGeneration.mock.calls.every( @@ -112,7 +127,9 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => { - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); // The rows stay for stale-while-revalidate AND for the content-hash // gate the worker's forced regeneration runs; deleting them here would // force a full LLM regeneration even for a re-synced unchanged batch. @@ -120,18 +137,20 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("maps both blood-pressure components to blood-pressure + general", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + { source: "manual" }, + ); expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]); }); it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "PULSE", - "RESTING_HEART_RATE", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["PULSE", "RESTING_HEART_RATE"], + { source: "manual" }, + ); // PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE // feeds those too AND carries its own generic assessment card // (`metric:RESTING_HEART_RATE`), so all three are dirtied. @@ -146,16 +165,18 @@ describe("invalidateStatusInsightsForTypes", () => { // BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a // generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the // general overview AND that generic scope. - await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("dedupes scopes across a mixed batch", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "WEIGHT", - "PULSE", - "BLOOD_GLUCOSE", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["WEIGHT", "PULSE", "BLOOD_GLUCOSE"], + { source: "manual" }, + ); // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ // general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE) expect(enqueuedScopes()).toEqual([ @@ -168,14 +189,16 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("is a no-op for an empty type set (no DB call)", async () => { - await invalidateStatusInsightsForTypes("u1", []); + await invalidateStatusInsightsForTypes("u1", [], { source: "manual" }); expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); it("enqueues nothing while statusText is unavailable for the record", async () => { aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"], { + source: "manual", + }); expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); @@ -183,7 +206,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => { userFindUnique.mockResolvedValue({ locale: "de" }); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); // weight + bmi + general, each warmed once for the user's resolved locale. expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3); @@ -197,7 +222,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("resolves the user's locale (en) for the regenerate, never the unused one", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const locales = new Set( enqueueStatusGeneration.mock.calls.map( (c) => (c[0] as { locale: string }).locale, @@ -210,7 +237,9 @@ describe("invalidateStatusInsightsForTypes", () => { // A steps sample touches only the general overview, never the weight / // pulse / bp scopes — the constant Apple-Health sync must not thrash // every card's cache. - await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]); }); @@ -219,14 +248,18 @@ describe("invalidateStatusInsightsForTypes", () => { // its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the // freshly-synced metric's card refreshes in the background instead of // lagging until the nightly warm pass. - await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("maps a remapped MeasurementType to its registry metric id", async () => { // ACTIVE_ENERGY_BURNED is stored under that DB type but the generic // metric id (route param + cache scope) is ACTIVE_ENERGY. - await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]); + await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]); }); @@ -234,7 +267,9 @@ describe("invalidateStatusInsightsForTypes", () => { // A weight sample feeds weight + bmi + general; WEIGHT is one of the // seven specialised metrics and has no generic registry entry, so no // `metric:` scope is enqueued. - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueuedScopes().some((s) => s.startsWith("metric:"))).toBe(false); }); @@ -249,7 +284,9 @@ describe("invalidateStatusInsightsForTypes", () => { // `general` was regenerated within the gap; a fresh WEIGHT sample // must not re-enqueue it. weight + bmi are still stale, so they refresh. noteFindMany.mockResolvedValue([freshRow("general")]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); @@ -261,7 +298,9 @@ describe("invalidateStatusInsightsForTypes", () => { // this is what lets same-day data be narrated same-day without // reopening the per-batch regeneration storm. noteFindMany.mockResolvedValue([]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); @@ -271,7 +310,9 @@ describe("invalidateStatusInsightsForTypes", () => { freshRow("bmi"), freshRow("general"), ]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -280,7 +321,9 @@ describe("invalidateStatusInsightsForTypes", () => { // scope that recently stalled must retry rather than be debounced into // staying cold. The probe asks the database for noted rows only; with // none returned, every dirtied scope refreshes. - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const where = noteFindMany.mock.calls[0][0].where; expect(where.textEncrypted).toEqual({ not: null }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); @@ -289,7 +332,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const where = noteFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); // Only the en notes for the dirtied scopes are probed. @@ -387,3 +432,127 @@ describe("enqueueStatusRefillForUser", () => { ]); }); }); + +// #1217 — the gap is chosen by where the batch came from. A watch drips +// syncs all day and every drip moves today's partial bucket, so the content +// hash cannot hold them back; a hand entry still has to be narrated the +// same day. +describe("regeneration gap by ingest source", () => { + it("holds a sync to six hours by default", async () => { + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(6); + }); + + it("keeps a hand entry at one hour", async () => { + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); + expect(probedGapHours(before)).toBe(1); + }); + + it("takes the sync gap from AI_SYNC_REGEN_GAP_HOURS when the operator set no value", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "12"; + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(12); + }); + + it("prefers the operator's stored gap over the environment", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "12"; + appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: 3 }); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(3); + }); + + it("ignores an out-of-range environment value", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "0"; + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(6); + }); + + it("falls back to the environment when the settings row cannot be read", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "24"; + appSettingsFindUnique.mockRejectedValue(new Error("db down")); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(24); + }); +}); + +// #1217 — the gap per AI usage level: frugal holds a sync to a day and a hand +// entry to six hours, standard uses the operator's sync gap and the hour, +// full keeps the hour for both (the behaviour before v1.43). +describe("regeneration gap by usage level", () => { + const cases: Array< + [level: string | null, source: "sync" | "manual", hours: number] + > = [ + ["FRUGAL", "sync", 24], + ["FRUGAL", "manual", 6], + ["STANDARD", "sync", 6], + ["STANDARD", "manual", 1], + ["FULL", "sync", 1], + ["FULL", "manual", 1], + // No choice: the instance default (standard). + [null, "sync", 6], + [null, "manual", 1], + ]; + + it.each(cases)( + "%s after a %s ingest waits %i h", + async (level, source, hours) => { + userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: level }); + appSettingsFindUnique.mockResolvedValue({ + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + }); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source, + }); + expect(probedGapHours(before)).toBe(hours); + }, + ); + + it("caps a stored full choice at the operator's ceiling", async () => { + userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: "FULL" }); + appSettingsFindUnique.mockResolvedValue({ + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: 8, + }); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(8); + }); + + it("follows a frugal instance default for an account without a choice", async () => { + userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: null }); + appSettingsFindUnique.mockResolvedValue({ + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: null, + }); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(24); + }); +}); diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts index b21f2b2c9..68b211f84 100644 --- a/src/lib/insights/__tests__/score-row.test.ts +++ b/src/lib/insights/__tests__/score-row.test.ts @@ -131,12 +131,17 @@ describe("upsertScoreRow", () => { now: new Date("2026-06-02T03:00:00Z"), }); - expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [ - { - type: "STRAIN_SCORE", - measuredAt: new Date("2026-06-01T12:00:00.000Z"), - }, - ]); + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "u1", + [ + { + type: "STRAIN_SCORE", + measuredAt: new Date("2026-06-01T12:00:00.000Z"), + }, + ], + // A computed score is not a hand entry: it waits the sync gap. + { source: "sync", label: "score" }, + ); }); }); diff --git a/src/lib/insights/__tests__/usage-level-simulation.test.ts b/src/lib/insights/__tests__/usage-level-simulation.test.ts new file mode 100644 index 000000000..f8891903e --- /dev/null +++ b/src/lib/insights/__tests__/usage-level-simulation.test.ts @@ -0,0 +1,210 @@ +/** + * #1217 — what the usage levels do to a watch-synced account, simulated + * deterministically through the real invalidator. + * + * One day of a phone that delivers a watch's samples every 15 minutes from + * 07:00 to 23:00, the morning sleep and recovery figures, two hand-entered + * blood-pressure readings and a hand-entered weight. Every scope the + * invalidator enqueues counts as one generation, the worst case the issue + * describes: today's partial bucket moves on every sync, so the content-hash + * gate never short-circuits a sync-driven run. The 04:30 nightly warm + * regenerates every scope once on every level and is counted apart. + * + * The clock, the cache rows and the queue are in memory; the scope mapping, + * the per-source gap and the level resolution are the production code. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sim = vi.hoisted(() => ({ + notes: new Map(), + generations: 0, + level: null as string | null, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(async () => ({ + locale: "en", + aiUsageLevel: sim.level, + })), + }, + appSettings: { + findUnique: vi.fn(async () => ({ + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + })), + }, + insightStatusCache: { + findMany: vi.fn( + async (args: { + where: { + OR: Array<{ metric: string; locale: string }>; + generatedAt: { gte: Date }; + }; + }) => + args.where.OR.filter( + (key) => + (sim.notes.get(key.metric) ?? -Infinity) >= + args.where.generatedAt.gte.getTime(), + ).map((key) => ({ metric: key.metric, locale: key.locale })), + ), + }, + }, +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); +vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ + enqueueStatusGeneration: vi.fn(async (job: { metric: string }) => { + sim.generations += 1; + sim.notes.set(job.metric, Date.now()); + }), +})); +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(async () => []), +})); + +import { invalidateStatusInsightsForTypes } from "../status-invalidation"; +import type { MeasurementType } from "@/generated/prisma/client"; + +type Event = { + at: string; + source: "sync" | "manual"; + types: MeasurementType[]; +}; + +/** One day of a watch account, minute-exact and the same on every run. */ +function day(): Event[] { + const events: Event[] = []; + for (let minutes = 7 * 60; minutes <= 23 * 60; minutes += 15) { + const hh = String(Math.floor(minutes / 60)).padStart(2, "0"); + const mm = String(minutes % 60).padStart(2, "0"); + const types: MeasurementType[] = [ + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "PULSE", + "FLIGHTS_CLIMBED", + ]; + if (minutes % 60 === 0) + types.push("TIME_IN_DAYLIGHT", "AUDIO_EXPOSURE_ENV"); + if (minutes % 120 === 0) { + types.push("HEART_RATE_VARIABILITY", "WALKING_HEART_RATE_AVERAGE"); + } + if (minutes === 7 * 60) { + types.push( + "SLEEP_DURATION", + "RESTING_HEART_RATE", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "WRIST_TEMPERATURE", + "VO2_MAX", + ); + } + events.push({ at: `${hh}:${mm}`, source: "sync", types }); + } + events.push( + { at: "07:30", source: "manual", types: ["WEIGHT"] }, + { + at: "08:00", + source: "manual", + types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, + { + at: "20:00", + source: "manual", + types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, + ); + return events.sort((a, b) => a.at.localeCompare(b.at)); +} + +const ALL_SCOPES_AT_WARM = new Set(); + +async function simulate(level: string | null): Promise<{ + warm: number; + ingest: number; + bySource: Record<"sync" | "manual", number>; +}> { + sim.notes.clear(); + sim.generations = 0; + sim.level = level; + // The 04:30 warm regenerates every scope the day will touch. + const warmAt = Date.parse("2026-10-15T04:30:00Z"); + for (const scope of ALL_SCOPES_AT_WARM) sim.notes.set(scope, warmAt); + const warm = ALL_SCOPES_AT_WARM.size; + const bySource = { sync: 0, manual: 0 }; + for (const event of day()) { + vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`)); + const before = sim.generations; + await invalidateStatusInsightsForTypes("u1", event.types, { + source: event.source, + }); + bySource[event.source] += sim.generations - before; + } + return { warm, ingest: sim.generations, bySource }; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); +}); + +describe("generations per day on a watch-synced account", () => { + it("counts the scopes once to seed the warm", async () => { + // Discover every scope the day dirties: run it once on `full` with no + // warm, which enqueues each scope at least once. + ALL_SCOPES_AT_WARM.clear(); + sim.notes.clear(); + sim.level = "FULL"; + for (const event of day()) { + vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`)); + await invalidateStatusInsightsForTypes("u1", event.types, { + source: event.source, + }); + } + for (const scope of sim.notes.keys()) ALL_SCOPES_AT_WARM.add(scope); + expect(ALL_SCOPES_AT_WARM.size).toBe(19); + }); + + it("before v1.43 (every ingest at one hour) versus each level", async () => { + const full = await simulate("FULL"); // the behaviour before v1.43 + const standard = await simulate("STANDARD"); + const frugal = await simulate("FRUGAL"); + const result = { full, standard, frugal }; + process.stdout.write(`SIMULATION ${JSON.stringify(result)}\n`); + + // Ingest-driven generations a day (the warm adds 19 on every level). + expect(full).toEqual({ + warm: 19, + ingest: 122, + bySource: { sync: 118, manual: 4 }, + }); + expect(standard).toEqual({ + warm: 19, + ingest: 25, + bySource: { sync: 20, manual: 5 }, + }); + expect(frugal).toEqual({ + warm: 19, + ingest: 2, + bySource: { sync: 0, manual: 2 }, + }); + // The contract's aim: standard well below a quarter of the old ingest + // volume, and below a third with the warm counted. + expect(standard.ingest / full.ingest).toBeLessThan(0.25); + expect( + (standard.warm + standard.ingest) / (full.warm + full.ingest), + ).toBeLessThan(0.32); + // Hand entries are narrated within the hour on standard, as before; on + // full the sync drip had often refreshed the same scope just before. + expect(standard.bySource.manual).toBeGreaterThanOrEqual( + full.bySource.manual, + ); + }); +}); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 20b9e1887..17ca5636f 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -315,7 +315,7 @@ export async function generateBiomarkerStatus(args: { value: r.value, })), }; - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 4057590a4..e90d9f602 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -42,6 +42,7 @@ import { buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, @@ -641,8 +642,9 @@ export async function prepareBloodPressureStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 9c9808185..06f213942 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -19,6 +19,7 @@ import { scaleGradedSeries, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, @@ -334,8 +335,9 @@ export async function prepareBmiStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index 422ec7fbc..b8646a92c 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -127,7 +127,7 @@ export async function runBriefingCompletion( // v1.41 — one resolution per call: the operator's switch and cap, the payer // from the chain's primary. `undefined` is today's wire. const reasoning = args.reasoningJob - ? await resolveJobReasoning(args.reasoningJob, args.chain) + ? await resolveJobReasoning(args.reasoningJob, args.chain, args.userId) : undefined; const thinkingTokens = reasoning && reasoning.effort !== "off" diff --git a/src/lib/insights/briefing-short-form.ts b/src/lib/insights/briefing-short-form.ts new file mode 100644 index 000000000..a877735d0 --- /dev/null +++ b/src/lib/insights/briefing-short-form.ts @@ -0,0 +1,108 @@ +/** + * v1.43 (#1217) — the daily briefing's short form, for the `frugal` AI usage + * level. + * + * The person chose the level knowingly, so a shorter briefing is fine there: + * the lead and today, at most one recommendation, no trend or storyboard + * annotations. The contract keeps its shape on every level: every required + * field stays present (`recommendations` is decoded as a required array by + * the native client), only shorter. `standard` and `full` keep the full + * contract, recommendations included. + * + * The prompt asks for the short form; {@link applyBriefingShortForm} holds the + * counts afterwards, so a model that writes more than asked cannot widen it. + */ +import { instructionLocale } from "@/lib/ai/prompts/output-language"; +import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage"; +import { + AI_USAGE_LEVEL_EFFECTS, + DEFAULT_AI_USAGE_DEFAULT_LEVEL, +} from "@/lib/ai/usage-level"; +import type { Locale } from "@/lib/i18n/config"; + +export type BriefingForm = "full" | "short"; + +/** At most this many entries of each list on the short form. */ +export const SHORT_FORM_LIMITS = Object.freeze({ + recommendations: 1, + signalsOfDay: 2, + keyFindings: 2, +}); + +/** + * The briefing form for one record's level. An unreadable level reads as the + * instance default's form. + */ +export async function briefingFormForRecord( + recordId: string, +): Promise { + try { + return AI_USAGE_LEVEL_EFFECTS[await loadRecordUsageLevel(recordId)] + .briefing; + } catch { + return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL].briefing; + } +} + +/** + * The block appended to the briefing's user prompt on the short form. Empty + * for the full form, so a `standard` or `full` prompt is unchanged. + */ +export function buildBriefingShortFormBlock( + locale: Locale, + form: BriefingForm, +): string { + if (form === "full") return ""; + if (instructionLocale(locale) === "de") { + return ` + +KURZFORM (vom Nutzer gewählte sparsame KI-Stufe) +Schreibe dieselbe JSON-Struktur mit allen Pflichtfeldern, nur kürzer: +- "summary": ein bis zwei Sätze. +- "dailyBriefing.paragraph": 40 bis 80 Wörter, ein Absatz: das Urteil des Tages und was es für heute heißt. +- "dailyBriefing.signalsOfDay": höchstens ${SHORT_FORM_LIMITS.signalsOfDay}; "dailyBriefing.keyFindings": höchstens ${SHORT_FORM_LIMITS.keyFindings}. +- "recommendations": höchstens ${SHORT_FORM_LIMITS.recommendations}, die wichtigste; ein leeres Array, wenn nichts ansteht. +- "trendAnnotations" und "storyboardAnnotations" weglassen. +Alle übrigen Regeln gelten unverändert.`; + } + return ` + +SHORT FORM (the person chose the frugal AI usage level) +Write the same JSON structure with every required field, only shorter: +- "summary": one or two sentences. +- "dailyBriefing.paragraph": 40 to 80 words, one paragraph: the day's verdict and what it means for today. +- "dailyBriefing.signalsOfDay": at most ${SHORT_FORM_LIMITS.signalsOfDay}; "dailyBriefing.keyFindings": at most ${SHORT_FORM_LIMITS.keyFindings}. +- "recommendations": at most ${SHORT_FORM_LIMITS.recommendations}, the most important one; an empty array when nothing is due. +- Leave out "trendAnnotations" and "storyboardAnnotations". +Every other rule still applies.`; +} + +/** + * Hold a parsed briefing payload to the short form's counts. Pure: returns a + * copy, every field still present. A payload that is not an object comes + * back as it was. + */ +export function applyBriefingShortForm(payload: T): T { + if (!payload || typeof payload !== "object") return payload; + const out = { ...(payload as Record) }; + if (Array.isArray(out.recommendations)) { + out.recommendations = out.recommendations.slice( + 0, + SHORT_FORM_LIMITS.recommendations, + ); + } + delete out.trendAnnotations; + delete out.storyboardAnnotations; + const briefing = out.dailyBriefing; + if (briefing && typeof briefing === "object") { + const b = { ...(briefing as Record) }; + if (Array.isArray(b.signalsOfDay)) { + b.signalsOfDay = b.signalsOfDay.slice(0, SHORT_FORM_LIMITS.signalsOfDay); + } + if (Array.isArray(b.keyFindings)) { + b.keyFindings = b.keyFindings.slice(0, SHORT_FORM_LIMITS.keyFindings); + } + out.dailyBriefing = b; + } + return out as T; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index cd396bfc3..fd714e06e 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -104,6 +104,11 @@ import { stripJsonFences, type SupportedLocale, } from "@/lib/insights/status-shared"; +import { + applyBriefingShortForm, + briefingFormForRecord, + buildBriefingShortFormBlock, +} from "@/lib/insights/briefing-short-form"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; @@ -965,7 +970,9 @@ export async function generateComprehensiveInsight( const compactFeatures = compactSections( features as unknown as Record, ); - const featuresJson = JSON.stringify(compactFeatures, null, 2); + // Compact on the wire: indentation is about 40% of a pretty-printed + // payload, and the model reads the structure without it (#1217). + const featuresJson = JSON.stringify(compactFeatures); // v1.16.8 — content-hash gate. When the compacted feature snapshot is // byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts) @@ -1006,11 +1013,16 @@ export async function generateComprehensiveInsight( const planProgressLines = await buildPlanProgressLines(userId).catch( () => [] as string[], ); + // v1.43 (#1217) — the short form on the `frugal` usage level. It joins the + // fingerprint only when it applies, so every other account keeps its stored + // hash, and a level change regenerates the briefing once. + const briefingForm = await briefingFormForRecord(userId); const snapshotHash = hashInsightSnapshot({ features: compactFeatures, aboutMe: aboutMe ?? null, comparisonBaseline, generationLocale: locale, + ...(briefingForm === "short" ? { briefingForm } : {}), // Absent without an active plan, so an account without one keeps its // stored fingerprint; with one, a plan that moves regenerates the // briefing the way moved data does. @@ -1074,6 +1086,7 @@ export async function generateComprehensiveInsight( locale, await resolveUserTimezone(userId), ); + userPrompt += buildBriefingShortFormBlock(locale, briefingForm); const systemPrompt = buildSystemPromptWithReferences( locale, referenceMetrics, @@ -1587,6 +1600,11 @@ export async function generateComprehensiveInsight( return failBeforeProvider(userId, locale, "aborted"); } + // v1.43 (#1217) — the short form holds its counts whatever the model wrote. + if (briefingForm === "short") { + insights = applyBriefingShortForm(insights); + } + // The payload was built under the privacy and profile scope captured before // the provider call. Recheck both before the write so an inclusion change // cannot be overwritten by stale in-flight content. diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 7d1acb045..bf29989e6 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -133,11 +133,12 @@ function scoreUserPrompt( /** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */ tz: string, ): string { - const snapshot = JSON.stringify( - { promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal }, - null, - 2, - ); + const snapshot = JSON.stringify({ + promptVersion: PROMPT_VERSION, + generatedForDay: todayKey, + band, + signal, + }); if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (${tz}) OPENER HINT: ${openerHint} diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index de8420700..d8d0acbba 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -22,6 +22,7 @@ import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { normalizeLocale, finalizeStatusSummary, @@ -418,8 +419,9 @@ export async function prepareGeneralStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index ed50332bb..57caf5be3 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -682,17 +682,25 @@ export function convertGradedSeries( } /** - * Assembled-snapshot soft char cap, mirroring the Coach's - * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the - * pretty-printed form). The status snapshots had no cap at all; a - * many-metric `general-status` could balloon past 100 K tokens. + * Assembled-snapshot soft char cap, measured against the COMPACT JSON the + * prompt ships (`JSON.stringify(snapshot)`). The status snapshots had no cap + * at all once; a many-metric `general-status` could balloon past 100 K + * tokens. + * + * Until v1.43 the cap was 24 000 characters of pretty-printed JSON. The + * prompt is compact now, and a graded series loses about 58% of its + * characters to indentation (compact is ~0.42 of pretty across one to sixteen + * metrics and thirty to a thousand days), so 24 000 pretty characters are + * ~10 100 compact ones. 14 000 keeps every snapshot that fitted before + * fitting now, with room for the string-heavier blocks whose ratio is higher: + * the model sees at least the same data for fewer tokens, never less. */ -export const MAX_SNAPSHOT_CHARS = 24_000; +export const MAX_SNAPSHOT_CHARS = 14_000; /** - * Walk an arbitrary snapshot object and, while it exceeds - * `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the - * prompt ships), shed the lowest-signal slices in order: + * Walk an arbitrary snapshot object and, while it exceeds the cap + * (`options.maxChars`, else `MAX_SNAPSHOT_CHARS`; measured against the + * compact form the prompt ships), shed the lowest-signal slices in order: * 1. drop every graded `yearly` array, * 2. drop every graded `weekly` array, * 3. truncate any other array to its tail `arrayTailCap` entries @@ -706,11 +714,21 @@ export const MAX_SNAPSHOT_CHARS = 24_000; */ export function degradeStatusSnapshotToBudget( snapshot: Record, - arrayTailCap = 30, + options: { + /** + * v1.43 (#1217) — the cap for this record's AI usage level + * (`statusSnapshotChars`, `@/lib/ai/usage-level`). Defaults to + * `MAX_SNAPSHOT_CHARS`. + */ + maxChars?: number; + arrayTailCap?: number; + } = {}, ): string[] { + const maxChars = options.maxChars ?? MAX_SNAPSHOT_CHARS; + const arrayTailCap = options.arrayTailCap ?? 30; const shed: string[] = []; - const size = () => JSON.stringify(snapshot, null, 2).length; - if (size() <= MAX_SNAPSHOT_CHARS) return shed; + const size = () => JSON.stringify(snapshot).length; + if (size() <= maxChars) return shed; const walk = ( node: unknown, @@ -738,14 +756,14 @@ export function degradeStatusSnapshotToBudget( }; dropField("yearly"); - if (size() <= MAX_SNAPSHOT_CHARS) { + if (size() <= maxChars) { shed.push("yearly"); return shed; } shed.push("yearly"); dropField("weekly"); - if (size() <= MAX_SNAPSHOT_CHARS) { + if (size() <= maxChars) { shed.push("weekly"); return shed; } @@ -761,7 +779,7 @@ export function degradeStatusSnapshotToBudget( parent[key] = value.slice(-arrayTailCap); } }); - if (size() <= MAX_SNAPSHOT_CHARS) { + if (size() <= maxChars) { shed.push("array-tails"); return shed; } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 299b97a64..977cbffce 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -28,6 +28,7 @@ import { } from "@/lib/insights/assessment-context"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series"; import { @@ -405,8 +406,9 @@ export async function prepareMedicationComplianceStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 8ee1c927c..1da61d7e5 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -62,6 +62,7 @@ import { degradeStatusSnapshotToBudget, type GradedSeries, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { type SupportedLocale, normalizeLocale, @@ -453,8 +454,9 @@ export async function generateMetricStatus(args: { const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(args.userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 4c787f040..355284e78 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -38,6 +38,7 @@ import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, @@ -664,8 +665,9 @@ export async function prepareMoodStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index b6143d2e1..c0cff4dda 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -27,6 +27,7 @@ import { buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, @@ -391,8 +392,9 @@ export async function preparePulseStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts index c82b07487..e49d57a31 100644 --- a/src/lib/insights/score-row.ts +++ b/src/lib/insights/score-row.ts @@ -105,9 +105,11 @@ export async function upsertScoreRow( // it through the shared post-mutation tail so the (type, day) rollup // bucket and the cached status assessment converge on the same tick that // wrote the score, instead of waiting for a discovery pass. - await afterMeasurementMutation(args.userId, [ - { type: args.type, measuredAt }, - ]); + await afterMeasurementMutation( + args.userId, + [{ type: args.type, measuredAt }], + { source: "sync", label: "score" }, + ); } /** The tally every score pass returns. */ diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index b12d59023..033b289fa 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -31,42 +31,52 @@ import { import { annotate } from "@/lib/logging/context"; import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { loadRecordAiUsage } from "@/lib/ai/capabilities/usage"; +import { statusRegenGapHours, type IngestSource } from "@/lib/ai/usage-level"; import type { MeasurementType } from "@/generated/prisma/client"; /** - * Minimum gap between a scope's last cached (re)generation and the next - * ingest-driven re-enqueue. A scope whose cached assessment was - * (re)generated within this window is left intact on a fresh measurement - * ingest — not re-enqueued. + * Where a batch of fresh measurements came from, which decides how soon the + * status cards it dirties may regenerate. * - * A constantly-syncing client (Apple Health drips batches every few - * minutes) used to delete + re-enqueue every dirtied scope on every batch, - * and because each delete dropped the cache row the per-(user,scope,locale) - * enqueue singleton (120 s) could not coalesce across batches that arrive - * minutes apart — so STEPS / general / a metric card regenerated several - * times an hour and the assessment felt "regenerated on every visit". This - * window coalesces the storm: a genuinely stale scope (no fresh assessment) - * still refreshes immediately, but a scope refreshed inside the window is - * skipped, so a fresh assessment survives the day's sync drip. - * - * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but - * a 6 h wall meant same-day data was not narrated same-day: the nightly - * warm at 04:30 restarted the window, so a notable 08:00 reading never - * re-enqueued its scopes and the card showed the pre-reading text until - * the next nightly tick. The window is now a ONE-HOUR minimum gap, and - * the budget role the 6 h wall carried moves to the worker's content-hash - * gate: every ingest that lands past the gap re-enqueues the dirtied - * scopes, the worker's forced run re-gathers the snapshot, and the gate - * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a - * timestamp refresh with zero provider cost. So this clock only bounds - * the SQL-gather frequency — at most one worker run per scope per hour - * under a constant sync drip — while provider spend tracks actual data - * change, which is what the gate exists to meter. Comparing the hash - * inline at invalidation time was rejected: it would run the full - * per-scope data gather inside the ingest path, which is exactly the - * work the queue exists to keep off that path. + * - `manual`: a reading the person entered or changed by hand (the web + * form, the phone's entry sheet, a Telegram reply, an MCP write, a + * delete or restore, a file they chose to import). It should be narrated + * the same day, so its gap stays short. + * - `sync`: rows a device or an integration delivered on its own schedule + * (the phone's Apple Health sync, Withings, Oura, Fitbit, Polar, WHOOP, + * Nightscout, Google Health, a computed score). A watch drips these all + * day, and every drip moves today's partial bucket, so the content-hash + * gate cannot catch them; only a longer gap does. */ -const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000; +export type StatusIngestSource = IngestSource; + +/** + * The minimum gap between a scope's last cached (re)generation and the next + * ingest-driven re-enqueue. A scope whose cached assessment was + * (re)generated within the gap is left intact and not re-enqueued. + * + * History: v1.9.0 introduced the window (30 min) because a constantly + * syncing client re-enqueued every dirtied scope on every batch. v1.16.8 + * widened it to 6 h for every ingest, which was rolled back to 1 h because a + * lone 08:00 reading after the 04:30 nightly warm was not narrated the same + * day. Since v1.43 (#1217) the gap is chosen by source and by the record's AI + * usage level (`statusRegenGapHours` in `@/lib/ai/usage-level`): a hand entry + * keeps the hour (six on `frugal`), a sync waits the operator's sync gap on + * `standard` (default six hours), a day on `frugal`, the hour on `full`. + */ +async function ingestRegenGapMs( + userId: string, + source: StatusIngestSource, +): Promise<{ gapMs: number; level: string }> { + const usage = await loadRecordAiUsage(userId); + const hours = statusRegenGapHours( + usage.level, + source, + usage.settings.syncRegenGapHours, + ); + return { gapMs: hours * 60 * 60 * 1000, level: usage.level }; +} /** * The seven per-metric assessment scopes. Each generator persists its @@ -130,6 +140,7 @@ function statusScopesForMeasurementType( export async function invalidateStatusInsightsForTypes( userId: string, types: Iterable, + options: { source: StatusIngestSource }, ): Promise { // One ordered set of every scope the batch dirties. The seven specialised // scopes are bare slugs (`weight`, `general`, …); the generic HealthKit @@ -184,14 +195,25 @@ export async function invalidateStatusInsightsForTypes( // only the redundant churn is removed. Past the gap the enqueue always // goes through — the worker's content-hash gate decides whether the // batch actually changed anything (see the constant's doc). - const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); + const { gapMs, level } = await ingestRegenGapMs(userId, options.source); + const freshScopes = await findRecentlyWarmedScopes( + userId, + locale, + scopes, + gapMs, + ); const staleScopes = Array.from(scopes).filter( (scope) => !freshScopes.has(scope), ); if (staleScopes.length === 0) { annotate({ action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: scopes.size, refreshed: 0 }, + meta: { + skipped: scopes.size, + refreshed: 0, + source: options.source, + usage_level: level, + }, }); return; } @@ -210,7 +232,12 @@ export async function invalidateStatusInsightsForTypes( annotate({ action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: freshScopes.size, refreshed: staleScopes.length }, + meta: { + skipped: freshScopes.size, + refreshed: staleScopes.length, + source: options.source, + usage_level: level, + }, }); } @@ -268,7 +295,7 @@ export async function enqueueStatusRefillForUser( /** * v1.9.0 — return the subset of `scopes` whose cached assessment for - * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a + * `locale` was generated within `gapMs` and is a * real (non-stub) assessment. Those scopes are skipped by the ingest * invalidator so a fresh assessment survives the sync drip. * @@ -281,8 +308,9 @@ async function findRecentlyWarmedScopes( userId: string, locale: SupportedLocale, scopes: ReadonlySet, + gapMs: number, ): Promise> { - const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); + const cutoff = new Date(Date.now() - gapMs); // Match exactly the cache actions for the candidate scopes in this locale. const candidateActions = Array.from(scopes, (scope) => statusCacheAction(scope, locale), diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 6646c9771..954688f65 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -216,7 +216,7 @@ export async function runStatusCompletion( // so a refused call never reads the controls. `undefined` (the operator // switched reasoning off, or the job resolved off) is today's wire. const reasoning = args.reasoningJob - ? await resolveJobReasoning(args.reasoningJob, chain) + ? await resolveJobReasoning(args.reasoningJob, chain, userId) : undefined; const thinkingTokens = reasoning && reasoning.effort !== "off" diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 7bc94bef0..09dc5328d 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -33,6 +33,7 @@ import { convertGradedSeries, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { applyDisplayTransform, applyDisplayTransformDelta, @@ -607,8 +608,9 @@ export async function prepareWeightStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index ff5c64e2b..f90e09300 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -605,7 +605,11 @@ export async function handleAppleHealthImport( if (importedTypes.length > 0) { // Registered so the test suite can await the detached handle. trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, importedTypes).catch((err) => { + invalidateStatusInsightsForTypes(userId, importedTypes, { + // An export file the person chose to import, narrated like any + // other import rather than held to the sync gap. + source: "manual", + }).catch((err) => { console.warn( `[apple-health-import] status-insight invalidate failed for user ${userId}`, err, diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index 58ba0c6db..b4928e022 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -228,7 +228,11 @@ export async function runHealthConnectImport( .map(([type]) => type as MeasurementType); if (touched.length > 0) { trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, touched).catch((err) => { + invalidateStatusInsightsForTypes(userId, touched, { + // An export file the person chose to import, like the Apple Health + // import: narrated like a hand entry, not held to the sync gap. + source: "manual", + }).catch((err) => { console.warn( `[health-connect-import] status-insight invalidate failed for user ${userId}`, err, diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index a762f1f4c..9d5445c9f 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -478,7 +478,7 @@ describe("MCP writes — post-write tail", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "u-1", [expect.objectContaining({ type: "WEIGHT" })], - "mcp", + { source: "manual", label: "mcp" }, ); }); @@ -495,7 +495,7 @@ describe("MCP writes — post-write tail", () => { expect.objectContaining({ type: "BLOOD_PRESSURE_SYS" }), expect.objectContaining({ type: "BLOOD_PRESSURE_DIA" }), ], - "mcp", + { source: "manual", label: "mcp" }, ); }); }); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 49e3365bd..196f0b291 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -355,7 +355,7 @@ export async function logMcpMeasurement(input: { await afterMeasurementMutation( input.userId, [{ type: input.type, measuredAt }], - "mcp", + { source: "manual", label: "mcp" }, ); await auditLog("mcp.write.measurement", { @@ -529,7 +529,7 @@ export async function logMcpBloodPressure(input: { { type: "BLOOD_PRESSURE_SYS", measuredAt }, { type: "BLOOD_PRESSURE_DIA", measuredAt }, ], - "mcp", + { source: "manual", label: "mcp" }, ); await auditLog("mcp.write.blood_pressure", { diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts index 54fcbd717..b9a98ff09 100644 --- a/src/lib/measurements/__tests__/create-from-telegram.test.ts +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -96,7 +96,7 @@ describe("logTelegramMeasurement", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "user-1", [expect.objectContaining({ type: "WEIGHT" })], - "telegram-measurement", + { source: "manual", label: "telegram-measurement" }, ); }); diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts index 9bbe84543..d2f08a71c 100644 --- a/src/lib/measurements/create-from-telegram.ts +++ b/src/lib/measurements/create-from-telegram.ts @@ -162,7 +162,7 @@ export async function logTelegramMeasurement(input: { await afterMeasurementMutation( input.userId, [{ type: input.type, measuredAt }], - "telegram-measurement", + { source: "manual", label: "telegram-measurement" }, ); return { status: "ok" }; diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index 9b0178cbf..7a9881da9 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -364,7 +364,7 @@ export async function applyAppleHealthDistanceRepair( type: REPAIRED_TYPE, measuredAt: row.measuredAt, })), - "repair-apple-health-distance", + { source: "sync", label: "repair-apple-health-distance" }, ); await client.auditLog.update({ where: { id: auditId }, diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 3af82a725..c1c0edc92 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -333,6 +333,7 @@ export async function upsertNightscoutEntries( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `nightscout: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 5bfc38b1f..fe46b8534 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -43,7 +43,10 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "ai.provider.none", "ai.record.notPermitted", "ai.unavailable", + "ai.usage.aboveCeiling", + "ai.usage.defaultAboveCeiling", "ai.usage.invalid", + "ai.usage.levelTooLow", "ai.usage.rateLimited", ], ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], diff --git a/src/lib/openapi/routes/ai-usage.ts b/src/lib/openapi/routes/ai-usage.ts index 58f7f7504..1711b4267 100644 --- a/src/lib/openapi/routes/ai-usage.ts +++ b/src/lib/openapi/routes/ai-usage.ts @@ -11,12 +11,7 @@ import { z } from "zod/v4"; import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({ id: "AiUsageLevel", @@ -111,7 +106,6 @@ export const aiUsagePaths: NonNullable = { "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).", content: { "application/json": { schema: errorEnvelope } }, }, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 79e693cec..de83b2e61 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -129,7 +129,7 @@ export const moduleAccessMap = z export const aiUnavailableReason = z.enum(AI_UNAVAILABLE_REASONS).meta({ id: "AiUnavailableReason", description: - "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.", + "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.", }); export const aiCapabilityState = z @@ -141,7 +141,7 @@ export const aiCapabilityState = z onDeviceAllowed: z .boolean() .describe( - "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.", + "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.", ), }) .meta({ @@ -164,7 +164,7 @@ export const aiCapabilities = z .meta({ id: "AiCapabilities", description: - "Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule.", + "Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level.", }); export const aiProviderState = z diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index f2d059c20..b74579208 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -91,13 +91,13 @@ export const errorEnvelope = z .string() .optional() .describe( - "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`).", + "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`).", ), reason: z .string() .optional() .describe( - "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).", + "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).", ), module: z .string() @@ -105,6 +105,12 @@ export const errorEnvelope = z .describe( "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module.", ), + requiredLevel: z + .enum(["frugal", "standard", "full"]) + .optional() + .describe( + "Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered.", + ), }) .optional(), }) diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 51a785ef3..855646c89 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -573,7 +573,10 @@ export async function retireOuraSleepRecords( where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, data: { deletedAt: new Date() }, }); - await afterMeasurementMutation(userId, rows, "oura"); + await afterMeasurementMutation(userId, rows, { + source: "sync", + label: "oura", + }); invalidateUserMeasurements(userId); annotate({ meta: { oura_sleep_retired_rows: res.count } }); return res.count; @@ -808,6 +811,7 @@ export async function upsertOuraMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `oura: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 23db25dbf..6cd575dd0 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -404,6 +404,7 @@ export async function upsertPolarMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `polar: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts index 6a983e08e..681356457 100644 --- a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts +++ b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts @@ -47,20 +47,24 @@ const day = (iso: string) => new Date(iso); describe("afterMeasurementMutation", () => { it("recomputes one bucket per distinct (type, day) and invalidates each type once", async () => { - await afterMeasurementMutation("user-1", [ - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T08:00:00Z"), - }, - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T21:00:00Z"), - }, - { - type: "PULSE" as MeasurementType, - measuredAt: day("2026-05-10T09:00:00Z"), - }, - ]); + await afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T21:00:00Z"), + }, + { + type: "PULSE" as MeasurementType, + measuredAt: day("2026-05-10T09:00:00Z"), + }, + ], + { source: "sync" }, + ); // Two rows on the same (WEIGHT, day) collapse into one recompute. expect(mocks.recompute).toHaveBeenCalledTimes(2); @@ -76,13 +80,15 @@ describe("afterMeasurementMutation", () => { ); expect(mocks.invalidate).toHaveBeenCalledTimes(1); - const [userId, types] = mocks.invalidate.mock.calls[0]; + const [userId, types, options] = mocks.invalidate.mock.calls[0]; expect(userId).toBe("user-1"); expect([...types].sort()).toEqual(["PULSE", "WEIGHT"]); + // The caller's source reaches the invalidator, which picks the gap by it. + expect(options).toEqual({ source: "sync" }); }); it("is a no-op for an empty row set", async () => { - await afterMeasurementMutation("user-1", []); + await afterMeasurementMutation("user-1", [], { source: "manual" }); expect(mocks.recompute).not.toHaveBeenCalled(); expect(mocks.invalidate).not.toHaveBeenCalled(); }); @@ -99,7 +105,7 @@ describe("afterMeasurementMutation", () => { measuredAt: day("2026-05-10T08:00:00Z"), }, ], - "test.surface", + { source: "manual", label: "test.surface" }, ); } finally { warn.mockRestore(); @@ -113,12 +119,16 @@ describe("afterMeasurementMutation", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect( - afterMeasurementMutation("user-1", [ - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T08:00:00Z"), - }, - ]), + afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + ], + { source: "manual" }, + ), ).resolves.toBeUndefined(); // Let the fire-and-forget rejection settle inside the helper's catch. await new Promise((resolve) => setImmediate(resolve)); diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index d4ff65816..f12c17160 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -22,16 +22,28 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { + invalidateStatusInsightsForTypes, + type StatusIngestSource, +} from "@/lib/insights/status-invalidation"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import type { MeasurementType } from "@/generated/prisma/client"; export async function afterMeasurementMutation( userId: string, rows: Array<{ type: MeasurementType; measuredAt: Date }>, - /** Log prefix identifying the calling surface in the warn lines. */ - label = "measurements", + options: { + /** + * Where the rows came from: a hand entry (`manual`) or a device or + * integration (`sync`). Decides how soon the status cards they dirty may + * regenerate (`StatusIngestSource`). + */ + source: StatusIngestSource; + /** Log prefix identifying the calling surface in the warn lines. */ + label?: string; + }, ): Promise { + const label = options.label ?? "measurements"; if (rows.length === 0) return; // Collapse to the distinct `(type, day)` set BEFORE recomputing so a @@ -53,7 +65,9 @@ export async function afterMeasurementMutation( // write on a detached handle, and one that lands while a vitest worker is // closing its RPC channel fails the whole run. No-op outside test. trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: options.source, + }).catch((err) => { console.warn(`[${label}] status-insight invalidate failed`, err); }), ); diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 2002fd38b..4237ff0fc 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -149,7 +149,7 @@ describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "user1", [legacyRow], - "whoop-body", + { source: "sync", label: "whoop-body" }, ); expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ where: { diff --git a/src/lib/whoop/__tests__/webhook-notification.test.ts b/src/lib/whoop/__tests__/webhook-notification.test.ts index 2e9db8f19..f118932bb 100644 --- a/src/lib/whoop/__tests__/webhook-notification.test.ts +++ b/src/lib/whoop/__tests__/webhook-notification.test.ts @@ -144,7 +144,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => { expect(afterMutation).toHaveBeenCalledWith( "user-1", [{ type: "SLEEP_DURATION", measuredAt }], - "whoop.webhook", + { source: "sync", label: "whoop.webhook" }, ); }); }); diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 2600b2687..5a3ca040a 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -78,7 +78,10 @@ export async function syncUserBody( }); if (legacy.length > 0) { await prisma.measurement.deleteMany({ where: legacyWhere }); - await afterMeasurementMutation(userId, legacy, "whoop-body"); + await afterMeasurementMutation(userId, legacy, { + source: "sync", + label: "whoop-body", + }); } } catch (err) { getEvent()?.addWarning( diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts index 464ab6718..bb6da4b42 100644 --- a/src/lib/whoop/sync-core.ts +++ b/src/lib/whoop/sync-core.ts @@ -421,13 +421,13 @@ export async function upsertWhoopMeasurements( const invalidateTouchedTypes = (): void => { if (touchedTypes.size === 0) return; - void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch( - (err) => { - getEvent()?.addWarning( - `whoop: status-insight invalidate failed for ${userId}: ${err}`, - ); - }, - ); + void invalidateStatusInsightsForTypes(userId, [...touchedTypes], { + source: "sync", + }).catch((err) => { + getEvent()?.addWarning( + `whoop: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); }; for ( diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index 55790ef77..6fe2cb8a5 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -197,7 +197,7 @@ export async function processWhoopNotification( type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "whoop.webhook", + { source: "sync", label: "whoop.webhook" }, ); if (resource === "workout") { // The Workout model carries no soft-delete column, so a deleted diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 49a92cc29..1f2284f06 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -550,6 +550,7 @@ export async function syncUserActivity( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings activity: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 0e6812e46..4721853fe 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -528,6 +528,7 @@ export async function syncUserEcg( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings ecg: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 1f8719950..252332887 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -638,6 +638,7 @@ export async function syncUserSleep( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings sleep: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 10bdc24b3..af4525e23 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -457,6 +457,7 @@ export async function syncUserMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/tests/integration/ai-usage-levels.test.ts b/tests/integration/ai-usage-levels.test.ts new file mode 100644 index 000000000..f19361326 --- /dev/null +++ b/tests/integration/ai-usage-levels.test.ts @@ -0,0 +1,368 @@ +/** + * v1.43 (#1217) — AI usage levels, through the real routes and the real + * invalidator against real Postgres. + * + * The resolution matrix, the estimate and the effect table are unit-tested + * (`src/lib/ai/__tests__/usage-level.test.ts`). What only this file proves is + * the wiring end to end: + * + * - `ai.usage` on `GET /api/auth/me` reads the record's stored choice and + * the operator's bounds off their rows, and the measured use off + * `coach_usage`; + * - `PUT /api/auth/me/ai-usage` writes the choice and refuses one above the + * ceiling; the admin route lowers the ceiling, which caps a stored choice + * at once without erasing it; + * - the `supplementLabel` capability closes with `usage_level` on `frugal`; + * - the status invalidator holds a `frugal` account's sync ingest to one + * regeneration per scope a day, while a hand entry six hours later goes + * through, and `standard` holds a sync to the operator's gap. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// The queue is not under test: record what the invalidator enqueues. +const enqueued = vi.hoisted(() => [] as Array<{ metric: string }>); +vi.mock("@/lib/jobs/insight-status-generate-shared", async (orig) => ({ + ...(await orig()), + enqueueStatusGeneration: vi.fn(async (job: { metric: string }) => { + enqueued.push(job); + }), +})); + +interface UsageState { + level: string; + preference: string | null; + defaultLevel: string; + maxLevel: string; + source: string; + options: Array<{ + level: string; + allowed: boolean; + estimatedTokensPerDay: number | null; + }>; + measuredTokensPerDay: number | null; + measuredDays: number; +} + +interface MePayload { + ai: { + capabilities: Record; + usage?: UsageState; + }; +} + +let counter = 0; + +/** A person with their own key, every receipt and the nutrients module on. */ +async function makeUser(overrides: Record = {}) { + const suffix = `${counter++}`; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `usage-${suffix}`, + email: `usage-${suffix}@example.test`, + role: "USER", + timezone: "UTC", + locale: "en", + onboardingCompletedAt: new Date(), + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + // Presence only: the probe never decrypts, so any value will do. + aiAnthropicKeyEncrypted: "v1:presence-only", + modulePreferencesJson: { nutrients: true }, + ...overrides, + }, + }); + await prisma.consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_full", + artefact: "test", + signedAt: new Date(), + }, + }); + return user; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function readMe(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + return ((await res.json()) as { data: MePayload }).data; +} + +async function putLevel(level: string | null): Promise { + const { PUT } = await import("@/app/api/auth/me/ai-usage/route"); + return (PUT as unknown as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/ai-usage", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ level }), + }), + ); +} + +/** Set the operator's usage bounds through the admin route, as an admin. */ +async function putBounds(aiUsage: Record) { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: `admin-${counter++}`, + email: `admin-${counter}@example.test`, + role: "ADMIN", + }, + }); + const previous = cookieJar.get("healthlog_session"); + await signIn(admin.id); + const { PUT } = + await import("@/app/api/admin/settings/assistant-flags/route"); + const { NextRequest } = await import("next/server"); + const res = await PUT( + new NextRequest("http://localhost/api/admin/settings/assistant-flags", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aiUsage }), + }), + ); + if (previous) cookieJar.set("healthlog_session", previous); + return res; +} + +/** A stored status note for `scope`, written `hoursAgo` hours ago. */ +async function noteWritten(userId: string, scope: string, hoursAgo: number) { + await getPrismaClient().insightStatusCache.create({ + data: { + userId, + metric: scope, + locale: "en", + textEncrypted: Buffer.from("ciphertext"), + dateKey: new Date().toISOString().slice(0, 10), + generatedAt: new Date(Date.now() - hoursAgo * 60 * 60 * 1000), + }, + }); +} + +async function invalidate(userId: string, source: "sync" | "manual") { + enqueued.length = 0; + const { invalidateStatusInsightsForTypes } = + await import("@/lib/insights/status-invalidation"); + await invalidateStatusInsightsForTypes(userId, ["ACTIVITY_STEPS"], { + source, + }); + return enqueued.map((job) => job.metric).sort(); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + enqueued.length = 0; + counter = 0; +}); + +describe("ai.usage on GET /api/auth/me", () => { + it("publishes the instance default with the measured use and the estimate", async () => { + const user = await makeUser(); + const today = new Date(); + for (let i = 1; i <= 4; i++) { + const day = new Date(today.getTime() - i * 86_400_000); + await getPrismaClient().coachUsage.create({ + data: { + userId: user.id, + dateKey: day.toISOString().slice(0, 10), + totalTokens: 100_000, + }, + }); + } + // Today is still running and does not count. + await getPrismaClient().coachUsage.create({ + data: { + userId: user.id, + dateKey: today.toISOString().slice(0, 10), + totalTokens: 9_000_000, + }, + }); + await signIn(user.id); + const { ai } = await readMe(); + expect(ai.usage).toEqual({ + level: "standard", + preference: null, + defaultLevel: "standard", + maxLevel: "standard", + source: "default", + options: [ + { level: "frugal", allowed: true, estimatedTokensPerDay: 50_000 }, + { level: "standard", allowed: true, estimatedTokensPerDay: 100_000 }, + { level: "full", allowed: false, estimatedTokensPerDay: 333_333 }, + ], + measuredTokensPerDay: 100_000, + measuredDays: 4, + }); + expect(ai.capabilities.supplementLabel).toMatchObject({ + available: true, + reason: null, + }); + }); + + it("writes the choice, closes the label photo on frugal, and refuses above the ceiling", async () => { + const user = await makeUser(); + await signIn(user.id); + + const res = await putLevel("frugal"); + expect(res.status).toBe(200); + expect( + (await getPrismaClient().user.findUnique({ where: { id: user.id } })) + ?.aiUsageLevel, + ).toBe("FRUGAL"); + const me = await readMe(); + expect(me.ai.usage).toMatchObject({ level: "frugal", source: "user" }); + expect(me.ai.capabilities.supplementLabel).toMatchObject({ + available: false, + reason: "usage_level", + }); + // Nothing else is touched by the level. + expect(me.ai.capabilities.documentAi.available).toBe(true); + + const above = await putLevel("full"); + expect(above.status).toBe(422); + expect(((await above.json()) as { meta: unknown }).meta).toMatchObject({ + errorCode: "ai.usage.aboveCeiling", + maxLevel: "standard", + }); + + expect((await putLevel(null)).status).toBe(200); + expect((await readMe()).ai.usage).toMatchObject({ + level: "standard", + preference: null, + source: "default", + }); + }); + + it("caps a stored choice the moment the operator lowers the ceiling, and keeps it", async () => { + const user = await makeUser(); + expect( + ( + await putBounds({ + maxLevel: "full", + defaultLevel: "standard", + syncRegenGapHours: null, + }) + ).status, + ).toBe(200); + await signIn(user.id); + expect((await putLevel("full")).status).toBe(200); + expect((await readMe()).ai.usage).toMatchObject({ + level: "full", + source: "user", + }); + + const lowered = await putBounds({ + maxLevel: "frugal", + defaultLevel: "frugal", + syncRegenGapHours: null, + }); + expect(lowered.status).toBe(200); + const usage = (await readMe()).ai.usage!; + expect(usage).toMatchObject({ + level: "frugal", + preference: "full", + maxLevel: "frugal", + source: "admin_cap", + }); + expect(usage.options.map((o) => o.allowed)).toEqual([true, false, false]); + + const refused = await putBounds({ + maxLevel: "frugal", + defaultLevel: "standard", + syncRegenGapHours: null, + }); + expect(refused.status).toBe(422); + }); +}); + +describe("status regeneration gap by level and source", () => { + it("frugal: no second generation per scope within 24 h of a sync; a hand entry after 6 h goes through", async () => { + const user = await makeUser({ aiUsageLevel: "FRUGAL" }); + await noteWritten(user.id, "general", 7); + await noteWritten(user.id, "metric:STEPS", 23); + // A sync 7 h and 23 h after the last generation: still inside the day. + expect(await invalidate(user.id, "sync")).toEqual([]); + // A hand entry: both scopes were generated more than six hours ago. + expect(await invalidate(user.id, "manual")).toEqual([ + "general", + "metric:STEPS", + ]); + }); + + it("frugal: a hand entry within six hours of the last generation waits", async () => { + const user = await makeUser({ aiUsageLevel: "FRUGAL" }); + await noteWritten(user.id, "general", 7); + await noteWritten(user.id, "metric:STEPS", 3); + expect(await invalidate(user.id, "manual")).toEqual(["general"]); + }); + + it("frugal: a sync a day after the last generation regenerates", async () => { + const user = await makeUser({ aiUsageLevel: "FRUGAL" }); + await noteWritten(user.id, "general", 25); + await noteWritten(user.id, "metric:STEPS", 25); + expect(await invalidate(user.id, "sync")).toEqual([ + "general", + "metric:STEPS", + ]); + }); + + it("standard: a sync waits the operator's gap, a hand entry the hour", async () => { + const user = await makeUser(); + await noteWritten(user.id, "general", 3); + await noteWritten(user.id, "metric:STEPS", 7); + expect(await invalidate(user.id, "sync")).toEqual(["metric:STEPS"]); + expect(await invalidate(user.id, "manual")).toEqual([ + "general", + "metric:STEPS", + ]); + + await putBounds({ + maxLevel: "standard", + defaultLevel: "standard", + syncRegenGapHours: 2, + }); + expect(await invalidate(user.id, "sync")).toEqual([ + "general", + "metric:STEPS", + ]); + }); +}); diff --git a/tests/integration/auth-me-ai-capabilities.test.ts b/tests/integration/auth-me-ai-capabilities.test.ts index fdabc33a0..090dbcb14 100644 --- a/tests/integration/auth-me-ai-capabilities.test.ts +++ b/tests/integration/auth-me-ai-capabilities.test.ts @@ -56,6 +56,8 @@ const ALL = [ "documentAi", "labsOcr", "medicationExtract", + // v1.43 — the supplement label photo (needs the nutrients module, on below). + "supplementLabel", ] as const; type Key = (typeof ALL)[number]; @@ -99,6 +101,8 @@ async function makeUser( aiModel: "claude-sonnet-4-6", // Presence only: the probe never decrypts, so any value will do. aiAnthropicKeyEncrypted: "v1:presence-only", + // The nutrients module is opt-in; on, so every capability can open. + modulePreferencesJson: { nutrients: true }, ...overrides, }, }); @@ -206,7 +210,7 @@ describe("GET /api/auth/me — ai", () => { await signIn(user.id); expectOnly( await readMe(), - ["documentAi", "labsOcr", "medicationExtract"], + ["documentAi", "labsOcr", "medicationExtract", "supplementLabel"], "operator_disabled", ); }); @@ -250,7 +254,13 @@ describe("GET /api/auth/me — ai", () => { await grantConsent(user.id, "ai_insights_only"); expectOnly( await readMe(), - ["coach", "documentAi", "labsOcr", "medicationExtract"], + [ + "coach", + "documentAi", + "labsOcr", + "medicationExtract", + "supplementLabel", + ], "consent_required", ); }); @@ -264,7 +274,7 @@ describe("GET /api/auth/me — ai", () => { it("reads the AI analysis opt-out as the person's own choice", async () => { const user = await makeUser("insights-off", { - modulePreferencesJson: { insights: false }, + modulePreferencesJson: { insights: false, nutrients: true }, }); await grantConsent(user.id, "ai_full"); await signIn(user.id); @@ -283,7 +293,7 @@ describe("GET /api/auth/me — ai", () => { it("closes workout notes with the workouts module", async () => { const user = await makeUser("workouts-off", { - modulePreferencesJson: { workouts: false }, + modulePreferencesJson: { workouts: false, nutrients: true }, }); await grantConsent(user.id, "ai_full"); await signIn(user.id); diff --git a/tests/integration/sharing-provider-origin.test.ts b/tests/integration/sharing-provider-origin.test.ts index e764a2002..d433804e9 100644 --- a/tests/integration/sharing-provider-origin.test.ts +++ b/tests/integration/sharing-provider-origin.test.ts @@ -126,7 +126,9 @@ async function grantManage(input: { const enqueueFromSharedMutation: (request: NextRequest) => Promise = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "medications"); - await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"]); + await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"], { + source: "manual", + }); return NextResponse.json({ data: { queued: true }, error: null }); }); diff --git a/tests/integration/summaries-pre-window-buckets.test.ts b/tests/integration/summaries-pre-window-buckets.test.ts index e8c95dea0..7c02ac22b 100644 --- a/tests/integration/summaries-pre-window-buckets.test.ts +++ b/tests/integration/summaries-pre-window-buckets.test.ts @@ -116,9 +116,11 @@ describe("all-time summaries with DAY buckets older than the fold window", () => await recomputeUserRollups(userId); // A backdated reading, as a manual entry or a sync of old samples writes // it: the hook folds that one day, window or not. - await afterMeasurementMutation(userId, [ - { type: "WEIGHT", measuredAt: old.at }, - ]); + await afterMeasurementMutation( + userId, + [{ type: "WEIGHT", measuredAt: old.at }], + { source: "manual" }, + ); await expectTrueFigures(userId, [64, 80]); });