fix: OCR API error
All checks were successful
Deploy to Staging / Build Images (push) Successful in 7m45s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 2m31s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 7m45s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 2m31s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
This commit is contained in:
@@ -123,6 +123,106 @@ export class OcrController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/extract/vin
|
||||
* Extract VIN from an uploaded image using VIN-specific OCR.
|
||||
*/
|
||||
async extractVin(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
|
||||
logger.info('VIN extract requested', {
|
||||
operation: 'ocr.controller.extractVin',
|
||||
userId,
|
||||
});
|
||||
|
||||
const file = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!file) {
|
||||
logger.warn('No file provided for VIN extraction', {
|
||||
operation: 'ocr.controller.extractVin.no_file',
|
||||
userId,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided',
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = file.mimetype as string;
|
||||
if (!SUPPORTED_TYPES.has(contentType)) {
|
||||
logger.warn('Unsupported file type for VIN extraction', {
|
||||
operation: 'ocr.controller.extractVin.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`,
|
||||
});
|
||||
}
|
||||
|
||||
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 VIN extraction', {
|
||||
operation: 'ocr.controller.extractVin.empty_file',
|
||||
userId,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Empty file provided',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ocrService.extractVin(userId, {
|
||||
fileBuffer,
|
||||
contentType,
|
||||
});
|
||||
|
||||
logger.info('VIN extract completed', {
|
||||
operation: 'ocr.controller.extractVin.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
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('VIN extract failed', {
|
||||
operation: 'ocr.controller.extractVin.error',
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'VIN extraction failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/jobs
|
||||
* Submit an async OCR job for large files.
|
||||
|
||||
@@ -17,6 +17,12 @@ export const ocrRoutes: FastifyPluginAsync = async (
|
||||
handler: ctrl.extract.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/extract/vin - VIN-specific OCR extraction
|
||||
fastify.post('/ocr/extract/vin', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.extractVin.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/jobs - Submit async OCR job
|
||||
fastify.post('/ocr/jobs', {
|
||||
preHandler: [requireAuth],
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
OcrExtractRequest,
|
||||
OcrJobSubmitRequest,
|
||||
OcrResponse,
|
||||
VinExtractionResponse,
|
||||
} from './ocr.types';
|
||||
|
||||
/** Maximum file size for sync processing (10MB) */
|
||||
@@ -92,6 +93,63 @@ export class OcrService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract VIN from an image using VIN-specific OCR.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param request - OCR extraction request
|
||||
* @returns VIN extraction result
|
||||
*/
|
||||
async extractVin(userId: string, request: OcrExtractRequest): Promise<VinExtractionResponse> {
|
||||
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
|
||||
const err: any = new Error(
|
||||
`File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.`
|
||||
);
|
||||
err.statusCode = 413;
|
||||
throw err;
|
||||
}
|
||||
|
||||
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('VIN extract requested', {
|
||||
operation: 'ocr.service.extractVin',
|
||||
userId,
|
||||
contentType: request.contentType,
|
||||
fileSize: request.fileBuffer.length,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.extractVin(
|
||||
request.fileBuffer,
|
||||
request.contentType
|
||||
);
|
||||
|
||||
logger.info('VIN extract completed', {
|
||||
operation: 'ocr.service.extractVin.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
vin: result.vin,
|
||||
confidence: result.confidence,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('VIN extract failed', {
|
||||
operation: 'ocr.service.extractVin.error',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async OCR job for large files.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,17 @@ export interface OcrExtractRequest {
|
||||
preprocess?: boolean;
|
||||
}
|
||||
|
||||
/** Response from VIN-specific extraction */
|
||||
export interface VinExtractionResponse {
|
||||
success: boolean;
|
||||
vin: string | null;
|
||||
confidence: number;
|
||||
boundingBox: { x: number; y: number; width: number; height: number } | null;
|
||||
alternatives: { vin: string; confidence: number }[];
|
||||
processingTimeMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Internal request to submit async job */
|
||||
export interface OcrJobSubmitRequest {
|
||||
fileBuffer: Buffer;
|
||||
|
||||
79
backend/src/features/ocr/external/ocr-client.ts
vendored
79
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* @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';
|
||||
import type { JobResponse, OcrResponse, VinExtractionResponse } from '../domain/ocr.types';
|
||||
|
||||
/** OCR service configuration */
|
||||
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
|
||||
@@ -32,12 +31,7 @@ export class OcrClient {
|
||||
contentType: string,
|
||||
preprocess: boolean = true
|
||||
): Promise<OcrResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileBuffer, {
|
||||
filename: this.getFilenameFromContentType(contentType),
|
||||
contentType,
|
||||
});
|
||||
|
||||
const formData = this.buildFormData(fileBuffer, contentType);
|
||||
const url = `${this.baseUrl}/extract?preprocess=${preprocess}`;
|
||||
|
||||
logger.info('OCR extract request', {
|
||||
@@ -50,8 +44,7 @@ export class OcrClient {
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: formData as any,
|
||||
headers: formData.getHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -77,6 +70,55 @@ export class OcrClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract VIN from an image using VIN-specific OCR.
|
||||
*
|
||||
* @param fileBuffer - Image file buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @returns VIN extraction result
|
||||
*/
|
||||
async extractVin(
|
||||
fileBuffer: Buffer,
|
||||
contentType: string
|
||||
): Promise<VinExtractionResponse> {
|
||||
const formData = this.buildFormData(fileBuffer, contentType);
|
||||
const url = `${this.baseUrl}/extract/vin`;
|
||||
|
||||
logger.info('OCR VIN extract request', {
|
||||
operation: 'ocr.client.extractVin',
|
||||
url,
|
||||
contentType,
|
||||
fileSize: fileBuffer.length,
|
||||
});
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('OCR VIN extract failed', {
|
||||
operation: 'ocr.client.extractVin.error',
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as VinExtractionResponse;
|
||||
|
||||
logger.info('OCR VIN extract completed', {
|
||||
operation: 'ocr.client.extractVin.success',
|
||||
success: result.success,
|
||||
vin: result.vin,
|
||||
confidence: result.confidence,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async OCR job for large files.
|
||||
*
|
||||
@@ -90,11 +132,7 @@ export class OcrClient {
|
||||
contentType: string,
|
||||
callbackUrl?: string
|
||||
): Promise<JobResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileBuffer, {
|
||||
filename: this.getFilenameFromContentType(contentType),
|
||||
contentType,
|
||||
});
|
||||
const formData = this.buildFormData(fileBuffer, contentType);
|
||||
if (callbackUrl) {
|
||||
formData.append('callback_url', callbackUrl);
|
||||
}
|
||||
@@ -111,8 +149,7 @@ export class OcrClient {
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: formData as any,
|
||||
headers: formData.getHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -205,6 +242,14 @@ export class OcrClient {
|
||||
}
|
||||
}
|
||||
|
||||
private buildFormData(fileBuffer: Buffer, contentType: string): FormData {
|
||||
const filename = this.getFilenameFromContentType(contentType);
|
||||
const blob = new Blob([fileBuffer], { type: contentType });
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, filename);
|
||||
return formData;
|
||||
}
|
||||
|
||||
private getFilenameFromContentType(contentType: string): string {
|
||||
const extensions: Record<string, string> = {
|
||||
'image/jpeg': 'image.jpg',
|
||||
|
||||
@@ -41,13 +41,13 @@ export interface UseVinOcrReturn extends UseVinOcrState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract VIN from image using OCR service
|
||||
* Extract VIN from image using VIN-specific OCR endpoint
|
||||
*/
|
||||
async function extractVinFromImage(file: File): Promise<VinOcrResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post('/ocr/extract', formData, {
|
||||
const response = await apiClient.post('/ocr/extract/vin', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000, // 30 seconds for OCR processing
|
||||
});
|
||||
@@ -55,19 +55,17 @@ async function extractVinFromImage(file: File): Promise<VinOcrResult> {
|
||||
const data = response.data;
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('OCR extraction failed');
|
||||
throw new Error(data.error || 'VIN extraction failed');
|
||||
}
|
||||
|
||||
// Extract VIN from the response
|
||||
const vinField = data.extractedFields?.vin;
|
||||
if (!vinField?.value) {
|
||||
if (!data.vin) {
|
||||
throw new Error('No VIN found in image. Please ensure the VIN is clearly visible.');
|
||||
}
|
||||
|
||||
return {
|
||||
vin: vinField.value.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''),
|
||||
confidence: vinField.confidence,
|
||||
rawText: data.rawText,
|
||||
vin: data.vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''),
|
||||
confidence: data.confidence,
|
||||
rawText: data.vin,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user