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

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:
Eric Gullickson
2026-02-01 16:02:11 -06:00
parent 94e49306dc
commit 852c9013b5
25 changed files with 1931 additions and 3 deletions

View File

@@ -34,6 +34,7 @@ import { userExportRoutes } from './features/user-export';
import { userImportRoutes } from './features/user-import';
import { ownershipCostsRoutes } from './features/ownership-costs';
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
import { ocrRoutes } from './features/ocr';
import { pool } from './core/config/database';
import { configRoutes } from './core/config/config.routes';
@@ -95,7 +96,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
timestamp: new Date().toISOString(),
environment: process.env['NODE_ENV'],
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
});
});
@@ -105,7 +106,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
scope: 'api',
timestamp: new Date().toISOString(),
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
});
});
@@ -151,6 +152,7 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(subscriptionsRoutes, { prefix: '/api' });
await app.register(donationsRoutes, { prefix: '/api' });
await app.register(webhooksRoutes, { prefix: '/api' });
await app.register(ocrRoutes, { prefix: '/api' });
await app.register(configRoutes, { prefix: '/api' });
// 404 handler

View File

@@ -14,6 +14,7 @@ Feature capsule directory. Each feature is 100% self-contained with api/, domain
| `fuel-logs/` | Fuel consumption tracking | Fuel log CRUD, statistics |
| `maintenance/` | Maintenance record management | Service records, reminders |
| `notifications/` | Email and push notifications | Alert system, email templates |
| `ocr/` | OCR proxy to mvp-ocr service | Image text extraction, async jobs |
| `onboarding/` | User onboarding flow | First-time user setup |
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
| `stations/` | Gas station search and favorites | Google Maps integration, station data |

View File

