feat(documents): expose content-index provenance on the document DTO

Surface the content-search index's source (vision vs local extraction) on
the list + detail DTO so the vault UI can tell an AI-read document from a
locally-indexed one. Threads the source through both read paths and the
serialisers, regenerates the OpenAPI contract, and adds a narrowing helper
for the free-String column.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 21:15:52 +02:00
1 parent d7ff74bb2e
commit bb0ebb1dce
9 files changed
+108 -14

No files matched your search

+24 -2
View File
@@ -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
+15 -3
View File
@@ -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),
),
);
},
+9 -6
View File
@@ -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<string>();
// 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<string, string>();
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,
@@ -32,6 +32,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): 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,
@@ -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",
};
@@ -33,6 +33,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
conditionLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
+5
View File
@@ -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),
};
+4 -1
View File
@@ -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
+48 -2
View File
@@ -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;
}