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
|
* POST /api/ocr/jobs
|
||||||
* Submit an async OCR job for large files.
|
* Submit an async OCR job for large files.
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export const ocrRoutes: FastifyPluginAsync = async (
|
|||||||
handler: ctrl.extract.bind(ctrl),
|
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
|
// POST /api/ocr/jobs - Submit async OCR job
|
||||||
fastify.post('/ocr/jobs', {
|
fastify.post('/ocr/jobs', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
OcrExtractRequest,
|
OcrExtractRequest,
|
||||||
OcrJobSubmitRequest,
|
OcrJobSubmitRequest,
|
||||||
OcrResponse,
|
OcrResponse,
|
||||||
|
VinExtractionResponse,
|
||||||
} from './ocr.types';
|
} from './ocr.types';
|
||||||
|
|
||||||
/** Maximum file size for sync processing (10MB) */
|
/** 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.
|
* Submit an async OCR job for large files.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ export interface OcrExtractRequest {
|
|||||||
preprocess?: boolean;
|
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 */
|
/** Internal request to submit async job */
|
||||||
export interface OcrJobSubmitRequest {
|
export interface OcrJobSubmitRequest {
|
||||||
fileBuffer: Buffer;
|
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
|
* @ai-summary HTTP client for OCR service communication
|
||||||
*/
|
*/
|
||||||
import FormData from 'form-data';
|
|
||||||
import { logger } from '../../../core/logging/logger';
|
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 */
|
/** OCR service configuration */
|
||||||
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
|
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
|
||||||
@@ -32,12 +31,7 @@ export class OcrClient {
|
|||||||
contentType: string,
|
contentType: string,
|
||||||
preprocess: boolean = true
|
preprocess: boolean = true
|
||||||
): Promise<OcrResponse> {
|
): Promise<OcrResponse> {
|
||||||
const formData = new FormData();
|
const formData = this.buildFormData(fileBuffer, contentType);
|
||||||
formData.append('file', fileBuffer, {
|
|
||||||
filename: this.getFilenameFromContentType(contentType),
|
|
||||||
contentType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `${this.baseUrl}/extract?preprocess=${preprocess}`;
|
const url = `${this.baseUrl}/extract?preprocess=${preprocess}`;
|
||||||
|
|
||||||
logger.info('OCR extract request', {
|
logger.info('OCR extract request', {
|
||||||
@@ -50,8 +44,7 @@ export class OcrClient {
|
|||||||
|
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData as any,
|
body: formData,
|
||||||
headers: formData.getHeaders(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -77,6 +70,55 @@ export class OcrClient {
|
|||||||
return result;
|
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.
|
* Submit an async OCR job for large files.
|
||||||
*
|
*
|
||||||
@@ -90,11 +132,7 @@ export class OcrClient {
|
|||||||
contentType: string,
|
contentType: string,
|
||||||
callbackUrl?: string
|
callbackUrl?: string
|
||||||
): Promise<JobResponse> {
|
): Promise<JobResponse> {
|
||||||
const formData = new FormData();
|
const formData = this.buildFormData(fileBuffer, contentType);
|
||||||
formData.append('file', fileBuffer, {
|
|
||||||
filename: this.getFilenameFromContentType(contentType),
|
|
||||||
contentType,
|
|
||||||
});
|
|
||||||
if (callbackUrl) {
|
if (callbackUrl) {
|
||||||
formData.append('callback_url', callbackUrl);
|
formData.append('callback_url', callbackUrl);
|
||||||
}
|
}
|
||||||
@@ -111,8 +149,7 @@ export class OcrClient {
|
|||||||
|
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData as any,
|
body: formData,
|
||||||
headers: formData.getHeaders(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
private getFilenameFromContentType(contentType: string): string {
|
||||||
const extensions: Record<string, string> = {
|
const extensions: Record<string, string> = {
|
||||||
'image/jpeg': 'image.jpg',
|
'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> {
|
async function extractVinFromImage(file: File): Promise<VinOcrResult> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
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' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
timeout: 30000, // 30 seconds for OCR processing
|
timeout: 30000, // 30 seconds for OCR processing
|
||||||
});
|
});
|
||||||
@@ -55,19 +55,17 @@ async function extractVinFromImage(file: File): Promise<VinOcrResult> {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error('OCR extraction failed');
|
throw new Error(data.error || 'VIN extraction failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract VIN from the response
|
if (!data.vin) {
|
||||||
const vinField = data.extractedFields?.vin;
|
|
||||||
if (!vinField?.value) {
|
|
||||||
throw new Error('No VIN found in image. Please ensure the VIN is clearly visible.');
|
throw new Error('No VIN found in image. Please ensure the VIN is clearly visible.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vin: vinField.value.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''),
|
vin: data.vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''),
|
||||||
confidence: vinField.confidence,
|
confidence: data.confidence,
|
||||||
rawText: data.rawText,
|
rawText: data.vin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user