diff --git a/backend/src/features/ocr/api/ocr.controller.ts b/backend/src/features/ocr/api/ocr.controller.ts index bf27063..b59b1ac 100644 --- a/backend/src/features/ocr/api/ocr.controller.ts +++ b/backend/src/features/ocr/api/ocr.controller.ts @@ -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. diff --git a/backend/src/features/ocr/api/ocr.routes.ts b/backend/src/features/ocr/api/ocr.routes.ts index 1251c2a..ca24175 100644 --- a/backend/src/features/ocr/api/ocr.routes.ts +++ b/backend/src/features/ocr/api/ocr.routes.ts @@ -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], diff --git a/backend/src/features/ocr/domain/ocr.service.ts b/backend/src/features/ocr/domain/ocr.service.ts index 6eaaaaa..e271eea 100644 --- a/backend/src/features/ocr/domain/ocr.service.ts +++ b/backend/src/features/ocr/domain/ocr.service.ts @@ -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 { + 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. * diff --git a/backend/src/features/ocr/domain/ocr.types.ts b/backend/src/features/ocr/domain/ocr.types.ts index 314d0e5..84e112e 100644 --- a/backend/src/features/ocr/domain/ocr.types.ts +++ b/backend/src/features/ocr/domain/ocr.types.ts @@ -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; diff --git a/backend/src/features/ocr/external/ocr-client.ts b/backend/src/features/ocr/external/ocr-client.ts index d8018eb..134b5f9 100644 --- a/backend/src/features/ocr/external/ocr-client.ts +++ b/backend/src/features/ocr/external/ocr-client.ts @@ -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 { - 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 { + 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 { - 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 = { 'image/jpeg': 'image.jpg', diff --git a/frontend/src/features/vehicles/hooks/useVinOcr.ts b/frontend/src/features/vehicles/hooks/useVinOcr.ts index 07d4dfe..8845432 100644 --- a/frontend/src/features/vehicles/hooks/useVinOcr.ts +++ b/frontend/src/features/vehicles/hooks/useVinOcr.ts @@ -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 { 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 { 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, }; }