Files
HealthLog/src/lib/jobs/document-index.ts
T
Marc-André Bombeck 73de8f6a52 feat(documents): bidirectional labs ↔ documents cross-link
Two directions, folding the vault and labs pipelines into one linking path
without weakening either manual flow.

Vault document → lab values (auto-stage). After the per-document auto-index
job transcribes a stored document, a lab-looking document is run through the
same inbound extraction the manual "Extract" button uses, but the facts land
PENDING for the existing human review — nothing is committed. Gated on both
the documents and labs modules plus the auto-AI-read consent gate the
auto-index external path already uses; a text-structuring pass over the
transcription the index just produced, so no second vision call is spent. The
staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or
an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call.
Everyone without the opt-in keeps the manual extract button.

Lab scan → vault. A lab report scanned in the labs area is now also filed
into the Documents vault (kind LAB_RESULT) through the existing upload —
encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the
committed labs are cross-linked back to it with the same APPROVED-observation
provenance the manual vault → labs path produces. Text-mode scans keep the
image on-device and file nothing. Idempotent: a re-commit inserts no rows and
links nothing new, and the vault dedupe means no duplicate document.

Owner-scoped and module-gated on both sides; the cross-link is best-effort so
it never fails the authoritative write.
2026-07-17 00:01:45 +02:00

94 lines
3.6 KiB
TypeScript

/**
* Automatic per-document content indexing, enqueued the moment a document is
* stored (`POST /api/documents/inbound`). Fire-and-forget: the upload response
* never blocks on or fails because of indexing.
*
* The job runs the shared AI-first decision tree (`indexDocumentContent`):
* provider (vision) first when one is configured + consented, else local
* text-layer extraction. Owner-scoped; the provider path is consent + budget
* gated; the local path needs neither (no egress). Serial concurrency keeps the
* provider calls + PDF parsing off the request pool.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { indexDocumentContent } from "@/lib/documents/index-document";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
export const DOCUMENT_INDEX_QUEUE = "document-index";
/** Serial concurrency — provider calls + PDF parse, kept off the request pool. */
export const DOCUMENT_INDEX_CONCURRENCY = 1;
export interface DocumentIndexPayload {
userId: string;
documentId: string;
enqueuedAt?: string;
}
/**
* Index one document (owner-scoped, provider-first/local-fallback). Never
* throws for an expected outcome — the decision tree returns a tagged result —
* so pg-boss does not retry a document that simply has no indexable text.
*/
export async function runDocumentIndex(
payload: DocumentIndexPayload,
): Promise<void> {
const { userId, documentId } = payload;
if (!userId || !documentId) return;
const outcome = await indexDocumentContent(userId, documentId);
annotate({
action: { name: "documents.autoIndex.run" },
meta: outcome.indexed
? { documentId, indexed: true, source: outcome.source }
: { documentId, indexed: false, reason: outcome.reason },
});
// S8 — once the document is indexed, auto-stage lab facts for a lab-looking
// document (both modules on + the auto-AI-read consent gate). Facts land
// PENDING for the existing human review; nothing is committed. Fully gated +
// idempotent inside; a failure here must never fail the index job, so it is
// swallowed to a no-op (the manual extract button remains the fallback).
if (outcome.indexed) {
await maybeAutoStageLabFacts(userId, documentId).catch(() => {});
}
}
/**
* Enqueue a per-document index job. Coalesced by `singletonKey` per document so
* a duplicate/idempotent re-upload cannot pile up parallel runs. Fire-and-
* forget — a missing boss (worker not up) is a no-op, and a `boss.send` failure
* (a transient DB hiccup) is swallowed to a no-op too, so enqueue can NEVER fail
* an already-stored upload. Indexing is a background nicety; the stored document
* is the contract. A dropped enqueue is recoverable via the corpus backfill.
*/
export async function enqueueDocumentIndex(
userId: string,
documentId: string,
): Promise<{ enqueued: boolean }> {
const boss = getGlobalBoss();
if (!boss) return { enqueued: false };
const payload: DocumentIndexPayload = {
userId,
documentId,
enqueuedAt: new Date().toISOString(),
};
try {
const jobId = await boss.send(DOCUMENT_INDEX_QUEUE, payload, {
retryLimit: 2,
retryDelay: 30,
retryBackoff: true,
singletonKey: `document-index|${documentId}`,
});
return { enqueued: Boolean(jobId) };
} catch (err) {
annotate({
action: { name: "documents.autoIndex.enqueueFailed" },
meta: { documentId, reason: err instanceof Error ? err.name : "unknown" },
});
return { enqueued: false };
}
}