Skip to content

Commit d6e086e

Browse files
authored
Merge pull request #132 from bcgov/develop
Develop
2 parents 56a857b + 6d45a61 commit d6e086e

10 files changed

Lines changed: 285 additions & 28 deletions

apps/backend-services/src/benchmark/benchmark-run.service.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { Prisma } from "@generated/client";
99
import { BadRequestException, NotFoundException } from "@nestjs/common";
1010
import { Test, TestingModule } from "@nestjs/testing";
11+
import { BLOB_STORAGE } from "@/blob-storage/blob-storage.interface";
1112
import { PrismaService } from "@/database/prisma.service";
1213
import { AuditLogService } from "./audit-log.service";
1314
import { BenchmarkRunService } from "./benchmark-run.service";
@@ -180,6 +181,17 @@ describe("BenchmarkRunService", () => {
180181
logBaselinePromoted: jest.fn().mockResolvedValue({}),
181182
},
182183
},
184+
{
185+
provide: BLOB_STORAGE,
186+
useValue: {
187+
read: jest.fn().mockResolvedValue(Buffer.from("{}", "utf-8")),
188+
write: jest.fn().mockResolvedValue(undefined),
189+
exists: jest.fn().mockResolvedValue(false),
190+
delete: jest.fn().mockResolvedValue(undefined),
191+
list: jest.fn().mockResolvedValue([]),
192+
deleteByPrefix: jest.fn().mockResolvedValue(undefined),
193+
},
194+
},
183195
],
184196
}).compile();
185197

apps/backend-services/src/benchmark/benchmark-run.service.ts

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ import { getErrorMessage } from "@ai-di/shared-logging";
1313

1414
import { execSync } from "node:child_process";
1515
import { createHash } from "node:crypto";
16+
import { validateBlobFilePath } from "@ai-di/blob-storage-paths";
1617
import { Prisma } from "@generated/client";
1718
import {
1819
BadRequestException,
20+
Inject,
1921
Injectable,
2022
Logger,
2123
NotFoundException,
2224
} from "@nestjs/common";
25+
import {
26+
BLOB_STORAGE,
27+
type BlobStorageInterface,
28+
} from "@/blob-storage/blob-storage.interface";
2329
import { computeConfigHash } from "@/workflow/config-hash";
2430
import type { GraphWorkflowConfig } from "@/workflow/graph-workflow-types";
2531
import { AuditLogService } from "./audit-log.service";
@@ -53,8 +59,47 @@ export class BenchmarkRunService {
5359
private benchmarkTemporal: BenchmarkTemporalService,
5460
private datasetService: DatasetService,
5561
private readonly auditLogService: AuditLogService,
62+
@Inject(BLOB_STORAGE)
63+
private readonly blobStorage: BlobStorageInterface,
5664
) {}
5765

66+
/**
67+
* Read per-sample evaluation details (groundTruth / prediction /
68+
* evaluationDetails) from blob storage. The Temporal workflow writes one
69+
* JSON file per sample at `{groupId}/benchmark/runs/{runId}/{sampleId}.json`
70+
* via `benchmark.persistEvaluationDetails`; we fetch and inline them here so
71+
* the per-sample drill-down API contract stays stable. Returns an empty
72+
* shape when the blob is missing or unreadable so the UI degrades gracefully
73+
* (older runs that pre-date the blob-storage scheme have no path).
74+
*/
75+
private async readEvaluationDetailsBlob(
76+
blobPath: string | undefined,
77+
): Promise<{
78+
groundTruth?: unknown;
79+
prediction?: unknown;
80+
evaluationDetails?: unknown;
81+
}> {
82+
if (!blobPath) return {};
83+
try {
84+
const buf = await this.blobStorage.read(validateBlobFilePath(blobPath));
85+
const parsed = JSON.parse(buf.toString("utf-8")) as {
86+
groundTruth?: unknown;
87+
prediction?: unknown;
88+
evaluationDetails?: unknown;
89+
};
90+
return {
91+
groundTruth: parsed.groundTruth ?? undefined,
92+
prediction: parsed.prediction ?? undefined,
93+
evaluationDetails: parsed.evaluationDetails ?? undefined,
94+
};
95+
} catch (err) {
96+
this.logger.warn(
97+
`Failed to read evaluation details blob "${blobPath}": ${getErrorMessage(err)}`,
98+
);
99+
return {};
100+
}
101+
}
102+
58103
/**
59104
* Get the current Git SHA of the worker codebase
60105
*/
@@ -938,6 +983,13 @@ export class BenchmarkRunService {
938983
groundTruth?: unknown;
939984
prediction?: unknown;
940985
evaluationDetails?: unknown;
986+
/**
987+
* Path to the per-sample evaluation details blob written by
988+
* `benchmark.persistEvaluationDetails`. Present on runs created after
989+
* the blob-storage scheme landed; missing on older runs (which had the
990+
* heavy fields inlined directly).
991+
*/
992+
evaluationBlobPath?: string;
941993
}>;
942994

