@@ -13,13 +13,19 @@ import { getErrorMessage } from "@ai-di/shared-logging";
1313
1414import { execSync } from "node:child_process" ;
1515import { createHash } from "node:crypto" ;
16+ import { validateBlobFilePath } from "@ai-di/blob-storage-paths" ;
1617import { Prisma } from "@generated/client" ;
1718import {
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" ;
2329import { computeConfigHash } from "@/workflow/config-hash" ;
2430import type { GraphWorkflowConfig } from "@/workflow/graph-workflow-types" ;
2531import { 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 ,
0 commit comments