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

This commit is contained in:
Eric Gullickson
2026-02-06 13:01:32 -06:00
parent 88db803b6a
commit 66314a0493
6 changed files with 244 additions and 26 deletions

View File

@@ -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.

View File

@@ -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],

View File

@@ -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.
*

View File

@@ -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;

View File

@@ -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',