import { sha256Hex } from "../../shared/utils/ids.js"; const OCR_CONFIG = { languages: ["es", "en"], dpi: 200, engine: "paddleocr", engineVersion: "3.4.0", runtimeVersion: "3.2.2", configVersion: "ocr-v1", returnLayout: true } as const; const TRANSIENT_STATUSES = new Set([502, 503]); const JOB_STATUSES = new Set(["queued", "running", "succeeded", "failed"]); export interface OcrAck { jobId: string; status: "queued"; documentSha256: string; requestedPages: number[]; configVersion: "ocr-v1"; createdAt: string; } export interface OcrJobStatus { jobId: string; status: "queued" | "running" | "succeeded" | "failed"; completedPages: number; totalPages: number; error: { code: string; message: string } | null; } export interface OcrResult { schemaVersion: "1"; jobId: string; documentSha256: string; engine: { name: "paddleocr"; version: "3.4.0"; runtime: "paddlepaddle-3.2.2"; device: "cpu"; configVersion: "ocr-v1"; dpi: 200 }; pages: Array<{ page: number; width: number; height: number; processingMs: number; text: string; metrics: { lineCount: number; nonWhitespaceCharacters: number; medianConfidence: number; p10Confidence: number; lowConfidenceLineRatio: number }; lines: Array<{ lineId: string; text: string; confidence: number; bbox: [number, number, number, number] }>; }>; } export class OcrClientError extends Error { constructor(public readonly code: string, public readonly status: number | undefined, public readonly retryable: boolean) { super(`OCR request failed: ${code}`); } } interface OcrClientOptions { baseUrl: string; token: string; fetch?: typeof globalThis.fetch; sleep?: (milliseconds: number) => Promise; } export class OcrClient { private readonly baseUrl: string; private readonly requestFetch: typeof globalThis.fetch; private readonly sleep: (milliseconds: number) => Promise; constructor(private readonly options: OcrClientOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/u, ""); this.requestFetch = options.fetch ?? globalThis.fetch; this.sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); } async submit(file: Buffer, expected: { documentSha256: string; pages: number[] }, persistedIdempotencyKey?: string): Promise { assertExpected(expected); if (sha256Hex(file) !== expected.documentSha256) throw integrityError(); const payload = { documentSha256: expected.documentSha256, pages: expected.pages, ...OCR_CONFIG }; const idempotencyKey = persistedIdempotencyKey ?? `${expected.documentSha256}:ocr-v1:${sha256Hex(JSON.stringify(expected.pages))}`; if (!idempotencyKey.trim() || /[\r\n]/u.test(idempotencyKey)) throw new TypeError("OCR idempotency key is invalid"); const value = await this.requestJson("/v1/jobs", () => { const form = new FormData(); form.set("file", new Blob([new Uint8Array(file)], { type: "application/pdf" }), "original.pdf"); form.set("request", JSON.stringify(payload)); return { method: "POST", headers: this.headers({ "Idempotency-Key": idempotencyKey }), body: form }; }); if (!isObject(value) || value.jobId === undefined || value.status !== "queued" || value.documentSha256 !== expected.documentSha256 || value.configVersion !== "ocr-v1" || !sameNumbers(value.requestedPages, expected.pages) || typeof value.createdAt !== "string" || Number.isNaN(Date.parse(value.createdAt))) throw integrityError(); return value as unknown as OcrAck; } async getStatus(jobId: string): Promise { const value = await this.requestJson(`/v1/jobs/${encodeURIComponent(jobId)}`, () => ({ headers: this.headers() })); if (!isObject(value) || value.jobId !== jobId || typeof value.status !== "string" || !JOB_STATUSES.has(value.status) || !isCount(value.completedPages) || !isCount(value.totalPages) || value.completedPages > value.totalPages || !(value.error === null || (isObject(value.error) && typeof value.error.code === "string" && typeof value.error.message === "string"))) { throw integrityError(); } return value as unknown as OcrJobStatus; } async pollUntilTerminal(jobId: string): Promise { let delay = 2_000; while (true) { const status = await this.getStatus(jobId); if (status.status === "succeeded" || status.status === "failed") return status; await this.sleep(delay); delay = Math.min(delay * 2, 15_000); } } async getResult(jobId: string, expected: { documentSha256: string; pages: number[] }): Promise { assertExpected(expected); const value = await this.requestJson(`/v1/jobs/${encodeURIComponent(jobId)}/result`, () => ({ headers: this.headers() })); if (!validResult(value, jobId, expected)) throw integrityError(); return value; } async delete(jobId: string): Promise { await this.requestJson(`/v1/jobs/${encodeURIComponent(jobId)}`, () => ({ method: "DELETE", headers: this.headers() })); } private headers(additional: Record = {}): Headers { return new Headers({ Authorization: `Bearer ${this.options.token}`, ...additional }); } private async requestJson(pathname: string, buildInit: () => RequestInit): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { let response: Response; try { response = await this.requestFetch(`${this.baseUrl}${pathname}`, buildInit()); } catch { if (attempt < 2) { await this.sleep(2_000 * 2 ** attempt); continue; } throw new OcrClientError("OCR_NETWORK_ERROR", undefined, false); } if (response.ok) return response.status === 204 ? null : response.json(); const body = await response.json().catch(() => ({})) as Record; const detail = isObject(body.detail) ? body.detail : body; const code = typeof detail.code === "string" ? detail.code : `OCR_HTTP_${response.status}`; if (TRANSIENT_STATUSES.has(response.status) && attempt < 2) { await this.sleep(2_000 * 2 ** attempt); continue; } throw new OcrClientError(code, response.status, response.status === 429); } throw new OcrClientError("OCR_RETRY_EXHAUSTED", undefined, false); } } function assertExpected(expected: { documentSha256: string; pages: number[] }): void { if (!/^[a-f0-9]{64}$/u.test(expected.documentSha256) || expected.pages.length === 0 || expected.pages.some((page, index) => !Number.isInteger(page) || page < 1 || (index > 0 && page <= expected.pages[index - 1]!))) { throw new TypeError("OCR request identity is invalid"); } } function validResult(value: unknown, jobId: string, expected: { documentSha256: string; pages: number[] }): value is OcrResult { if (!isObject(value) || value.schemaVersion !== "1" || value.jobId !== jobId || value.documentSha256 !== expected.documentSha256 || !isObject(value.engine) || canonicalEngine(value.engine) !== "paddleocr|3.4.0|paddlepaddle-3.2.2|cpu|ocr-v1|200" || !Array.isArray(value.pages) || !sameNumbers(value.pages.map((page) => isObject(page) ? page.page : undefined), expected.pages)) return false; return value.pages.every((page) => isObject(page) && isCount(page.page) && positiveCount(page.width) && positiveCount(page.height) && isCount(page.processingMs) && typeof page.text === "string" && isObject(page.metrics) && Array.isArray(page.lines) && page.lines.every((line) => isObject(line) && typeof line.lineId === "string" && line.lineId.length > 0 && typeof line.text === "string" && validRatio(line.confidence) && Array.isArray(line.bbox) && line.bbox.length === 4 && line.bbox.every(Number.isFinite)) && page.text === page.lines.map((line) => (line as Record).text).join("\n") && page.metrics.lineCount === page.lines.length && isCount(page.metrics.nonWhitespaceCharacters) && validRatio(page.metrics.medianConfidence) && validRatio(page.metrics.p10Confidence) && validRatio(page.metrics.lowConfidenceLineRatio)); } function canonicalEngine(engine: Record): string { return [engine.name, engine.version, engine.runtime, engine.device, engine.configVersion, engine.dpi].join("|"); } function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isCount(value: unknown): value is number { return Number.isInteger(value) && Number(value) >= 0; } function positiveCount(value: unknown): value is number { return isCount(value) && value > 0; } function validRatio(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1; } function sameNumbers(value: unknown, expected: number[]): boolean { return Array.isArray(value) && value.length === expected.length && value.every((entry, index) => entry === expected[index]); } function integrityError(): Error { return new Error("OCR response integrity validation failed"); }