397 lines
21 KiB
TypeScript
397 lines
21 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { createApp } from "../../src/app.js";
|
|
import { env } from "../../src/config/env.js";
|
|
import { CatalogError } from "../../src/modules/catalog/errors.js";
|
|
import { CatalogRepository } from "../../src/modules/catalog/repository.js";
|
|
import { KnowledgeLifecycleReconciler } from "../../src/modules/catalog/reconciler.js";
|
|
import { OcrDispatcher } from "../../src/modules/ocr/dispatcher.js";
|
|
import { IngestService } from "../../src/modules/ingest/service.js";
|
|
import type { EmbeddingProvider } from "../../src/modules/embeddings/provider.js";
|
|
import type { VectorStoreClient } from "../../src/modules/vectorstore/client.js";
|
|
import { sha256Hex } from "../../src/shared/utils/ids.js";
|
|
|
|
function setEnvFlag(name: keyof typeof env, value: unknown): void {
|
|
(env as unknown as Record<string, unknown>)[name] = value;
|
|
}
|
|
|
|
function buildPdf(text: string): Buffer {
|
|
const stream = text ? `BT /F1 10 Tf 40 760 Td (${text.replace(/([\\()])/g, "\\$1")}) Tj ET` : "";
|
|
const objects = [
|
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
|
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`,
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
|
];
|
|
let pdf = "%PDF-1.4\n";
|
|
const offsets = [0];
|
|
objects.forEach((object, index) => {
|
|
offsets.push(Buffer.byteLength(pdf));
|
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
|
});
|
|
const xref = Buffer.byteLength(pdf);
|
|
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
pdf += offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("");
|
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
return Buffer.from(pdf);
|
|
}
|
|
|
|
function embeddingProvider(): EmbeddingProvider {
|
|
return {
|
|
providerName: "test-provider",
|
|
modelName: "test-model",
|
|
dimensions: 3,
|
|
async embed(input) { return input.map(() => [0.1, 0.2, 0.3]); }
|
|
};
|
|
}
|
|
|
|
function vectorStore(): VectorStoreClient {
|
|
return {
|
|
kind: "fake",
|
|
async upsert() {},
|
|
async countVersionPoints() { return 0; }
|
|
} as VectorStoreClient;
|
|
}
|
|
|
|
test("OCR-disabled textual PDFs retain the native synchronous path", async (context) => {
|
|
const previousLifecycle = env.knowledgeLifecycleEnforced;
|
|
setEnvFlag("knowledgeLifecycleEnforced", true);
|
|
context.after(() => setEnvFlag("knowledgeLifecycleEnforced", previousLifecycle));
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rag-ocr-disabled-"));
|
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
|
const filePath = path.join(directory, "native.pdf");
|
|
const sufficient = Array.from({ length: 24 }, (_, index) => `Alpha${index} beta${index}`).join(" ");
|
|
await writeFile(filePath, buildPdf(sufficient));
|
|
const calls: string[] = [];
|
|
const catalog = {
|
|
async beginAttempt() { return "attempt-1"; },
|
|
async updateAttempt() {},
|
|
async withSourceLock(_sourceId: string, handler: () => Promise<unknown>) { return handler(); },
|
|
async findReusableVersion() { return { versionId: "native-1", versionNumber: 1, state: "active", previousVersionId: null, expectedDocumentCount: 1 }; },
|
|
async assertActiveVersionPrecondition() { calls.push("native"); }
|
|
};
|
|
const service = new IngestService(embeddingProvider(), vectorStore(), catalog as never, {
|
|
enabled: false,
|
|
artifactRoot: directory
|
|
});
|
|
|
|
const result = await service.ingest({ sourceType: "file", sourceRef: "native.pdf", readPath: filePath, activate: true, expectedActiveVersionId: null });
|
|
|
|
assert.equal(result.state, "active");
|
|
assert.equal("phase" in result, false);
|
|
assert.deepEqual(calls, ["native"]);
|
|
});
|
|
|
|
test("OCR-required PDFs are durably accepted once and duplicate pending ingestion reuses status identity", async (context) => {
|
|
const previousLifecycle = env.knowledgeLifecycleEnforced;
|
|
setEnvFlag("knowledgeLifecycleEnforced", true);
|
|
context.after(() => setEnvFlag("knowledgeLifecycleEnforced", previousLifecycle));
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rag-ocr-accept-"));
|
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
|
const filePath = path.join(directory, "scanned.pdf");
|
|
await writeFile(filePath, buildPdf(""));
|
|
let createdInput: Record<string, unknown> | undefined;
|
|
let pending: Record<string, unknown> | undefined;
|
|
const catalog = {
|
|
async beginAttempt() { return "attempt-1"; },
|
|
async updateAttempt() {},
|
|
async withSourceLock(_sourceId: string, handler: () => Promise<unknown>) { return handler(); },
|
|
async findPendingOcrVersion() { return pending; },
|
|
async createPendingVersion(input: Record<string, unknown>) {
|
|
createdInput = input;
|
|
pending = { version: { versionId: input.versionId, versionNumber: 4, state: "indexing" }, jobs: [{ jobId: "job-1" }] };
|
|
return (pending as { version: unknown }).version;
|
|
},
|
|
async markIndexing() {}
|
|
};
|
|
const service = new IngestService(embeddingProvider(), vectorStore(), catalog as never, {
|
|
enabled: true,
|
|
artifactRoot: path.join(directory, "artifacts")
|
|
});
|
|
const source = { sourceId: "src:scan", sourceType: "file" as const, sourceRef: "scanned.pdf", readPath: filePath, activate: true, expectedActiveVersionId: null };
|
|
|
|
const accepted = await service.ingest(source);
|
|
const duplicate = await service.ingest(source);
|
|
|
|
assert.deepEqual(accepted, {
|
|
accepted: true,
|
|
sourceId: "src:scan",
|
|
versionId: accepted.versionId,
|
|
versionNumber: 4,
|
|
state: "indexing",
|
|
phase: "ocr_queued",
|
|
statusUrl: `/ingestions/${accepted.versionId}`,
|
|
reviewUrl: null,
|
|
activated: false
|
|
});
|
|
assert.equal(duplicate.versionId, accepted.versionId);
|
|
assert.equal((createdInput?.sourceContentHash), null);
|
|
assert.deepEqual((createdInput?.ocrJobs as Array<{ requestedPages: number[] }>)[0]?.requestedPages, [1]);
|
|
const originalPath = path.join(directory, "artifacts", String(accepted.versionId), "documents");
|
|
assert.equal((await readFile(path.join(directory, "artifacts", String(accepted.versionId), "manifest.json"), "utf8")).includes("scanned.pdf"), true);
|
|
assert.equal(path.isAbsolute(originalPath), true);
|
|
});
|
|
|
|
test("OCR acceptance retains every supported native document and reloads a concurrent winner", async (context) => {
|
|
const previousLifecycle = env.knowledgeLifecycleEnforced;
|
|
setEnvFlag("knowledgeLifecycleEnforced", true);
|
|
context.after(() => setEnvFlag("knowledgeLifecycleEnforced", previousLifecycle));
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rag-ocr-multi-"));
|
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
|
await writeFile(path.join(directory, "scanned.pdf"), buildPdf(""));
|
|
await writeFile(path.join(directory, "notes.txt"), "Native companion document\n");
|
|
const winner = { version: { versionId: "22222222-2222-4222-8222-222222222222", versionNumber: 7, state: "indexing" }, jobs: [{ jobId: "winner-job" }] };
|
|
let lookupCount = 0;
|
|
let candidateInput: Record<string, unknown> | undefined;
|
|
const catalog = {
|
|
async beginAttempt() { return "attempt-multi"; },
|
|
async updateAttempt() {},
|
|
async withSourceLock(_sourceId: string, handler: () => Promise<unknown>) { return handler(); },
|
|
async findPendingOcrVersion() { lookupCount += 1; return lookupCount === 1 ? undefined : winner; },
|
|
async createPendingVersion(input: Record<string, unknown>) {
|
|
candidateInput = input;
|
|
throw Object.assign(new Error("duplicate pending identity"), { code: "23505" });
|
|
}
|
|
};
|
|
const service = new IngestService(embeddingProvider(), vectorStore(), catalog as never, { enabled: true, artifactRoot: path.join(directory, "artifacts") });
|
|
|
|
const accepted = await service.ingest({ sourceId: "src:multi", sourceType: "folder", sourceRef: "bundle", readPath: directory, activate: true, expectedActiveVersionId: null });
|
|
|
|
assert.equal(accepted.versionId, winner.version.versionId);
|
|
assert.equal(candidateInput?.expectedDocumentCount, 2);
|
|
assert.deepEqual((candidateInput?.documents as Array<{ documentKey: string }>).map(({ documentKey }) => documentKey), ["notes.txt", "scanned.pdf"]);
|
|
assert.equal((candidateInput?.ocrJobs as unknown[]).length, 1);
|
|
});
|
|
|
|
test("application wiring dispatches a newly accepted OCR job from durable artifacts", async (context) => {
|
|
const previous = {
|
|
lifecycle: env.knowledgeLifecycleEnforced,
|
|
enabled: (env as unknown as Record<string, unknown>).ocrIngestEnabled,
|
|
root: (env as unknown as Record<string, unknown>).ocrArtifactRoot
|
|
};
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rag-ocr-runtime-"));
|
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
|
setEnvFlag("knowledgeLifecycleEnforced", true);
|
|
setEnvFlag("ocrIngestEnabled" as keyof typeof env, true);
|
|
setEnvFlag("ocrArtifactRoot" as keyof typeof env, path.join(directory, "artifacts"));
|
|
context.after(() => {
|
|
setEnvFlag("knowledgeLifecycleEnforced", previous.lifecycle);
|
|
setEnvFlag("ocrIngestEnabled" as keyof typeof env, previous.enabled);
|
|
setEnvFlag("ocrArtifactRoot" as keyof typeof env, previous.root);
|
|
});
|
|
const pdf = buildPdf("");
|
|
let queuedJob: Record<string, unknown> | undefined;
|
|
let persistedKey: unknown;
|
|
const dispatchCalls: string[] = [];
|
|
const catalog = {
|
|
async beginAttempt() { return "runtime-attempt"; }, async updateAttempt() {},
|
|
async withSourceLock(_sourceId: string, handler: () => Promise<unknown>) { return handler(); },
|
|
async findPendingOcrVersion() { return undefined; },
|
|
async createPendingVersion(input: Record<string, unknown>) {
|
|
const job = (input.ocrJobs as Array<Record<string, unknown>>)[0]!;
|
|
persistedKey = job.remoteIdempotencyKey;
|
|
queuedJob = { ...job, jobId: "runtime-job", versionId: input.versionId, remoteJobId: null, state: "queued", completedPages: 0, attemptCount: 0, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt: null, errorCode: null, errorDetail: null };
|
|
return { versionId: input.versionId, versionNumber: 3 };
|
|
},
|
|
async markIndexing() {},
|
|
async claimNextOcrJob() { const job = queuedJob; queuedJob = undefined; return job ? { ...job, state: "running", attemptCount: 1 } : undefined; },
|
|
async setOcrRemoteJob() {},
|
|
async requeueOcrJob() { dispatchCalls.push("requeued"); },
|
|
async completeOcrJob() { return false; }, async failOcrJob() {}, async markReviewRequired() {}, async markFailed() {}
|
|
};
|
|
const client = {
|
|
async submit(bytes: Buffer, _expected: unknown, key: string) { assert.equal(key, persistedKey); dispatchCalls.push(`${bytes.length}:${key}`); return { jobId: "runtime-remote" }; },
|
|
async getStatus() { return { jobId: "runtime-remote", status: "queued", completedPages: 0, totalPages: 1, error: null }; }
|
|
};
|
|
const server = createApp({ catalog: catalog as never, ocrClient: client as never, startReconciler: false }).listen(0);
|
|
context.after(() => server.close());
|
|
const address = server.address();
|
|
assert.ok(address && typeof address === "object");
|
|
|
|
const form = new FormData();
|
|
form.set("file", new Blob([new Uint8Array(pdf)], { type: "application/pdf" }), "runtime.pdf");
|
|
form.set("sourceId", "src:runtime");
|
|
form.set("activate", "true");
|
|
form.set("expectedActiveVersionId", "null");
|
|
const response = await fetch(`http://127.0.0.1:${address.port}/ingest/upload`, { method: "POST", body: form });
|
|
assert.equal(response.status, 202);
|
|
assert.equal((await response.json() as { uploadedResource: string }).uploadedResource, "runtime.pdf");
|
|
for (let attempt = 0; attempt < 20 && !dispatchCalls.includes("requeued"); attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
assert.equal(dispatchCalls.length, 2);
|
|
assert.match(dispatchCalls[0]!, /^\d+:.+:ocr-v1:.+$/u);
|
|
assert.equal(dispatchCalls[1], "requeued");
|
|
});
|
|
|
|
test("dispatcher recovers only expired work, reuses its remote job, and completes it once", async () => {
|
|
const calls: string[] = [];
|
|
const job = {
|
|
jobId: "job-expired",
|
|
versionId: "version-1",
|
|
documentId: "document-1",
|
|
remoteJobId: "remote-1",
|
|
remoteIdempotencyKey: "persisted-key",
|
|
state: "queued" as const,
|
|
requestedPages: [1],
|
|
completedPages: 0,
|
|
configVersion: "ocr-v1",
|
|
attemptCount: 1,
|
|
heartbeatAt: null,
|
|
leaseExpiresAt: null,
|
|
nextAttemptAt: null,
|
|
errorCode: null,
|
|
errorDetail: null
|
|
};
|
|
let available = true;
|
|
const repository = {
|
|
async recoverExpiredOcrLeases() { calls.push("recover"); return [job]; },
|
|
async claimNextOcrJob() { throw new Error("recovery must not claim unrelated queued work"); },
|
|
async claimOcrJob(jobId: string) { assert.equal(jobId, job.jobId); if (!available) return undefined; available = false; calls.push("claim-exact"); return { ...job, state: "running" as const }; },
|
|
async setOcrRemoteJob() { calls.push("submit-persist"); },
|
|
async requeueOcrJob() { calls.push("requeue"); },
|
|
async completeOcrJob() { calls.push("complete"); return true; },
|
|
async failOcrJob() { calls.push("job-failed"); },
|
|
async markReviewRequired() { calls.push("review-required"); },
|
|
async markFailed() { calls.push("version-failed"); }
|
|
};
|
|
const client = {
|
|
async submit() { calls.push("submit"); throw new Error("existing remote work must not be duplicated"); },
|
|
async getStatus() { calls.push("status"); return { jobId: "remote-1", status: "succeeded", completedPages: 1, totalPages: 1, error: null }; },
|
|
async getResult() { calls.push("result"); return { pages: [{ page: 1 }] }; }
|
|
};
|
|
const dispatcher = new OcrDispatcher(repository, client as never, async () => ({ bytes: Buffer.from("pdf"), documentSha256: sha256Hex("pdf") }));
|
|
|
|
assert.equal(await dispatcher.recoverExpiredLeases(), 1);
|
|
assert.deepEqual(calls, ["recover", "claim-exact", "status", "result", "complete", "review-required"]);
|
|
});
|
|
|
|
test("OCR exhaustion fails the leased candidate without activation or engine substitution", async () => {
|
|
const calls: string[] = [];
|
|
const repository = {
|
|
async claimNextOcrJob() {
|
|
return { jobId: "job-1", versionId: "version-1", documentId: "document-1", remoteJobId: null, remoteIdempotencyKey: "same-key", state: "running", requestedPages: [1], completedPages: 0, configVersion: "ocr-v1", attemptCount: 1, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt: null, errorCode: null, errorDetail: null };
|
|
},
|
|
async setOcrRemoteJob() { calls.push("remote"); },
|
|
async requeueOcrJob() { calls.push("requeue"); },
|
|
async completeOcrJob() { calls.push("complete"); return false; },
|
|
async failOcrJob(_jobId: string, code: string) { calls.push(`job:${code}`); },
|
|
async markReviewRequired() { calls.push("review"); },
|
|
async markFailed(_versionId: string, code: string) { calls.push(`version:${code}`); }
|
|
};
|
|
const client = { async submit() { throw new Error("OCR unavailable after retries"); } };
|
|
const dispatcher = new OcrDispatcher(repository as never, client as never, async () => ({ bytes: Buffer.from("pdf"), documentSha256: sha256Hex("pdf") }));
|
|
|
|
assert.equal(await dispatcher.runOnce(), "failed");
|
|
assert.deepEqual(calls, ["job:OCR_DISPATCH_FAILED", "version:OCR_DISPATCH_FAILED"]);
|
|
});
|
|
|
|
test("reconciler makes expired OCR leases dispatchable while leaving live leases to PostgreSQL", async () => {
|
|
const calls: string[] = [];
|
|
const catalog = {
|
|
async withGlobalTryLock(_name: string, handler: () => Promise<unknown>) { return handler(); },
|
|
async resolveActiveVersions() { return []; },
|
|
async validateActiveInvariant() { return []; },
|
|
async listOrphanedIndexingCandidates() { return []; }
|
|
};
|
|
const dispatcher = {
|
|
async recoverExpiredLeases() { calls.push("ocr-recovery"); return 1; },
|
|
async dispatchAvailable() { calls.push("ocr-dispatch"); return 2; }
|
|
};
|
|
const reconciler = new KnowledgeLifecycleReconciler(catalog as never, vectorStore(), dispatcher as never);
|
|
|
|
const status = await reconciler.runOnce();
|
|
|
|
assert.equal(status.ocrLeasesRecovered, 1);
|
|
assert.deepEqual(calls, ["ocr-recovery", "ocr-dispatch"]);
|
|
});
|
|
|
|
test("runtime HTTP routing returns native 201, OCR 202/status, and catalog-down 503", async () => {
|
|
const previous = { knowledgeLifecycleEnforced: env.knowledgeLifecycleEnforced, lifecycleAdminToken: env.lifecycleAdminToken, ocrIngestEnabled: env.ocrIngestEnabled };
|
|
setEnvFlag("knowledgeLifecycleEnforced", true);
|
|
setEnvFlag("lifecycleAdminToken", "unit-8-token");
|
|
setEnvFlag("ocrIngestEnabled", true);
|
|
const versionId = "11111111-1111-4111-8111-111111111111";
|
|
const ingestService = {
|
|
async ingest(input: Record<string, unknown>) {
|
|
return input.sourceRef === "native.pdf"
|
|
? { accepted: true, sourceId: "src:native", versionId: "native-1", versionNumber: 1, state: "active", filesDiscovered: 1, documentsProcessed: 1, chunksStored: 1, activated: true, noOp: false, collectionName: "rag" }
|
|
: { accepted: true, sourceId: "src:scan", versionId, versionNumber: 2, state: "indexing", phase: "ocr_queued", statusUrl: `/ingestions/${versionId}`, reviewUrl: null, activated: false };
|
|
},
|
|
async cleanup() { return { deleted: 0 }; }
|
|
};
|
|
const catalog = {
|
|
async getIngestionStatus(id: string) {
|
|
assert.equal(id, versionId);
|
|
return { sourceId: "src:scan", versionId, state: "indexing", phase: "ocr_running", activated: false, documents: [{ documentId: "doc:scan", state: "ocr_running", completedPages: 0, totalPages: 1, pages: [{ page: 1, method: "ocr", state: "ocr_running" }] }], error: null, statusUrl: `/ingestions/${versionId}`, reviewUrl: null };
|
|
}
|
|
};
|
|
const app = createApp({ ingestService: ingestService as never, catalog: catalog as never, startReconciler: false });
|
|
const server = app.listen(0);
|
|
try {
|
|
const address = server.address();
|
|
assert.ok(address && typeof address === "object");
|
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
const request = (sourceRef: string) => fetch(`${baseUrl}/ingest`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceType: "file", sourceRef }) });
|
|
|
|
assert.equal((await request("native.pdf")).status, 201);
|
|
const accepted = await request("scan.pdf");
|
|
assert.equal(accepted.status, 202);
|
|
assert.equal((await accepted.json() as { statusUrl: string }).statusUrl, `/ingestions/${versionId}`);
|
|
assert.equal((await fetch(`${baseUrl}/ingestions/${versionId}`)).status, 401);
|
|
const status = await fetch(`${baseUrl}/ingestions/${versionId}`, { headers: { authorization: "Bearer unit-8-token" } });
|
|
assert.equal(status.status, 200);
|
|
assert.equal((await status.json() as { phase: string }).phase, "ocr_running");
|
|
|
|
const unavailable = createApp({ ingestService: ingestService as never, catalog: undefined, startReconciler: false }).listen(0);
|
|
try {
|
|
const unavailableAddress = unavailable.address();
|
|
assert.ok(unavailableAddress && typeof unavailableAddress === "object");
|
|
const response = await fetch(`http://127.0.0.1:${unavailableAddress.port}/ingestions/${versionId}`, { headers: { authorization: "Bearer unit-8-token" } });
|
|
assert.equal(response.status, 503);
|
|
assert.equal((await response.json() as { code: string }).code, "CATALOG_UNAVAILABLE");
|
|
const retrieval = await fetch(`http://127.0.0.1:${unavailableAddress.port}/retrieve`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ query: "previous active content", mode: "documental", intent: "specific" })
|
|
});
|
|
assert.equal(retrieval.status, 503);
|
|
assert.equal((await retrieval.json() as { code: string }).code, "CATALOG_UNAVAILABLE");
|
|
} finally {
|
|
unavailable.close();
|
|
}
|
|
} finally {
|
|
server.close();
|
|
setEnvFlag("knowledgeLifecycleEnforced", previous.knowledgeLifecycleEnforced);
|
|
setEnvFlag("lifecycleAdminToken", previous.lifecycleAdminToken);
|
|
setEnvFlag("ocrIngestEnabled", previous.ocrIngestEnabled);
|
|
}
|
|
});
|
|
|
|
test("status reports all native and OCR documents and fails the version when one document fails", async () => {
|
|
let query = 0;
|
|
const pool = { async query() {
|
|
query += 1;
|
|
if (query === 1) return { rowCount: 1, rows: [{ source_id: "src:mixed", state: "failed", error_code: "OCR_QUALITY_BLOCKED", error_detail: "Visible ink failed OCR quality" }] };
|
|
if (query === 2) return { rowCount: 2, rows: [
|
|
{ document_id: "doc:native", index_state: "indexing", job_state: null, completed_pages: null, requested_pages: null },
|
|
{ document_id: "doc:ocr", index_state: "failed", job_state: "failed", completed_pages: 0, requested_pages: [2] }
|
|
] };
|
|
return { rowCount: 3, rows: [
|
|
{ document_id: "doc:native", page_number: 1, extraction_method: "native", blocked_reason: null },
|
|
{ document_id: "doc:ocr", page_number: 1, extraction_method: "native", blocked_reason: null },
|
|
{ document_id: "doc:ocr", page_number: 2, extraction_method: "ocr", blocked_reason: "OCR_QUALITY_BLOCKED" }
|
|
] };
|
|
} };
|
|
const status = await new CatalogRepository(pool as never).getIngestionStatus("version-mixed") as {
|
|
phase: string; documents: Array<{ documentId: string; completedPages: number; totalPages: number }>; error: { code: string }
|
|
};
|
|
|
|
assert.equal(status.phase, "failed");
|
|
assert.deepEqual(status.documents.map(({ documentId, completedPages, totalPages }) => [documentId, completedPages, totalPages]), [
|
|
["doc:native", 1, 1], ["doc:ocr", 1, 2]
|
|
]);
|
|
assert.equal(status.error.code, "OCR_QUALITY_BLOCKED");
|
|
});
|