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 (
+
+
+
+
+ );
+}
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() {
})}