diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 756279cdb..291fc5562 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26193,6 +26193,15 @@ components: - attachment hasContentIndex: type: boolean + contentIndexSource: + anyOf: + - type: string + enum: + - vision + - text-ocr + - local-pdf + - local-ocr + - type: "null" createdAt: type: string updatedAt: @@ -26214,6 +26223,7 @@ components: - conditionLinks - servingClass - hasContentIndex + - contentIndexSource - createdAt - updatedAt additionalProperties: false @@ -26222,8 +26232,10 @@ components: (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the - document has a content-search index (drives the re-index affordance). Treat an unknown `kind` value as OTHER - when decoding. + document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was + produced — `vision` means an AI provider read the original, the other values are provider-free local + extractions, and it is null when `hasContentIndex` is false. Treat an unknown `kind` value as OTHER when + decoding. DocumentConditionLink: type: object properties: @@ -26448,6 +26460,15 @@ components: - attachment hasContentIndex: type: boolean + contentIndexSource: + anyOf: + - type: string + enum: + - vision + - text-ocr + - local-pdf + - local-ocr + - type: "null" createdAt: type: string updatedAt: @@ -26473,6 +26494,7 @@ components: - conditionLinks - servingClass - hasContentIndex + - contentIndexSource - createdAt - updatedAt - facts diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index b7b76581d..2edea326d 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -27,7 +27,10 @@ import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { documentUpdateSchema } from "@/lib/validations/inbound-documents"; +import { + documentUpdateSchema, + toContentIndexSource, +} from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string }> }; @@ -80,7 +83,7 @@ export const GET = apiHandler( loadConditionLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, - select: { id: true }, + select: { source: true }, }), ]); @@ -95,6 +98,7 @@ export const GET = apiHandler( document.facts, links.get(document.id) ?? [], contentIndex !== null, + toContentIndexSource(contentIndex?.source), ), ); }, @@ -173,7 +177,13 @@ export const PATCH = apiHandler( omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const links = await loadConditionLinks(user.id, [document.id]); + const [links, contentIndex] = await Promise.all([ + loadConditionLinks(user.id, [document.id]), + prisma.documentContentIndex.findUnique({ + where: { documentId: document.id }, + select: { source: true }, + }), + ]); const touched = [ ...Object.keys(data), @@ -199,6 +209,8 @@ export const PATCH = apiHandler( document, document.facts, links.get(document.id) ?? [], + contentIndex !== null, + toContentIndexSource(contentIndex?.source), ), ); }, diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index bf25857da..e3985b0f7 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -55,6 +55,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { documentCreateSchema, documentListQuerySchema, + toContentIndexSource, } from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; @@ -506,15 +507,16 @@ export const GET = apiHandler(async (request: Request) => { page.map((d) => d.id), ); - // Which of the page's documents have a content index (drives `hasContentIndex` - // + the re-index affordance). One indexed grouped query; never the ciphertext. - const indexedIds = new Set(); + // Which of the page's documents have a content index (drives the searchable + // status + the provenance the UI reads to tell an AI-read document from a + // locally-indexed one). One grouped query; never the ciphertext. + const indexSources = new Map(); if (page.length > 0) { const indexed = await prisma.documentContentIndex.findMany({ where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, - select: { documentId: true }, + select: { documentId: true, source: true }, }); - for (const row of indexed) indexedIds.add(row.documentId); + for (const row of indexed) indexSources.set(row.documentId, row.source); } annotate({ @@ -533,7 +535,8 @@ export const GET = apiHandler(async (request: Request) => { doc, factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 }, linkMap.get(doc.id) ?? [], - indexedIds.has(doc.id), + indexSources.has(doc.id), + toContentIndexSource(indexSources.get(doc.id)), ), ), nextCursor, diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 9e58adefa..61680c65e 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -32,6 +32,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }], servingClass: "inline", hasContentIndex: false, + contentIndexSource: null, createdAt: "2025-10-05T08:00:00.000Z", updatedAt: "2025-10-05T08:00:00.000Z", ...overrides, diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx index 6e352ff82..9d838e54f 100644 --- a/src/components/documents/__tests__/episode-documents-card.test.tsx +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -52,6 +52,7 @@ function doc(id: string, title: string): InboundDocumentDto { conditionLinks: [{ episodeId: "ep1", name: "Knee" }], servingClass: "inline", hasContentIndex: false, + contentIndexSource: null, createdAt: "2025-10-05T08:00:00.000Z", updatedAt: "2025-10-05T08:00:00.000Z", }; diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 2e9c79ca8..8af11a9c7 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -33,6 +33,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { conditionLinks: [], servingClass: "inline", hasContentIndex: false, + contentIndexSource: null, createdAt: "2026-03-11T08:00:00.000Z", updatedAt: "2026-03-11T08:00:00.000Z", ...overrides, diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index c18f2fa70..7e6d38a00 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -19,6 +19,7 @@ import type { import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentConditionLinkDto, + DocumentContentIndexSourceValue, ExtractedFactDto, FactData, FactProvenance, @@ -135,6 +136,7 @@ export function serialiseDocument( counts: { factCount: number; pendingCount: number }, conditionLinks: DocumentConditionLinkDto[] = [], hasContentIndex = false, + contentIndexSource: DocumentContentIndexSourceValue | null = null, ): InboundDocumentDto { return { id: doc.id, @@ -157,6 +159,7 @@ export function serialiseDocument( conditionLinks, servingClass: servingClassFor(doc.mimeType), hasContentIndex, + contentIndexSource: hasContentIndex ? contentIndexSource : null, createdAt: doc.createdAt.toISOString(), updatedAt: doc.updatedAt.toISOString(), }; @@ -183,6 +186,7 @@ export function serialiseDocumentDetail( facts: ExtractedFact[], conditionLinks: DocumentConditionLinkDto[] = [], hasContentIndex = false, + contentIndexSource: DocumentContentIndexSourceValue | null = null, ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part @@ -194,6 +198,7 @@ export function serialiseDocumentDetail( { factCount, pendingCount }, conditionLinks, hasContentIndex, + contentIndexSource, ), facts: facts.map(serialiseFact), }; diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 565ed7265..f43b917cb 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -108,13 +108,16 @@ const inboundDocument = z conditionLinks: z.array(conditionLink), servingClass: z.enum(["inline", "attachment"]), hasContentIndex: z.boolean(), + contentIndexSource: z + .enum(["vision", "text-ocr", "local-pdf", "local-ocr"]) + .nullable(), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "InboundDocument", description: - "A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (drives the re-index affordance). Treat an unknown `kind` value as OTHER when decoding.", + "A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. Treat an unknown `kind` value as OTHER when decoding.", }); const inboundDocumentDetail = inboundDocument diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 7b86b2c50..5a6cf8834 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -59,6 +59,45 @@ export const INBOUND_DOCUMENT_KINDS = [ ] as const; export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number]; +/** + * How a document's content index was produced (mirrors the server `source` + * column). `vision` means an AI provider read the original (the AI-first path); + * every other value is a provider-free local extraction. The UI reads it to + * tell an AI-read document from a locally-indexed one — the former already had + * the richer read, the latter can still be offered "Read with AI". + */ +export const DOCUMENT_CONTENT_INDEX_SOURCES = [ + "vision", + "text-ocr", + "local-pdf", + "local-ocr", +] as const; +export type DocumentContentIndexSourceValue = + (typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number]; + +/** True when the index was produced by an AI provider reading the original. */ +export function isAiReadSource( + source: DocumentContentIndexSourceValue | string | null, +): boolean { + return source === "vision"; +} + +/** + * Narrow the free-String DB `source` column to the DTO union, or `null` for an + * absent / unrecognised value. The column is a free String server-side (adding + * a source needs no migration) so the read path guards it before it hits the + * contract. + */ +export function toContentIndexSource( + source: string | null | undefined, +): DocumentContentIndexSourceValue | null { + return DOCUMENT_CONTENT_INDEX_SOURCES.includes( + source as DocumentContentIndexSourceValue, + ) + ? (source as DocumentContentIndexSourceValue) + : null; +} + /** The document lifecycle states. STORED is the library default. */ export const INBOUND_DOCUMENT_STATUSES = [ "STORED", @@ -230,10 +269,17 @@ export interface InboundDocumentDto { servingClass: "inline" | "attachment"; /** * Whether the document has a content-search index (encrypted extracted text + - * blind token array). Drives the "index for search" / re-index affordances; - * `false` until the document is indexed. + * blind token array). `false` until the document is indexed (auto-indexed on + * upload; the UI reads this for the searchable status, not as a to-do). */ hasContentIndex: boolean; + /** + * How that index was produced — `vision` (an AI provider read the original) + * vs a local extraction (`local-pdf` / `text-ocr` / `local-ocr`), or `null` + * when `hasContentIndex` is false. Lets the UI tell an AI-read document from + * a locally-indexed one and offer a richer "Read with AI" pass on the latter. + */ + contentIndexSource: DocumentContentIndexSourceValue | null; createdAt: string; updatedAt: string; }