943995
// Collect available dimensions: metadata keys plus "pass" as a synthetic dimension
@@ -995,17 +1047,36 @@ export class BenchmarkRunService {
9951047
const offset = (page - 1) * limit;
9961048
const paginatedResults = filteredResults.slice(offset, offset + limit);
9971049

998-
// Map to DTO
999-
const results: PerSampleResultDto[] = paginatedResults.map((result) => ({
1000-
sampleId: result.sampleId,
1001-
metadata: result.metadata || {},
1002-
metrics: result.metrics || {},
1003-
pass: result.pass ?? false,
1004-
diagnostics: result.diagnostics,
1005-
groundTruth: result.groundTruth,
1006-
prediction: result.prediction,
1007-
evaluationDetails: result.evaluationDetails,
1008-
}));
1050+
// Map to DTO. Heavy fields (groundTruth / prediction / evaluationDetails)
1051+
// were stripped from `metrics.perSampleResults` to keep the activity
1052+
// payload under Temporal's 2 MB blob limit; the workflow persists them to
1053+
// blob storage instead. Read each blob in parallel and inline the parsed
1054+
// contents so the API contract for the drill-down view stays unchanged.
1055+
const inlinedDetails = await Promise.all(
1056+
paginatedResults.map((r) =>
1057+
// Backwards-compat: prefer inline fields if present (older runs).
1058+
r.evaluationBlobPath
1059+
? this.readEvaluationDetailsBlob(r.evaluationBlobPath)
1060+
: Promise.resolve({
1061+
groundTruth: r.groundTruth,
1062+
prediction: r.prediction,
1063+
evaluationDetails: r.evaluationDetails,
1064+
}),
1065+
),
1066+
);
1067+
1068+
const results: PerSampleResultDto[] = paginatedResults.map(
1069+
(result, idx) => ({
1070+
sampleId: result.sampleId,
1071+
metadata: result.metadata || {},
1072+
metrics: result.metrics || {},
1073+
pass: result.pass ?? false,
1074+
diagnostics: result.diagnostics,
1075+
groundTruth: inlinedDetails[idx].groundTruth,
1076+
prediction: inlinedDetails[idx].prediction,
1077+
evaluationDetails: inlinedDetails[idx].evaluationDetails,
1078+
}),
1079+
);
10091080

10101081
return {
10111082
runId: run.id,

apps/temporal/src/activities.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export {
4747
benchmarkLoadOcrCache,
4848
benchmarkPersistOcrCache,
4949
} from "./activities/benchmark-ocr-cache";
50+
export type {
51+
BenchmarkPersistEvaluationDetailsInput,
52+
BenchmarkPersistEvaluationDetailsOutput,
53+
} from "./activities/benchmark-persist-evaluation-details";
54+
export { benchmarkPersistEvaluationDetails } from "./activities/benchmark-persist-evaluation-details";
5055
export type { BenchmarkUpdateRunStatusInput } from "./activities/benchmark-update-run";
5156
export { benchmarkUpdateRunStatus } from "./activities/benchmark-update-run";
5257
export type {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Persist per-sample evaluation details (groundTruth, prediction,
3+
* evaluationDetails, diagnostics) to blob storage as a JSON file.
4+
*
5+
* The drill-down UI consumes these heavy fields, but they're too large to
6+
* round-trip through Temporal payloads at scale (~30 KB per sample × ~100
7+
* samples = ~3 MB, exceeding the 2 MB blob limit on activity inputs).
8+
* Instead, the parent workflow calls this activity once per sample (each
9+
* payload safely under the limit), and stores only the returned
10+
* `evaluationBlobPath` on the BenchmarkRun row.
11+
*
12+
* Backend reads the blob on demand when a client opens drill-down for a
13+
* specific sample.
14+
*/
15+
16+
import {
17+
buildBlobFilePath,
18+
OperationCategory,
19+
} from "@ai-di/blob-storage-paths";
20+
import { getBlobStorageClient } from "../blob-storage/blob-storage-client";
21+
import type { Prisma } from "../generated";
22+
import { createActivityLogger } from "../logger";
23+
import { getPrismaClient } from "./database-client";
24+
25+
export interface BenchmarkPersistEvaluationDetailsInput {
26+
runId: string;
27+
sampleId: string;
28+
/** Heavy fields stripped from the in-memory EvaluationResult before aggregate. */
29+
details: {
30+
groundTruth?: unknown;
31+
prediction?: unknown;
32+
evaluationDetails?: unknown;
33+
diagnostics?: unknown;
34+
};
35+
}
36+
37+
export interface BenchmarkPersistEvaluationDetailsOutput {
38+
/** Blob storage key written. Stored on the run row's perSampleResults. */
39+
evaluationBlobPath: string;
40+
}
41+
42+
/**
43+
* Resolve the dataset/project group_id for a benchmark run via project relation.
44+
* The blob path scheme requires a CUID group prefix (see buildBlobFilePath).
45+
*/
46+
async function resolveGroupId(
47+
runId: string,
48+
prisma: Prisma.TransactionClient | ReturnType<typeof getPrismaClient>,
49+
): Promise<string> {
50+
const row = await prisma.benchmarkRun.findUnique({
51+
where: { id: runId },
52+
select: { project: { select: { group_id: true } } },
53+
});
54+
if (!row?.project?.group_id) {
55+
throw new Error(
56+
`Cannot persist evaluation details: BenchmarkRun ${runId} or its project not found`,
57+
);
58+
}
59+
return row.project.group_id;
60+
}
61+
62+
export async function benchmarkPersistEvaluationDetails(
63+
input: BenchmarkPersistEvaluationDetailsInput,
64+
): Promise<BenchmarkPersistEvaluationDetailsOutput> {
65+
const log = createActivityLogger("benchmarkPersistEvaluationDetails", {
66+
runId: input.runId,
67+
sampleId: input.sampleId,
68+
});
69+
70+
const prisma = getPrismaClient();
71+
const groupId = await resolveGroupId(input.runId, prisma);
72+
73+
const blobPath = buildBlobFilePath(
74+
groupId,
75+
OperationCategory.BENCHMARK,
76+
["runs", input.runId],
77+
`${input.sampleId}.json`,
78+
);
79+
80+
const blobStorage = getBlobStorageClient();
81+
const data = Buffer.from(
82+
JSON.stringify({
83+
sampleId: input.sampleId,
84+
runId: input.runId,
85+
groundTruth: input.details.groundTruth ?? null,
86+
prediction: input.details.prediction ?? null,
87+
evaluationDetails: input.details.evaluationDetails ?? null,
88+
diagnostics: input.details.diagnostics ?? null,
89+
}),
90+
"utf-8",
91+
);
92+
await blobStorage.write(blobPath, data);
93+
94+
log.info("benchmark evaluation details persist", {
95+
event: "persist",
96+
blobPath,
97+
bytes: data.byteLength,
98+
});
99+
100+
return { evaluationBlobPath: blobPath };
101+
}

apps/temporal/src/activities/check-ocr-confidence.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ describe("checkOcrConfidence activity", () => {
365365
expect(result.requiresReview).toBe(false);
366366
});
367367

368-
it("skips document update when documentId is benchmark synthetic id", async () => {
368+
it("forces requiresReview=false and skips DB update for benchmark documents", async () => {
369369
const ocrResult: OCRResult = {
370370
success: true,
371371
status: "succeeded",
@@ -413,7 +413,10 @@ describe("checkOcrConfidence activity", () => {
413413
threshold: 0.95,
414414
});
415415

416-
expect(result.requiresReview).toBe(true);
416+
// Confidence is well below threshold but the documentId starts with
417+
// "benchmark-", so the activity must short-circuit requiresReview to
418+
// false (avoiding the 24h humanGate park) and never touch the DB.
419+
expect(result.requiresReview).toBe(false);
417420
expect(prismaMock.document.update).not.toHaveBeenCalled();
418421
});
419422
});

apps/temporal/src/activities/check-ocr-confidence.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ export async function checkOcrConfidence(params: {
5656
const normalizedConfidence =
5757
averageConfidence > 1 ? averageConfidence / 100 : averageConfidence;
5858

59-
const requiresReview = normalizedConfidence < confidenceThreshold;
59+
// Benchmark runs must never wait for HITL: the humanReview node would
60+
// park the workflow on a 24h timer waiting for an approval signal that
61+
// can't arrive in benchmark mode. Force requiresReview=false so the
62+
// switch routes straight to storeResults.
63+
const isBenchmark = documentId.startsWith("benchmark-");
64+
const requiresReview =
65+
!isBenchmark && normalizedConfidence < confidenceThreshold;
6066

6167
log.info("Check OCR confidence complete", {
6268
event: "complete",
@@ -69,8 +75,7 @@ export async function checkOcrConfidence(params: {
6975
// Update document status if review is required (skip in benchmark: no DB row exists)
7076
// Note: We keep status as 'ongoing_ocr' since the workflow is still in progress
7177
// The workflow itself tracks the 'awaiting_review' state separately
72-
const isBenchmark = documentId.startsWith("benchmark-");
73-
if (requiresReview && !isBenchmark) {
78+
if (requiresReview) {
7479
const prisma = getPrismaClient();
7580
await prisma.document.update({
7681
where: { id: documentId },
@@ -84,12 +89,6 @@ export async function checkOcrConfidence(params: {
8489
status: "ongoing_ocr",
8590
requiresReview: true,
8691
});
87-
} else if (requiresReview && isBenchmark) {
88-
log.debug("Skipping document status update for benchmark run", {
89-
event: "status_update_skipped",
90-
documentId,
91-
reason: "benchmark",
92-
});
9392
}
9493

9594
return {

apps/temporal/src/activity-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const EXPECTED_ACTIVITY_TYPES = [
3232
"benchmark.loadDatasetManifest",
3333
"benchmark.loadOcrCache",
3434
"benchmark.persistOcrCache",
35+
"benchmark.persistEvaluationDetails",
3536
"ocr.spellcheck",
3637
"ocr.characterConfusion",
3738
"ocr.normalizeFields",

apps/temporal/src/activity-registry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
benchmarkCompareAgainstBaseline,
1515
benchmarkEvaluate,
1616
benchmarkLoadOcrCache,
17+
benchmarkPersistEvaluationDetails,
1718
benchmarkPersistOcrCache,
1819
benchmarkUpdateRunStatus,
1920
benchmarkWritePrediction,
@@ -342,6 +343,17 @@ register({
342343
description: "Persist Azure OCR poll JSON for a benchmark sample",
343344
});
344345

346+
register({
347+
activityType: "benchmark.persistEvaluationDetails",
348+
activityFn: benchmarkPersistEvaluationDetails as (
349+
...args: unknown[]
350+
) => Promise<unknown>,
351+
defaultTimeout: "30s",
352+
defaultRetry: { maximumAttempts: 3 },
353+
description:
354+
"Persist per-sample evaluation details (groundTruth/prediction/evaluationDetails) to blob storage",
355+
});
356+
345357
// -- Azure Classifier activities -------------------------------------------
346358

347359
register({

apps/temporal/src/activity-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const REGISTERED_ACTIVITY_TYPES = [
3434
"benchmark.loadDatasetManifest",
3535
"benchmark.loadOcrCache",
3636
"benchmark.persistOcrCache",
37+
"benchmark.persistEvaluationDetails",
3738
"ocr.spellcheck",
3839
"ocr.characterConfusion",
3940
"ocr.normalizeFields",

0 commit comments

Comments
 (0)