@@ -0,0 +1,54 @@
# OCR Feature
Backend proxy for OCR service communication. Handles authentication, validation, and file streaming to the OCR container.
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/ocr/extract` | Synchronous OCR extraction (max 10MB) |
| POST | `/api/ocr/jobs` | Submit async OCR job (max 200MB) |
| GET | `/api/ocr/jobs/:jobId` | Poll async job status |
## Architecture
```
api/
ocr.controller.ts # Request handlers
ocr.routes.ts # Route registration
ocr.validation.ts # Request validation types
domain/
ocr.service.ts # Business logic
ocr.types.ts # TypeScript types
external/
ocr-client.ts # HTTP client to OCR service
```
## Supported File Types
- HEIC (converted server-side)
- JPEG
- PNG
- PDF (first page only)
## Response Format
```typescript
interface OcrResponse {
success: boolean;
documentType: 'vin' | 'receipt' | 'manual' | 'unknown';
rawText: string;
confidence: number; // 0.0 - 1.0
extractedFields: Record<string, { value: string; confidence: number }>;
processingTimeMs: number;
}
```
## Async Job Flow
1. POST `/api/ocr/jobs` with file
2. Receive `{ jobId, status: 'pending' }`
3. Poll GET `/api/ocr/jobs/:jobId`
4. When `status: 'completed'`, result contains OCR data
Jobs expire after 1 hour.

View 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',
});
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* @ai-summary Fastify routes for OCR API
*/
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { OcrController } from './ocr.controller';
export const ocrRoutes: FastifyPluginAsync = async (
fastify: FastifyInstance,
_opts: FastifyPluginOptions
) => {
const ctrl = new OcrController();
const requireAuth = fastify.authenticate.bind(fastify);
// POST /api/ocr/extract - Synchronous OCR extraction
fastify.post('/ocr/extract', {
preHandler: [requireAuth],
handler: ctrl.extract.bind(ctrl),
});
// POST /api/ocr/jobs - Submit async OCR job
fastify.post('/ocr/jobs', {
preHandler: [requireAuth],
handler: ctrl.submitJob.bind(ctrl),
});
// GET /api/ocr/jobs/:jobId - Get job status
fastify.get<{ Params: { jobId: string } }>('/ocr/jobs/:jobId', {
preHandler: [requireAuth],
handler: ctrl.getJobStatus.bind(ctrl),
});
};

View File

@@ -0,0 +1,18 @@
/**
* @ai-summary Validation types for OCR API
*/
/** Query parameters for OCR extract endpoint */
export interface ExtractQuery {
preprocess?: boolean;
}
/** Path parameters for job status endpoint */
export interface JobIdParams {
jobId: string;
}
/** Form data for job submission */
export interface JobSubmitBody {
callbackUrl?: string;
}

View File

@@ -0,0 +1,208 @@
/**
* @ai-summary Domain service for OCR operations
*/
import { logger } from '../../../core/logging/logger';
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
import type {
JobResponse,
OcrExtractRequest,
OcrJobSubmitRequest,
OcrResponse,
} from './ocr.types';
/** Maximum file size for sync processing (10MB) */
const MAX_SYNC_SIZE = 10 * 1024 * 1024;
/** Maximum file size for async processing (200MB) */
const MAX_ASYNC_SIZE = 200 * 1024 * 1024;
/** Supported MIME types */
const SUPPORTED_TYPES = new Set([
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'application/pdf',
]);
/**
* Domain service for OCR operations.
* Handles business logic and validation for OCR requests.
*/
export class OcrService {
/**
* Extract text from an image using synchronous OCR.
*
* @param userId - User ID for logging
* @param request - OCR extraction request
* @returns OCR extraction result
*/
async extract(userId: string, request: OcrExtractRequest): Promise<OcrResponse> {
// Validate file size for sync processing
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
const err: any = new Error(
`File too large for sync processing. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB. Use async job submission for larger files.`
);
err.statusCode = 413;
throw err;
}
// Validate content type
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('OCR extract requested', {
operation: 'ocr.service.extract',
userId,
contentType: request.contentType,
fileSize: request.fileBuffer.length,
preprocess: request.preprocess ?? true,
});
try {
const result = await ocrClient.extract(
request.fileBuffer,
request.contentType,
request.preprocess ?? true
);
logger.info('OCR extract completed', {
operation: 'ocr.service.extract.success',
userId,
success: result.success,
documentType: result.documentType,
confidence: result.confidence,
processingTimeMs: result.processingTimeMs,
textLength: result.rawText.length,
});
return result;
} catch (error) {
logger.error('OCR extract failed', {
operation: 'ocr.service.extract.error',
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* Submit an async OCR job for large files.
*
* @param userId - User ID for logging
* @param request - Job submission request
* @returns Job response with job ID
*/
async submitJob(userId: string, request: OcrJobSubmitRequest): Promise<JobResponse> {
// Validate file size for async processing
if (request.fileBuffer.length > MAX_ASYNC_SIZE) {
const err: any = new Error(
`File too large. Max: ${MAX_ASYNC_SIZE / (1024 * 1024)}MB.`
);
err.statusCode = 413;
throw err;
}
// Validate content type
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('OCR job submit requested', {
operation: 'ocr.service.submitJob',
userId,
contentType: request.contentType,
fileSize: request.fileBuffer.length,
hasCallback: !!request.callbackUrl,
});
try {
const result = await ocrClient.submitJob(
request.fileBuffer,
request.contentType,
request.callbackUrl
);
logger.info('OCR job submitted', {
operation: 'ocr.service.submitJob.success',
userId,
jobId: result.jobId,
status: result.status,
});
return result;
} catch (error) {
logger.error('OCR job submit failed', {
operation: 'ocr.service.submitJob.error',
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* Get the status of an async OCR job.
*
* @param userId - User ID for logging
* @param jobId - Job ID to check
* @returns Job status response
*/
async getJobStatus(userId: string, jobId: string): Promise<JobResponse> {
logger.debug('OCR job status requested', {
operation: 'ocr.service.getJobStatus',
userId,
jobId,
});
try {
const result = await ocrClient.getJobStatus(jobId);
logger.debug('OCR job status retrieved', {
operation: 'ocr.service.getJobStatus.success',
userId,
jobId,
status: result.status,
progress: result.progress,
});
return result;
} catch (error) {
if (error instanceof JobNotFoundError) {
const err: any = new Error(`Job ${jobId} not found. Jobs expire after 1 hour.`);
err.statusCode = 404;
throw err;
}
logger.error('OCR job status failed', {
operation: 'ocr.service.getJobStatus.error',
userId,
jobId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* Check if the OCR service is available.
*
* @returns true if OCR service is healthy
*/
async isServiceHealthy(): Promise<boolean> {
return ocrClient.isHealthy();
}
}
/** Singleton instance */
export const ocrService = new OcrService();

View File

@@ -0,0 +1,53 @@
/**
* @ai-summary TypeScript types for OCR feature
*/
/** Types of documents that can be processed by OCR */
export type DocumentType = 'vin' | 'receipt' | 'manual' | 'unknown';
/** A single extracted field with confidence score */
export interface ExtractedField {
value: string;
confidence: number;
}
/** Response from OCR extraction */
export interface OcrResponse {
success: boolean;
documentType: DocumentType;
rawText: string;
confidence: number;
extractedFields: Record<string, ExtractedField>;
processingTimeMs: number;
}
/** Status of an async OCR job */
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
/** Response for async job status */
export interface JobResponse {
jobId: string;
status: JobStatus;
progress?: number;
result?: OcrResponse;
error?: string;
}
/** Request to submit an async OCR job */
export interface JobSubmitRequest {
callbackUrl?: string;
}
/** Internal request to OCR service */
export interface OcrExtractRequest {
fileBuffer: Buffer;
contentType: string;
preprocess?: boolean;
}
/** Internal request to submit async job */
export interface OcrJobSubmitRequest {
fileBuffer: Buffer;
contentType: string;
callbackUrl?: string;
}

View File

@@ -0,0 +1,229 @@
/**
* @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';
/** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
const OCR_TIMEOUT_MS = 30000; // 30 seconds for sync operations
/**
* HTTP client for communicating with the OCR service.
*/
export class OcrClient {
private readonly baseUrl: string;
constructor(baseUrl: string = OCR_SERVICE_URL) {
this.baseUrl = baseUrl;
}
/**
* Extract text from an image using OCR.
*
* @param fileBuffer - Image file buffer
* @param contentType - MIME type of the file
* @param preprocess - Whether to apply preprocessing (default: true)
* @returns OCR extraction result
*/
async extract(
fileBuffer: Buffer,
contentType: string,
preprocess: boolean = true
): Promise<OcrResponse> {
const formData = new FormData();
formData.append('file', fileBuffer, {
filename: this.getFilenameFromContentType(contentType),
contentType,
});
const url = `${this.baseUrl}/extract?preprocess=${preprocess}`;
logger.info('OCR extract request', {
operation: 'ocr.client.extract',
url,
contentType,
fileSize: fileBuffer.length,
preprocess,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData as any,
headers: formData.getHeaders(),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR extract failed', {
operation: 'ocr.client.extract.error',
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
const result = (await response.json()) as OcrResponse;
logger.info('OCR extract completed', {
operation: 'ocr.client.extract.success',
success: result.success,
documentType: result.documentType,
confidence: result.confidence,
processingTimeMs: result.processingTimeMs,
});
return result;
}
/**
* Submit an async OCR job for large files.
*
* @param fileBuffer - Image file buffer
* @param contentType - MIME type of the file
* @param callbackUrl - Optional URL to call when job completes
* @returns Job submission response
*/
async submitJob(
fileBuffer: Buffer,
contentType: string,
callbackUrl?: string
): Promise<JobResponse> {
const formData = new FormData();
formData.append('file', fileBuffer, {
filename: this.getFilenameFromContentType(contentType),
contentType,
});
if (callbackUrl) {
formData.append('callback_url', callbackUrl);
}
const url = `${this.baseUrl}/jobs`;
logger.info('OCR job submit request', {
operation: 'ocr.client.submitJob',
url,
contentType,
fileSize: fileBuffer.length,
hasCallback: !!callbackUrl,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData as any,
headers: formData.getHeaders(),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR job submit failed', {
operation: 'ocr.client.submitJob.error',
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
const result = (await response.json()) as JobResponse;
logger.info('OCR job submitted', {
operation: 'ocr.client.submitJob.success',
jobId: result.jobId,
status: result.status,
});
return result;
}
/**
* Get the status of an async OCR job.
*
* @param jobId - Job ID to check
* @returns Job status response
*/
async getJobStatus(jobId: string): Promise<JobResponse> {
const url = `${this.baseUrl}/jobs/${jobId}`;
logger.debug('OCR job status request', {
operation: 'ocr.client.getJobStatus',
jobId,
});
const response = await this.fetchWithTimeout(url, {
method: 'GET',
});
if (response.status === 404) {
throw new JobNotFoundError(jobId);
}
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR job status failed', {
operation: 'ocr.client.getJobStatus.error',
jobId,
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
return (await response.json()) as JobResponse;
}
/**
* Check if the OCR service is healthy.
*
* @returns true if healthy, false otherwise
*/
async isHealthy(): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(`${this.baseUrl}/health`, {
method: 'GET',
});
return response.ok;
} catch {
return false;
}
}
private async fetchWithTimeout(
url: string,
options: RequestInit & { headers?: Record<string, string> }
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OCR_TIMEOUT_MS);
try {
return await fetch(url, {
...options,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
private getFilenameFromContentType(contentType: string): string {
const extensions: Record<string, string> = {
'image/jpeg': 'image.jpg',
'image/png': 'image.png',
'image/heic': 'image.heic',
'image/heif': 'image.heif',
'application/pdf': 'document.pdf',
};
return extensions[contentType] || 'file.bin';
}
}
/** Error thrown when a job is not found */
export class JobNotFoundError extends Error {
constructor(jobId: string) {
super(`Job ${jobId} not found`);
this.name = 'JobNotFoundError';
}
}
/** Singleton instance */
export const ocrClient = new OcrClient();

View File

@@ -0,0 +1,11 @@
/**
* @ai-summary Public API for OCR feature capsule
*/
export { ocrRoutes } from './api/ocr.routes';
export type {
DocumentType,
ExtractedField,
JobResponse,
JobStatus,
OcrResponse,
} from './domain/ocr.types';