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
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:
229
backend/src/features/ocr/external/ocr-client.ts
vendored
Normal file
229
backend/src/features/ocr/external/ocr-client.ts
vendored
Normal 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();
|
||||
Reference in New Issue
Block a user