feat(medications): store a supplement's composition, read its label with the document AI, and carry it through backup and restore

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:29:40 +02:00
1 parent 98d64c2052
commit eb3cc4237f
28 files changed
+1078 -97

No files matched your search

+7
View File
@@ -279,7 +279,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"medications.course.oneShot",
"medications.course.overlap",
"medications.course.windowRequired",
"medications.ingredients.extract.budgetExceeded",
"medications.ingredients.extract.failed",
"medications.ingredients.extract.fileTooLarge",
"medications.ingredients.extract.fileType",
"medications.ingredients.extract.rateLimited",
"medications.ingredients.invalid",
"medications.ingredients.unitMismatch",
"medications.intake.bulk.apple_health_not_mirrored",
"medications.intake.bulk.invalid",
"medications.intake.bulk.too_large",
@@ -288,6 +294,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"medications.intake.injection_site.disallowed",
"medications.intake.taken_at.before_start",
"medications.mirror.limit_exceeded",
"medications.notFound",
],
mentalHealth: ["mentalHealth.rateLimited"],
module: ["module.disabled"],
@@ -15,11 +15,18 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
moduleDisabledResponse,
notImplementedResponse,
recordRefusal,
stdResponses,
} from "../shared";
import { aiRunAccepted } from "../ai-run-accepted";
import {
aiCheckFailedResponse,
aiNoProviderResponse,
aiRefusal403Description,
} from "../ai-refusal";
const NUTRIENTS_MODULE_OFF =
'The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: "nutrients"`.';
export const medicationIngredient = z
.object({
@@ -81,7 +88,7 @@ export const medicationIngredientPaths: NonNullable<ZodOpenApiObject["paths"]> =
tags: ["Medications"],
summary: "Replace a supplement's composition (v1.43)",
description:
'Stores the composition per unit in canonical units and re-counts every taken dose of this medication into the day\'s micronutrients (`recompute.queued`). Needs the `nutrients` module (403 `module.disabled`, `meta.module: "nutrients"`). 404 `medications.notFound`; 422 `medications.ingredients.invalid` (body, duplicate or unknown code); 422 `medications.ingredients.unitMismatch` with `meta.index` and `meta.allowedUnits`.',
'Stores the composition per unit in canonical units and re-counts every taken dose of this medication into the day\'s micronutrients: `recompute.queued` is true when the worker has the recompute (the rows follow within moments), false when it ran before this answer. Needs the `nutrients` module (403 `module.disabled`, `meta.module: "nutrients"`). Delegable at MANAGE on the `medications` domain. 404 `medications.notFound`; 422 `medications.ingredients.invalid` (body, duplicate code, or unknown code with `meta.index`); 422 `medications.ingredients.unitMismatch` with `meta.index` and `meta.allowedUnits`; 429 above 30 a minute per caller.',
parameters: [idempotencyKeyParameter],
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
@@ -106,13 +113,12 @@ export const medicationIngredientPaths: NonNullable<ZodOpenApiObject["paths"]> =
},
},
},
...moduleDisabledResponse,
...recordRefusal(NUTRIENTS_MODULE_OFF),
"404": {
description: "Medication not found.",
description: "`medications.notFound`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -121,7 +127,7 @@ export const medicationIngredientPaths: NonNullable<ZodOpenApiObject["paths"]> =
tags: ["Medications"],
summary: "Read a supplement label (v1.43)",
description:
"Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). 202 with an AI run; poll `GET /api/ai-runs/{id}` (kind `SUPPLEMENT_LABEL_EXTRACT`, result `SupplementLabelExtractResult`). Needs `ai.capabilities.supplementLabel.available` (refused like every AI capability, reason `usage_level` included) and the `nutrients` module. 422 `medications.ingredients.extract.fileType`, 413 `medications.ingredients.extract.fileTooLarge`, 429 `medications.ingredients.extract.rateLimited` (six an hour).",
"Multipart `file` (JPEG, PNG, WebP or PDF, at most 10 MB; the bytes decide the type, not the header). HEIC is refused with `medications.ingredients.extract.fileType`: send the photo as JPEG. 202 with an AI run; poll `GET /api/ai-runs/{id}` (kind `SUPPLEMENT_LABEL_EXTRACT`, result `SupplementLabelExtractResult`). Amounts in the proposal are per single unit: a label printed per serving of several units is divided down, and `servingNote` says what was printed. Needs `ai.capabilities.supplementLabel.available` (refused like every AI capability, reason `usage_level` included) and the `nutrients` module. The account's own session only: provider work is never admitted inside somebody else's record. 404 `medications.notFound`; 422 `medications.ingredients.extract.fileType`; 413 `medications.ingredients.extract.fileTooLarge`; 429 `medications.ingredients.extract.rateLimited` (six an hour) or `medications.ingredients.extract.budgetExceeded` (the day's AI budget). A run that fails carries `medications.ingredients.extract.failed`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
@@ -135,13 +141,24 @@ export const medicationIngredientPaths: NonNullable<ZodOpenApiObject["paths"]> =
},
responses: {
...aiRunAccepted,
...moduleDisabledResponse,
"403": {
description: `${NUTRIENTS_MODULE_OFF} ${aiRefusal403Description("supplementLabel")}`,
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "`medications.notFound`.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "`medications.ingredients.extract.fileTooLarge`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
"422": {
description: `\`medications.ingredients.extract.fileType\`: the upload is not a JPEG, PNG, WebP or PDF, or carries no \`file\` field. ${aiNoProviderResponse("supplementLabel").description}`,
content: { "application/json": { schema: errorEnvelope } },
},
"503": aiCheckFailedResponse,
},
},
},
-2
View File
@@ -28,7 +28,6 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
stdResponses,
} from "./shared";
@@ -97,7 +96,6 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
+1 -1
View File
@@ -164,7 +164,7 @@ export const aiCapabilities = z
.meta({
id: "AiCapabilities",
description:
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule.",
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level.",
});
export const aiProviderState = z
+1 -1
View File
@@ -91,7 +91,7 @@ export const errorEnvelope = z
.string()
.optional()
.describe(
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`).",
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`).",
),
reason: z
.string()