feat: add core OCR API integration (refs #65)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 5m59s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 31s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

OCR Service (Python/FastAPI):
- POST /extract for synchronous OCR extraction
- POST /jobs and GET /jobs/{job_id} for async processing
- Image preprocessing (deskew, denoise) for accuracy
- HEIC conversion via pillow-heif
- Redis job queue for async processing

Backend (Fastify):
- POST /api/ocr/extract - authenticated proxy to OCR
- POST /api/ocr/jobs - async job submission
- GET /api/ocr/jobs/:jobId - job polling
- Multipart file upload handling
- JWT authentication required

File size limits: 10MB sync, 200MB async
Processing time target: <3 seconds for typical photos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-01 16:02:11 -06:00
parent 94e49306dc
commit 852c9013b5
25 changed files with 1931 additions and 3 deletions

View File

@@ -0,0 +1,229 @@
/**
* @ai-summary HTTP client for OCR service communication
*/
import FormData from 'form-data';
import { logger } from '../../../core/logging/logger';
import type { JobResponse, OcrResponse } from '../domain/ocr.types';
/** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
const OCR_TIMEOUT_MS = 30000; // 30 seconds for sync operations
/**
* HTTP client for communicating with the OCR service.
*/
export class OcrClient {
private readonly baseUrl: string;
constructor(baseUrl: string = OCR_SERVICE_URL) {
this.baseUrl = baseUrl;
}
/**
* Extract text from an image using OCR.
*
* @param fileBuffer - Image file buffer
* @param contentType - MIME type of the file
* @param preprocess - Whether to apply preprocessing (default: true)
* @returns OCR extraction result
*/
async extract(
fileBuffer: Buffer,
contentType: string,
preprocess: boolean = true
): Promise<OcrResponse> {
const formData = new FormData();
formData.append('file', fileBuffer, {
filename: this.getFilenameFromContentType(contentType),
contentType,
});
const url = `${this.baseUrl}/extract?preprocess=${preprocess}`;
logger.info('OCR extract request', {
operation: 'ocr.client.extract',
url,
contentType,
fileSize: fileBuffer.length,
preprocess,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData as any,
headers: formData.getHeaders(),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR extract failed', {
operation: 'ocr.client.extract.error',
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
const result = (await response.json()) as OcrResponse;
logger.info('OCR extract completed', {
operation: 'ocr.client.extract.success',
success: result.success,
documentType: result.documentType,
confidence: result.confidence,
processingTimeMs: result.processingTimeMs,
});
return result;
}
/**
* Submit an async OCR job for large files.
*
* @param fileBuffer - Image file buffer
* @param contentType - MIME type of the file
* @param callbackUrl - Optional URL to call when job completes
* @returns Job submission response
*/
async submitJob(
fileBuffer: Buffer,
contentType: string,
callbackUrl?: string
): Promise<JobResponse> {
const formData = new FormData();
formData.append('file', fileBuffer, {
filename: this.getFilenameFromContentType(contentType),
contentType,
});
if (callbackUrl) {
formData.append('callback_url', callbackUrl);
}
const url = `${this.baseUrl}/jobs`;
logger.info('OCR job submit request', {
operation: 'ocr.client.submitJob',
url,
contentType,
fileSize: fileBuffer.length,
hasCallback: !!callbackUrl,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData as any,
headers: formData.getHeaders(),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR job submit failed', {
operation: 'ocr.client.submitJob.error',
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
const result = (await response.json()) as JobResponse;
logger.info('OCR job submitted', {
operation: 'ocr.client.submitJob.success',
jobId: result.jobId,
status: result.status,
});
return result;
}
/**
* Get the status of an async OCR job.
*
* @param jobId - Job ID to check
* @returns Job status response
*/
async getJobStatus(jobId: string): Promise<JobResponse> {
const url = `${this.baseUrl}/jobs/${jobId}`;
logger.debug('OCR job status request', {
operation: 'ocr.client.getJobStatus',
jobId,
});
const response = await this.fetchWithTimeout(url, {
method: 'GET',
});
if (response.status === 404) {
throw new JobNotFoundError(jobId);
}
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR job status failed', {
operation: 'ocr.client.getJobStatus.error',
jobId,
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
return (await response.json()) as JobResponse;
}
/**
* Check if the OCR service is healthy.
*
* @returns true if healthy, false otherwise
*/
async isHealthy(): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(`${this.baseUrl}/health`, {
method: 'GET',
});
return response.ok;
} catch {
return false;
}
}
private async fetchWithTimeout(
url: string,
options: RequestInit & { headers?: Record<string, string> }
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OCR_TIMEOUT_MS);
try {
return await fetch(url, {
...options,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
private getFilenameFromContentType(contentType: string): string {
const extensions: Record<string, string> = {
'image/jpeg': 'image.jpg',
'image/png': 'image.png',
'image/heic': 'image.heic',
'image/heif': 'image.heif',
'application/pdf': 'document.pdf',
};
return extensions[contentType] || 'file.bin';
}
}
/** Error thrown when a job is not found */
export class JobNotFoundError extends Error {
constructor(jobId: string) {
super(`Job ${jobId} not found`);
this.name = 'JobNotFoundError';
}
}
/** Singleton instance */
export const ocrClient = new OcrClient();