mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
d7ff74bb2e
commit
bb0ebb1dce
9 files changed
+108
-14
No files matched your search
+24
-2
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user