mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
94 lines
3.6 KiB
TypeScript
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 };
|
|
}
|
|
}
|