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:
@@ -14,6 +14,7 @@ Feature capsule directory. Each feature is 100% self-contained with api/, domain
|
||||
| `fuel-logs/` | Fuel consumption tracking | Fuel log CRUD, statistics |
|
||||
| `maintenance/` | Maintenance record management | Service records, reminders |
|
||||
| `notifications/` | Email and push notifications | Alert system, email templates |
|
||||
| `ocr/` | OCR proxy to mvp-ocr service | Image text extraction, async jobs |
|
||||
| `onboarding/` | User onboarding flow | First-time user setup |
|
||||
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
|
||||
| `stations/` | Gas station search and favorites | Google Maps integration, station data |
|
||||
|
||||
54
backend/src/features/ocr/README.md
Normal file
54
backend/src/features/ocr/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# OCR Feature
|
||||
|
||||
Backend proxy for OCR service communication. Handles authentication, validation, and file streaming to the OCR container.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/ocr/extract` | Synchronous OCR extraction (max 10MB) |
|
||||
| POST | `/api/ocr/jobs` | Submit async OCR job (max 200MB) |
|
||||
| GET | `/api/ocr/jobs/:jobId` | Poll async job status |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
api/
|
||||
ocr.controller.ts # Request handlers
|
||||
ocr.routes.ts # Route registration
|
||||
ocr.validation.ts # Request validation types
|
||||
domain/
|
||||
ocr.service.ts # Business logic
|
||||
ocr.types.ts # TypeScript types
|
||||
external/
|
||||
ocr-client.ts # HTTP client to OCR service
|
||||
```
|
||||
|
||||
## Supported File Types
|
||||
|
||||
- HEIC (converted server-side)
|
||||
- JPEG
|
||||
- PNG
|
||||
- PDF (first page only)
|
||||
|
||||
## Response Format
|
||||
|
||||
```typescript
|
||||
interface OcrResponse {
|
||||
success: boolean;
|
||||
documentType: 'vin' | 'receipt' | 'manual' | 'unknown';
|
||||
rawText: string;
|
||||
confidence: number; // 0.0 - 1.0
|
||||
extractedFields: Record<string, { value: string; confidence: number }>;
|
||||
processingTimeMs: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Async Job Flow
|
||||
|
||||
1. POST `/api/ocr/jobs` with file
|
||||
2. Receive `{ jobId, status: 'pending' }`
|
||||
3. Poll GET `/api/ocr/jobs/:jobId`
|
||||
4. When `status: 'completed'`, result contains OCR data
|
||||
|
||||
Jobs expire after 1 hour.
|
||||
275
backend/src/features/ocr/api/ocr.controller.ts
Normal file
275
backend/src/features/ocr/api/ocr.controller.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @ai-summary Controller for OCR API endpoints
|
||||
*/
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { ocrService } from '../domain/ocr.service';
|
||||
import type { ExtractQuery, JobIdParams, JobSubmitBody } from './ocr.validation';
|
||||
|
||||
/** Supported MIME types for OCR */
|
||||
const SUPPORTED_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
export class OcrController {
|
||||
/**
|
||||
* POST /api/ocr/extract
|
||||
* Extract text from an uploaded image using synchronous OCR.
|
||||
*/
|
||||
async extract(
|
||||
request: FastifyRequest<{ Querystring: ExtractQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const preprocess = request.query.preprocess !== false;
|
||||
|
||||
logger.info('OCR extract requested', {
|
||||
operation: 'ocr.controller.extract',
|
||||
userId,
|
||||
preprocess,
|
||||
});
|
||||
|
||||
// Get uploaded file
|
||||
const file = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!file) {
|
||||
logger.warn('No file provided for OCR', {
|
||||
operation: 'ocr.controller.extract.no_file',
|
||||
userId,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
const contentType = file.mimetype as string;
|
||||
if (!SUPPORTED_TYPES.has(contentType)) {
|
||||
logger.warn('Unsupported file type for OCR', {
|
||||
operation: 'ocr.controller.extract.unsupported_type',
|
||||
userId,
|
||||
contentType,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
|
||||
});
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
logger.warn('Empty file provided for OCR', {
|
||||
operation: 'ocr.controller.extract.empty_file',
|
||||
userId,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Empty file provided',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ocrService.extract(userId, {
|
||||
fileBuffer,
|
||||
contentType,
|
||||
preprocess,
|
||||
});
|
||||
|
||||
logger.info('OCR extract completed', {
|
||||
operation: 'ocr.controller.extract.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
documentType: result.documentType,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 413) {
|
||||
return reply.code(413).send({
|
||||
error: 'Payload Too Large',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 415) {
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('OCR extract failed', {
|
||||
operation: 'ocr.controller.extract.error',
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'OCR processing failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/jobs
|
||||
* Submit an async OCR job for large files.
|
||||
*/
|
||||
async submitJob(
|
||||
request: FastifyRequest<{ Body: JobSubmitBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
|
||||
logger.info('OCR job submit requested', {
|
||||
operation: 'ocr.controller.submitJob',
|
||||
userId,
|
||||
});
|
||||
|
||||
// Get uploaded file
|
||||
const file = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!file) {
|
||||
logger.warn('No file provided for OCR job', {
|
||||
operation: 'ocr.controller.submitJob.no_file',
|
||||
userId,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
const contentType = file.mimetype as string;
|
||||
if (!SUPPORTED_TYPES.has(contentType)) {
|
||||
logger.warn('Unsupported file type for OCR job', {
|
||||
operation: 'ocr.controller.submitJob.unsupported_type',
|
||||
userId,
|
||||
contentType,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
|
||||
});
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
logger.warn('Empty file provided for OCR job', {
|
||||
operation: 'ocr.controller.submitJob.empty_file',
|
||||
userId,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Empty file provided',
|
||||
});
|
||||
}
|
||||
|
||||
// Get callback URL from form data (if present)
|
||||
const callbackUrl = file.fields?.callbackUrl?.value as string | undefined;
|
||||
|
||||
try {
|
||||
const result = await ocrService.submitJob(userId, {
|
||||
fileBuffer,
|
||||
contentType,
|
||||
callbackUrl,
|
||||
});
|
||||
|
||||
logger.info('OCR job submitted', {
|
||||
operation: 'ocr.controller.submitJob.success',
|
||||
userId,
|
||||
jobId: result.jobId,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
return reply.code(202).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 413) {
|
||||
return reply.code(413).send({
|
||||
error: 'Payload Too Large',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 415) {
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('OCR job submit failed', {
|
||||
operation: 'ocr.controller.submitJob.error',
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Job submission failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ocr/jobs/:jobId
|
||||
* Get the status of an async OCR job.
|
||||
*/
|
||||
async getJobStatus(
|
||||
request: FastifyRequest<{ Params: JobIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const { jobId } = request.params;
|
||||
|
||||
logger.debug('OCR job status requested', {
|
||||
operation: 'ocr.controller.getJobStatus',
|
||||
userId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrService.getJobStatus(userId, jobId);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('OCR job status failed', {
|
||||
operation: 'ocr.controller.getJobStatus.error',
|
||||
userId,
|
||||
jobId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to retrieve job status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
31
backend/src/features/ocr/api/ocr.routes.ts
Normal file
31
backend/src/features/ocr/api/ocr.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for OCR API
|
||||
*/
|
||||
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
|
||||
import { OcrController } from './ocr.controller';
|
||||
|
||||
export const ocrRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const ctrl = new OcrController();
|
||||
const requireAuth = fastify.authenticate.bind(fastify);
|
||||
|
||||
// POST /api/ocr/extract - Synchronous OCR extraction
|
||||
fastify.post('/ocr/extract', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.extract.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/jobs - Submit async OCR job
|
||||
fastify.post('/ocr/jobs', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.submitJob.bind(ctrl),
|
||||
});
|
||||
|
||||
// GET /api/ocr/jobs/:jobId - Get job status
|
||||
fastify.get<{ Params: { jobId: string } }>('/ocr/jobs/:jobId', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.getJobStatus.bind(ctrl),
|
||||
});
|
||||
};
|
||||
18
backend/src/features/ocr/api/ocr.validation.ts
Normal file
18
backend/src/features/ocr/api/ocr.validation.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Validation types for OCR API
|
||||
*/
|
||||
|
||||
/** Query parameters for OCR extract endpoint */
|
||||
export interface ExtractQuery {
|
||||
preprocess?: boolean;
|
||||
}
|
||||
|
||||
/** Path parameters for job status endpoint */
|
||||
export interface JobIdParams {
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
/** Form data for job submission */
|
||||
export interface JobSubmitBody {
|
||||
callbackUrl?: string;
|
||||
}
|
||||
208
backend/src/features/ocr/domain/ocr.service.ts
Normal file
208
backend/src/features/ocr/domain/ocr.service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @ai-summary Domain service for OCR operations
|
||||
*/
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
|
||||
import type {
|
||||
JobResponse,
|
||||
OcrExtractRequest,
|
||||
OcrJobSubmitRequest,
|
||||
OcrResponse,
|
||||
} from './ocr.types';
|
||||
|
||||
/** Maximum file size for sync processing (10MB) */
|
||||
const MAX_SYNC_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** Maximum file size for async processing (200MB) */
|
||||
const MAX_ASYNC_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
/** Supported MIME types */
|
||||
const SUPPORTED_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Domain service for OCR operations.
|
||||
* Handles business logic and validation for OCR requests.
|
||||
*/
|
||||
export class OcrService {
|
||||
/**
|
||||
* Extract text from an image using synchronous OCR.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param request - OCR extraction request
|
||||
* @returns OCR extraction result
|
||||
*/
|
||||
async extract(userId: string, request: OcrExtractRequest): Promise<OcrResponse> {
|
||||
// Validate file size for sync processing
|
||||
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
|
||||
const err: any = new Error(
|
||||
`File too large for sync processing. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB. Use async job submission for larger files.`
|
||||
);
|
||||
err.statusCode = 413;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (!SUPPORTED_TYPES.has(request.contentType)) {
|
||||
const err: any = new Error(
|
||||
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_TYPES].join(', ')}`
|
||||
);
|
||||
err.statusCode = 415;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info('OCR extract requested', {
|
||||
operation: 'ocr.service.extract',
|
||||
userId,
|
||||
contentType: request.contentType,
|
||||
fileSize: request.fileBuffer.length,
|
||||
preprocess: request.preprocess ?? true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.extract(
|
||||
request.fileBuffer,
|
||||
request.contentType,
|
||||
request.preprocess ?? true
|
||||
);
|
||||
|
||||
logger.info('OCR extract completed', {
|
||||
operation: 'ocr.service.extract.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
documentType: result.documentType,
|
||||
confidence: result.confidence,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
textLength: result.rawText.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('OCR extract failed', {
|
||||
operation: 'ocr.service.extract.error',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async OCR job for large files.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param request - Job submission request
|
||||
* @returns Job response with job ID
|
||||
*/
|
||||
async submitJob(userId: string, request: OcrJobSubmitRequest): Promise<JobResponse> {
|
||||
// Validate file size for async processing
|
||||
if (request.fileBuffer.length > MAX_ASYNC_SIZE) {
|
||||
const err: any = new Error(
|
||||
`File too large. Max: ${MAX_ASYNC_SIZE / (1024 * 1024)}MB.`
|
||||
);
|
||||
err.statusCode = 413;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (!SUPPORTED_TYPES.has(request.contentType)) {
|
||||
const err: any = new Error(
|
||||
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_TYPES].join(', ')}`
|
||||
);
|
||||
err.statusCode = 415;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info('OCR job submit requested', {
|
||||
operation: 'ocr.service.submitJob',
|
||||
userId,
|
||||
contentType: request.contentType,
|
||||
fileSize: request.fileBuffer.length,
|
||||
hasCallback: !!request.callbackUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.submitJob(
|
||||
request.fileBuffer,
|
||||
request.contentType,
|
||||
request.callbackUrl
|
||||
);
|
||||
|
||||
logger.info('OCR job submitted', {
|
||||
operation: 'ocr.service.submitJob.success',
|
||||
userId,
|
||||
jobId: result.jobId,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('OCR job submit failed', {
|
||||
operation: 'ocr.service.submitJob.error',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of an async OCR job.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param jobId - Job ID to check
|
||||
* @returns Job status response
|
||||
*/
|
||||
async getJobStatus(userId: string, jobId: string): Promise<JobResponse> {
|
||||
logger.debug('OCR job status requested', {
|
||||
operation: 'ocr.service.getJobStatus',
|
||||
userId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.getJobStatus(jobId);
|
||||
|
||||
logger.debug('OCR job status retrieved', {
|
||||
operation: 'ocr.service.getJobStatus.success',
|
||||
userId,
|
||||
jobId,
|
||||
status: result.status,
|
||||
progress: result.progress,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof JobNotFoundError) {
|
||||
const err: any = new Error(`Job ${jobId} not found. Jobs expire after 1 hour.`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.error('OCR job status failed', {
|
||||
operation: 'ocr.service.getJobStatus.error',
|
||||
userId,
|
||||
jobId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the OCR service is available.
|
||||
*
|
||||
* @returns true if OCR service is healthy
|
||||
*/
|
||||
async isServiceHealthy(): Promise<boolean> {
|
||||
return ocrClient.isHealthy();
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance */
|
||||
export const ocrService = new OcrService();
|
||||
53
backend/src/features/ocr/domain/ocr.types.ts
Normal file
53
backend/src/features/ocr/domain/ocr.types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @ai-summary TypeScript types for OCR feature
|
||||
*/
|
||||
|
||||
/** Types of documents that can be processed by OCR */
|
||||
export type DocumentType = 'vin' | 'receipt' | 'manual' | 'unknown';
|
||||
|
||||
/** A single extracted field with confidence score */
|
||||
export interface ExtractedField {
|
||||
value: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Response from OCR extraction */
|
||||
export interface OcrResponse {
|
||||
success: boolean;
|
||||
documentType: DocumentType;
|
||||
rawText: string;
|
||||
confidence: number;
|
||||
extractedFields: Record<string, ExtractedField>;
|
||||
processingTimeMs: number;
|
||||
}
|
||||
|
||||
/** Status of an async OCR job */
|
||||
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
/** Response for async job status */
|
||||
export interface JobResponse {
|
||||
jobId: string;
|
||||
status: JobStatus;
|
||||
progress?: number;
|
||||
result?: OcrResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Request to submit an async OCR job */
|
||||
export interface JobSubmitRequest {
|
||||
callbackUrl?: string;
|
||||
}
|
||||
|
||||
/** Internal request to OCR service */
|
||||
export interface OcrExtractRequest {
|
||||
fileBuffer: Buffer;
|
||||
contentType: string;
|
||||
preprocess?: boolean;
|
||||
}
|
||||
|
||||
/** Internal request to submit async job */
|
||||
export interface OcrJobSubmitRequest {
|
||||
fileBuffer: Buffer;
|
||||
contentType: string;
|
||||
callbackUrl?: string;
|
||||
}
|
||||
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();
|
||||
11
backend/src/features/ocr/index.ts
Normal file
11
backend/src/features/ocr/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @ai-summary Public API for OCR feature capsule
|
||||
*/
|
||||
export { ocrRoutes } from './api/ocr.routes';
|
||||
export type {
|
||||
DocumentType,
|
||||
ExtractedField,
|
||||
JobResponse,
|
||||
JobStatus,
|
||||
OcrResponse,
|
||||
} from './domain/ocr.types';
|
||||
Reference in New Issue
Block a user