From 92f2cc729f7c06e21ac825f1493b76bfe310165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:18:48 +0200 Subject: [PATCH] fix(documents): name the document-AI capability in each route that can queue it, and catalogue the picker's error codes The ingest path takes the capability from its caller, so the upload and the import route each read getAiCapability("documentAi") in their own source, where the AI route inventory classifies them. --- docs/api/openapi.yaml | 9 ++++- src/app/api/__tests__/ai-route-inventory.ts | 5 +++ src/app/api/documents/inbound/route.ts | 4 ++ .../sources/[system]/import/route.ts | 4 ++ src/lib/documents/ingest.ts | 11 +++++- src/lib/documents/sources/types.ts | 39 ++++++++++++------- src/lib/openapi/error-codes.ts | 15 +++++++ 7 files changed, 68 insertions(+), 19 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0827581f2..91f77e5c8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31198,8 +31198,13 @@ components: 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; - encounter — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found, + 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.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, + documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter + — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 2e49101b2..7e90462ed 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -67,6 +67,11 @@ export const AI_ROUTES: Readonly> = { capabilities: ["documentAi"], why: "The upload is data and always accepted; only the AI work queued after it follows the capability.", }, + "src/app/api/documents/sources/[system]/import/route.ts": { + kind: "mixed", + capabilities: ["documentAi"], + why: "A picked document is stored whatever AI says; only the summary queued after a fresh insert follows the capability.", + }, "src/app/api/auth/me/documents-auto-ai-read/route.ts": { kind: "mixed", capabilities: ["documentAi"], diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f2f08dd2d..90c4dde70 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -49,6 +49,7 @@ import { getClientIp, sanitiseZodIssues, } from "@/lib/api-response"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; import { @@ -480,6 +481,9 @@ async function processUpload( aiDeferred: ctx.aiDeferred, sourceKeyChecked: true, limits, + // Only the AI work queued after a fresh insert follows the capability; + // the upload itself is data and always accepted. + documentAi: () => getAiCapability("documentAi"), }); switch (result.kind) { diff --git a/src/app/api/documents/sources/[system]/import/route.ts b/src/app/api/documents/sources/[system]/import/route.ts index a2cad851e..606e3c947 100644 --- a/src/app/api/documents/sources/[system]/import/route.ts +++ b/src/app/api/documents/sources/[system]/import/route.ts @@ -40,6 +40,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { prisma } from "@/lib/db"; import { ingestDocument, @@ -241,6 +242,9 @@ export const POST = apiHandler( aiDeferred: false, sourceKeyChecked: true, limits, + // The import is data; only the summary queued after a fresh insert + // follows the capability (and the person's auto-read setting). + documentAi: () => getAiCapability("documentAi"), }); switch (result.kind) { diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts index 06614a5c9..e38df19d3 100644 --- a/src/lib/documents/ingest.ts +++ b/src/lib/documents/ingest.ts @@ -29,7 +29,7 @@ import type { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { @@ -102,6 +102,13 @@ export interface IngestInput { sourceKeyChecked: boolean; /** The per-file cap and the quota, resolved by the caller. */ limits: { maxFileBytes: number; quotaBytes: number }; + /** + * The `documentAi` capability, asked by the caller's route only once a new + * document was stored (a duplicate never asks). Passed in rather than asked + * here so each route that can queue AI work names the capability in its own + * source, where the AI route inventory reads it. + */ + documentAi: () => Promise; } export type IngestResult = @@ -358,7 +365,7 @@ export async function ingestDocument( // the detail sheet offers "Generate summary" rather than a pending state. const documentAi = input.aiDeferred ? { available: false as const, reason: "deferred" } - : await getAiCapability("documentAi"); + : await input.documentAi(); if (documentAi.available) { void enqueueDocumentSummary(userId, document.id); } else { diff --git a/src/lib/documents/sources/types.ts b/src/lib/documents/sources/types.ts index 187f670b3..1e6cdf774 100644 --- a/src/lib/documents/sources/types.ts +++ b/src/lib/documents/sources/types.ts @@ -105,23 +105,32 @@ export const DOCUMENT_PICKER_LINK_KINDS = [ export type DocumentPickerLinkKind = (typeof DOCUMENT_PICKER_LINK_KINDS)[number]; -/** Why a request to a source failed, as `meta.errorCode` suffixes. */ +/** + * Why a request to a source failed, as the `meta.errorCode` values the routes + * emit. The route helper keys its status and wording on the last segment. + */ export const DOCUMENT_SOURCE_ERROR_CODES = [ - "unavailable", - "notConnected", - "originNotAllowed", - "unreachable", - "redirected", - "authRefused", - "versionTooOld", - "badResponse", - "notFound", - "linkTargetNotFound", - "rateLimited", + "documents.sources.unavailable", + "documents.sources.notConnected", + "documents.sources.originNotAllowed", + "documents.sources.unreachable", + "documents.sources.redirected", + "documents.sources.authRefused", + "documents.sources.versionTooOld", + "documents.sources.badResponse", + "documents.sources.notFound", + "documents.sources.linkTargetNotFound", + "documents.sources.rateLimited", ] as const; -export type DocumentSourceErrorCode = - (typeof DOCUMENT_SOURCE_ERROR_CODES)[number]; +type SourceErrorSuffix = T extends `documents.sources.${infer S}` + ? S + : never; +export type DocumentSourceErrorCode = SourceErrorSuffix< + (typeof DOCUMENT_SOURCE_ERROR_CODES)[number] +>; -export function documentSourceErrorCode(code: DocumentSourceErrorCode): string { +export function documentSourceErrorCode( + code: DocumentSourceErrorCode, +): (typeof DOCUMENT_SOURCE_ERROR_CODES)[number] { return `documents.sources.${code}`; } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 92602141d..f8bbf0e7b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -136,6 +136,21 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "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.rateLimited", + "documents.sources.redirected", + "documents.sources.tokenRequired", + "documents.sources.unavailable", + "documents.sources.unreachable", + "documents.sources.versionTooOld", ], encounter: [ "encounter.invalid",