Skip to content

Commit 0806c3f

Browse files
authored
Merge pull request #61 from bcgov/AI-1042
Add document image normalization docs
2 parents 9fa233e + edbdb52 commit 0806c3f

53 files changed

Lines changed: 2121 additions & 354 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend-services/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@
6060
"openid-client": "^6.8.2",
6161
"passport": "^0.7.0",
6262
"passport-jwt": "^4.0.1",
63+
"pdf-lib": "^1.17.1",
6364
"pg": "^8.16.3",
6465
"prisma": "7.2.0",
6566
"prom-client": "^15.1.3",
6667
"rxjs": "^7.8.2",
68+
"sharp": "^0.34.5",
6769
"uuid": "13.0.0"
6870
},
6971
"devDependencies": {

apps/backend-services/src/benchmark/ground-truth-generation.service.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
BlobStorageInterface,
7777
} from "@/blob-storage/blob-storage.interface";
7878
import { DocumentService } from "@/document/document.service";
79+
import { PdfNormalizationService } from "@/document/pdf-normalization.service";
7980
import { OcrService } from "@/ocr/ocr.service";
8081
import { GroundTruthGenerationService } from "./ground-truth-generation.service";
8182
import { GroundTruthJobDbService } from "./ground-truth-job-db.service";
@@ -102,6 +103,13 @@ const mockHitlDatasetService = {
102103
buildGroundTruth: jest.fn(),
103104
};
104105

