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:
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user