179 lines
8.9 KiB
TypeScript
179 lines
8.9 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
export class OcrClient {
|
|
private readonly baseUrl: string;
|
|
private readonly requestFetch: typeof globalThis.fetch;
|
|
private readonly sleep: (milliseconds: number) => Promise<void>;
|
|
|
|
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<OcrAck> {
|
|
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<OcrJobStatus> {
|
|
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<OcrJobStatus> {
|
|
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<OcrResult> {
|
|
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<void> {
|
|
await this.requestJson(`/v1/jobs/${encodeURIComponent(jobId)}`, () => ({ method: "DELETE", headers: this.headers() }));
|
|
}
|
|
|
|
private headers(additional: Record<string, string> = {}): Headers {
|
|
return new Headers({ Authorization: `Bearer ${this.options.token}`, ...additional });
|
|
}
|
|
|
|
private async requestJson(pathname: string, buildInit: () => RequestInit): Promise<unknown> {
|
|
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<string, unknown>;
|
|
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<string, unknown>).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, unknown>): string {
|
|
return [engine.name, engine.version, engine.runtime, engine.device, engine.configVersion, engine.dpi].join("|");
|
|
}
|
|
function isObject(value: unknown): value is Record<string, unknown> { 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"); }
|