106+
const mockPdfNormalizationService = {
107+
validateForUpload: jest.fn().mockResolvedValue(undefined),
108+
normalizeToPdf: jest
109+
.fn()
110+
.mockImplementation((buf: Buffer) => Promise.resolve(Buffer.from(buf))),
111+
};
112+
105113
const sampleManifest = {
106114
schemaVersion: "1.0",
107115
samples: [
@@ -148,6 +156,10 @@ describe("GroundTruthGenerationService", () => {
148156
provide: HitlDatasetService,
149157
useValue: mockHitlDatasetService,
150158
},
159+
{
160+
provide: PdfNormalizationService,
161+
useValue: mockPdfNormalizationService,
162+
},
151163
{
152164
provide: BLOB_STORAGE,
153165
useValue: mockBlobStorage,

apps/backend-services/src/benchmark/ground-truth-generation.service.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
BlobStorageInterface,
1818
} from "@/blob-storage/blob-storage.interface";
1919
import { DocumentService } from "@/document/document.service";
20+
import { extensionForOriginalBlob } from "@/document/original-blob-key.util";
21+
import {
22+
PdfNormalizationError,
23+
PdfNormalizationService,
24+
} from "@/document/pdf-normalization.service";
2025
import { ExtractedFields } from "@/ocr/azure-types";
2126
import { OcrService } from "@/ocr/ocr.service";
2227
import {
@@ -54,6 +59,7 @@ export class GroundTruthGenerationService {
5459
private readonly documentService: DocumentService,
5560
private readonly ocrService: OcrService,
5661
private readonly hitlDatasetService: HitlDatasetService,
62+
private readonly pdfNormalization: PdfNormalizationService,
5763
@Inject(BLOB_STORAGE) private readonly blobStorage: BlobStorageInterface,
5864
) {}
5965

@@ -233,7 +239,10 @@ export class GroundTruthGenerationService {
233239
? "image"
234240
: "pdf";
235241
const originalFilename = path.basename(inputFile.path);
236-
const ext = path.extname(originalFilename);
242+
const originalExtension = extensionForOriginalBlob(
243+
originalFilename,
244+
fileType,
245+
);
237246

238247
// Read modelId from workflow config ctx defaults
239248
const workflow = await this.groundTruthJobDbService.findWorkflowConfig(
@@ -246,19 +255,74 @@ export class GroundTruthGenerationService {
246255
(workflowConfig?.ctx?.modelId?.defaultValue as string) ||
247256
"prebuilt-layout";
248257

249-
// Create document record
250258
const documentId = crypto.randomUUID();
251-
const docBlobKey = `documents/${documentId}/original${ext}`;
259+
const docBlobKey = `documents/${documentId}/original.${originalExtension}`;
260+
261+
await this.pdfNormalization.validateForUpload(fileBuffer, fileType);
252262

253-
// Write file to document storage
254263
await this.blobStorage.write(docBlobKey, fileBuffer);
255264

265+
const normalizedKey = `documents/${documentId}/normalized.pdf`;
266+
try {
267+
const pdfBuffer = await this.pdfNormalization.normalizeToPdf(
268+
fileBuffer,
269+
fileType,
270+
);
271+
await this.blobStorage.write(normalizedKey, pdfBuffer);
272+
} catch (e) {
273+
if (e instanceof BadRequestException) {
274+
throw e;
275+
}
276+
if (!(e instanceof PdfNormalizationError)) {
277+
this.logger.warn(
278+
`Ground truth job ${jobId}: unexpected normalization error: ${
279+
e instanceof Error ? e.message : String(e)
280+
}`,
281+
);
282+
}
283+
const errorMessage =
284+
e instanceof PdfNormalizationError
285+
? e.message
286+
: "Document could not be converted to PDF.";
287+
const failedDoc = {
288+
id: documentId,
289+
title: job.sampleId,
290+
original_filename: originalFilename,
291+
file_path: docBlobKey,
292+
normalized_file_path: null as string | null,
293+
file_type: fileType,
294+
file_size: fileBuffer.length,
295+
metadata: {
296+
source: "ground-truth-generation",
297+
datasetId,
298+
datasetVersionId: versionId,
299+
sampleId: job.sampleId,
300+
} as Prisma.JsonValue,
301+
source: "ground-truth-generation",
302+
status: DocumentStatus.conversion_failed,
303+
apim_request_id: null,
304+
workflow_id: null,
305+
workflow_config_id: job.workflowConfigId,
306+
workflow_execution_id: null,
307+
model_id: modelId,
308+
group_id: groupId,
309+
};
310+
await this.documentService.createDocument(failedDoc);
311+
await this.groundTruthJobDbService.updateJob(jobId, {
312+
documentId,
313+
status: GroundTruthJobStatus.failed,
314+
error: errorMessage,
315+
});
316+
return;
317+
}
318+
256319
// Create document in DB
257320
const documentData = {
258321
id: documentId,
259322
title: job.sampleId,
260323
original_filename: originalFilename,
261324
file_path: docBlobKey,
325+
normalized_file_path: normalizedKey,
262326
file_type: fileType,
263327
file_size: fileBuffer.length,
264328
metadata: {

apps/backend-services/src/benchmark/hitl-dataset.service.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ describe("HitlDatasetService", () => {
8383
id: "doc-1",
8484
original_filename: "invoice-001.pdf",
8585
file_path: "documents/doc-1/original.pdf",
86+
normalized_file_path: "documents/doc-1/normalized.pdf",
8687
file_type: "pdf",
8788
status: "completed_ocr",
8889
ocr_result: { keyValuePairs: mockOcrFields },
@@ -92,6 +93,7 @@ describe("HitlDatasetService", () => {
9293
id: "doc-2",
9394
original_filename: "invoice-002.pdf",
9495
file_path: "documents/doc-2/original.pdf",
96+
normalized_file_path: "documents/doc-2/normalized.pdf",
9597
file_type: "pdf",
9698
status: "completed_ocr",
9799
ocr_result: { keyValuePairs: mockOcrFields },

apps/backend-services/src/benchmark/hitl-dataset.service.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface DocumentWithReview {
3333
id: string;
3434
original_filename: string;
3535
file_path: string;
36+
normalized_file_path: string | null;
3637
file_type: string;
3738
ocr_result: {
3839
keyValuePairs: unknown;
@@ -345,13 +346,20 @@ export class HitlDatasetService {
345346
}
346347
usedSampleIds.add(sampleId);
347348

348-
// Copy the original document file
349-
const inputFilename = `${sampleId}${ext}`;
349+
if (!doc.normalized_file_path) {
350+
throw new Error(
351+
"Document has no normalized PDF; cannot export to dataset",
352+
);
353+
}
354+
355+
const inputFilename = `${sampleId}.pdf`;
350356
const inputRelativePath = `inputs/${inputFilename}`;
351357
const inputBlobKey = `${storagePrefix}/${inputRelativePath}`;
352358

353-
const originalFileBuffer = await this.blobStorage.read(doc.file_path);
354-
await this.blobStorage.write(inputBlobKey, originalFileBuffer);
359+
const normalizedPdfBuffer = await this.blobStorage.read(
360+
doc.normalized_file_path,
361+
);
362+
await this.blobStorage.write(inputBlobKey, normalizedPdfBuffer);
355363

356364
// Build ground truth as flat key-value pairs (same format as uploaded
357365
// ground truth and as predictions produced by extractPredictionFromCtx
@@ -369,8 +377,7 @@ export class HitlDatasetService {
369377
Buffer.from(JSON.stringify(groundTruth, null, 2)),
370378
);
371379

372-
// Determine MIME type
373-
const mimeType = this.getMimeType(ext);
380+
const mimeType = "application/pdf";
374381

375382
return {
376383
id: sampleId,
@@ -445,19 +452,6 @@ export class HitlDatasetService {
445452

446453
return groundTruth;
447454
}
448-
449-
private getMimeType(ext: string): string {
450-
const mimeTypes: Record<string, string> = {
451-
".pdf": "application/pdf",
452-
".png": "image/png",
453-
".jpg": "image/jpeg",
454-
".jpeg": "image/jpeg",
455-
".tif": "image/tiff",
456-
".tiff": "image/tiff",
457-
".bmp": "image/bmp",
458-
};
459-
return mimeTypes[ext.toLowerCase()] ?? "application/octet-stream";
460-
}
461455
}
462456

463457
/**

apps/backend-services/src/document/document-db.service.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const makeDocument = (overrides: Partial<DocumentData> = {}): DocumentData => ({
3333
title: "Test Document",
3434
original_filename: "test.pdf",
3535
file_path: "documents/doc-1/original.pdf",
36+
normalized_file_path: "documents/doc-1/normalized.pdf",
3637
file_type: "pdf",
3738
file_size: 1024,
3839
metadata: {},
@@ -72,6 +73,7 @@ describe("DocumentDbService", () => {
7273
title: doc.title,
7374
original_filename: doc.original_filename,
7475
file_path: doc.file_path,
76+
normalized_file_path: doc.normalized_file_path,
7577
file_type: doc.file_type,
7678
file_size: doc.file_size,
7779
metadata: doc.metadata,

apps/backend-services/src/document/document-db.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class DocumentDbService {
4545
title: data.title,
4646
original_filename: data.original_filename,
4747
file_path: data.file_path,
48+
normalized_file_path: data.normalized_file_path ?? null,
4849
file_type: data.file_type,
4950
file_size: data.file_size,
5051
metadata: data.metadata,

apps/backend-services/src/document/document.controller.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ import {
2626
ApiOkResponse,
2727
ApiOperation,
2828
ApiParam,
29+
ApiProduces,
2930
ApiQuery,
3031
ApiTags,
32+
ApiUnauthorizedResponse,
3133
} from "@nestjs/swagger";
3234
import { Request, Response } from "express";
3335
import { AuditService } from "@/audit/audit.service";
@@ -47,6 +49,7 @@ import { type DocumentData, DocumentService } from "./document.service";
4749
import { ApproveDocumentDto } from "./dto/approve-document.dto";
4850
import { OcrResultResponseDto } from "./dto/ocr-result-response.dto";
4951
import { UpdateDocumentDto } from "./dto/update-document.dto";
52+
import { getContentTypeFromFilename } from "./mime-from-filename";
5053

5154
@ApiTags("Documents")
5255
@Controller("api/documents")
@@ -388,15 +391,81 @@ export class DocumentController {
388391
}
389392
}
390393

394+
@Get("/:documentId/view")
395+
@HttpCode(HttpStatus.OK)
396+
@Identity({ allowApiKey: true })
397+
@ApiOperation({
398+
summary: "View normalized document as PDF",
399+
description:
400+
"Streams the normalized PDF used for in-app display and OCR. Always application/pdf.",
401+
})
402+
@ApiParam({ name: "documentId", description: "Document ID" })
403+
@ApiProduces("application/pdf")
404+
@ApiOkResponse({
405+
description: "Normalized PDF bytes (Content-Type: application/pdf)",
406+
})
407+
@ApiNotFoundResponse({
408+
description: "Document not found or normalized PDF unavailable",
409+
})
410+
@ApiUnauthorizedResponse({ description: "Not authenticated" })
411+
@ApiForbiddenResponse({ description: "Access denied: not a group member" })
412+
async viewDocument(
413+
@Param("documentId") documentId: string,
414+
@Res() res: Response,
415+
@Req() req: Request,
416+
): Promise<void> {
417+
this.logger.debug(`=== DocumentController.viewDocument ===`);
418+
this.logger.debug(`Document ID: ${documentId}`);
419+
420+
const document = await this.documentService.findDocument(documentId);
421+
if (!document) {
422+
throw new NotFoundException(`Document not found: ${documentId}`);
423+
}
424+
425+
identityCanAccessGroup(req.resolvedIdentity, document.group_id);
426+
427+
if (!document.normalized_file_path) {
428+
throw new NotFoundException(
429+
`Normalized PDF is not available for document: ${documentId}`,
430+
);
431+
}
432+
433+
await this.auditService.recordEvent({
434+
event_type: "document_accessed",
435+
resource_type: "document",
436+
resource_id: documentId,
437+
actor_id: req.resolvedIdentity.actorId,
438+
document_id: documentId,
439+
group_id: document.group_id ?? undefined,
440+
payload: { action: "view" },
441+
});
442+
443+
const fileBuffer = await this.blobStorage.read(
444+
document.normalized_file_path,
445+
);
446+
447+
res.setHeader("Content-Type", "application/pdf");
448+
res.setHeader("Content-Disposition", 'inline; filename="document.pdf"');
449+
res.setHeader("Content-Length", fileBuffer.length);
450+
res.send(fileBuffer);
451+
452+
this.logger.debug("=== DocumentController.viewDocument completed ===");
453+
}
454+
391455
@Get("/:documentId/download")
392456
@HttpCode(HttpStatus.OK)
393-
@Identity()
394-
@ApiOperation({ summary: "Download a document file by ID" })
457+
@Identity({ allowApiKey: true })
458+
@ApiOperation({
459+
summary: "Download original uploaded file",
460+
description:
461+
"Serves the stored original blob (not the normalized PDF). Filename and type follow original_filename.",
462+
})
395463
@ApiParam({ name: "documentId", description: "Document ID" })
396464
@ApiOkResponse({
397-
description: "Returns the document file buffer as a download",
465+
description: "Returns the original file buffer",
398466
})
399467
@ApiNotFoundResponse({ description: "Document not found or file missing" })
468+
@ApiUnauthorizedResponse({ description: "Not authenticated" })
400469
@ApiForbiddenResponse({ description: "Access denied: not a group member" })
401470
async downloadDocument(
402471
@Param("documentId") documentId: string,
@@ -428,14 +497,8 @@ export class DocumentController {
428497
// Read file from blob storage using the blob key
429498
const fileBuffer = await this.blobStorage.read(document.file_path);
430499

431-
// Set appropriate headers
432500
const fileName = document.original_filename || `document-${documentId}`;
433-
const mimeType =
434-
document.file_type === "pdf"
435-
? "application/pdf"
436-
: document.file_type === "image"
437-
? "image/jpeg"
438-
: "application/octet-stream";
501+
const mimeType = getContentTypeFromFilename(fileName);
439502

440503
res.setHeader("Content-Type", mimeType);
441504
res.setHeader("Content-Disposition", `inline; filename="${fileName}"`);

apps/backend-services/src/document/document.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { TemporalModule } from "../temporal/temporal.module";
44
import { DocumentController } from "./document.controller";
55
import { DocumentService } from "./document.service";
66
import { DocumentDbService } from "./document-db.service";
7+
import { PdfNormalizationService } from "./pdf-normalization.service";
78

89
@Module({
910
imports: [BlobStorageModule, TemporalModule],
10-
providers: [DocumentDbService, DocumentService],
11+
providers: [DocumentDbService, DocumentService, PdfNormalizationService],
1112
controllers: [DocumentController],
12-
exports: [DocumentService],
13+
exports: [DocumentService, PdfNormalizationService],
1314
})
1415
export class DocumentModule {}

0 commit comments

Comments
 (0)