rag-service/src/modules/ocr/client.ts

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"); }