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",