Upgrade async GPU analysis and workbench UX

This commit is contained in:
Jens
2026-08-23 21:50:11 +02:00
parent 4040cbca7b
commit b996986d20
59 changed files with 3999 additions and 274 deletions
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DetectionRunRequest } from '../../types'
import { detectionApi } from './detection'
describe('detectionApi.runAsync', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('starts production inference only through the queued endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
id: 'job-1',
job_type: 'detection.run',
status: 'queued',
project_id: 'project 1',
parameters_json: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchMock)
const payload: DetectionRunRequest = {
project_id: 'project 1',
dataset_id: 'dataset-1',
model_id: 'yolo-configured',
confidence_threshold: 0.15,
tile_manifest_path: '/tiles/manifest.json',
}
const response = await detectionApi.runAsync(payload)
expect(response.status).toBe('queued')
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/detection/run-async?project_id=project%201')
expect(init).toMatchObject({ method: 'POST', credentials: 'same-origin' })
expect(JSON.parse(String(init.body))).toEqual(payload)
})
})
+5 -5
View File
@@ -7,7 +7,7 @@ import type {
DetectionRunListResponse,
DetectionRunRead,
DetectionRunRequest,
DetectionRunResponse,
JobRead,
ModelAssetListResponse,
YoloPreflightResponse,
} from '../../types'
@@ -28,12 +28,12 @@ export const detectionApi = {
listModelAssets: (): Promise<ModelAssetListResponse> => apiGet<ModelAssetListResponse>('/api/v1/detection/model-assets'),
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null; model_asset_id?: string | null } = {}): Promise<YoloPreflightResponse> =>
apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`),
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
apiPost<DetectionRunResponse>(`/api/v1/detection/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
runAsync: (payload: DetectionRunRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/detection/run-async?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<DetectionRunListResponse> =>
apiGet<DetectionRunListResponse>(`/api/v1/detection/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<DetectionRunRead> =>
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}`),
getRun: (analysisRunId: string, projectId?: string | null): Promise<DetectionRunRead> =>
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}${queryString({ project_id: projectId })}`),
listDetections: (
analysisRunId: string,
params: {
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SegmentationRunRequest } from '../../types'
import { segmentationApi } from './segmentation'
describe('segmentationApi.runAsync', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('starts production segmentation only through the queued endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
id: 'job-1',
job_type: 'segmentation.run',
status: 'queued',
project_id: 'project 1',
parameters_json: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchMock)
const payload: SegmentationRunRequest = {
project_id: 'project 1',
dataset_id: 'dataset-1',
model_id: 'yolo-seg-configured',
confidence_threshold: 0.5,
tile_manifest_path: '/tiles/manifest.json',
}
const response = await segmentationApi.runAsync(payload)
expect(response.status).toBe('queued')
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/segmentation/run-async?project_id=project%201')
expect(init).toMatchObject({ method: 'POST', credentials: 'same-origin' })
expect(JSON.parse(String(init.body))).toEqual(payload)
})
it('scopes a persisted run read to the active guest project', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
id: 'run-1',
analysis_type: 'segmentation',
status: 'success',
project_id: 'project 1',
parameters_json: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchMock)
await segmentationApi.getRun('run-1', 'project 1')
expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/segmentation/runs/run-1?project_id=project+1')
})
})
+5 -5
View File
@@ -7,7 +7,7 @@ import type {
SegmentationRunListResponse,
SegmentationRunRead,
SegmentationRunRequest,
SegmentationRunResponse,
JobRead,
} from '../../types'
function queryString(params: Record<string, string | number | null | undefined>): string {
@@ -23,12 +23,12 @@ function queryString(params: Record<string, string | number | null | undefined>)
export const segmentationApi = {
listModels: (): Promise<SegmentationModelsResponse> => apiGet<SegmentationModelsResponse>('/api/v1/segmentation/models'),
run: (payload: SegmentationRunRequest): Promise<SegmentationRunResponse> =>
apiPost<SegmentationRunResponse>(`/api/v1/segmentation/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
runAsync: (payload: SegmentationRunRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/segmentation/run-async?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<SegmentationRunListResponse> =>
apiGet<SegmentationRunListResponse>(`/api/v1/segmentation/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<SegmentationRunRead> =>
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}`),
getRun: (analysisRunId: string, projectId?: string | null): Promise<SegmentationRunRead> =>
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}${queryString({ project_id: projectId })}`),
listSegmentations: (
analysisRunId: string,
params: { project_id: string; dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null },
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest'
import type { DetectionRunRead, DetectionRunRequest, JobRead } from '../types'
import {
completedDetectionResponse,
DetectionJobError,
waitForDetectionJob,
} from './detectionJob'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
function job(status: string, overrides: Partial<JobRead> = {}): JobRead {
return {
id: jobId,
job_type: 'detection.run',
status,
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
...overrides,
}
}
function run(overrides: Partial<DetectionRunRead> = {}): DetectionRunRead {
return {
id: 'run-1',
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'detection',
status: 'success',
model_name: 'yolo-configured',
parameters_json: {},
result_json: { detection_count: 4 },
...overrides,
}
}
const request: DetectionRunRequest = {
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-configured',
confidence_threshold: 0.15,
tile_manifest_path: '/tiles/manifest.json',
}
describe('waitForDetectionJob', () => {
it('follows queued and running states until the persisted GPU job succeeds', async () => {
const readJob = vi.fn()
.mockResolvedValueOnce(job('running'))
.mockResolvedValueOnce(job('success', { result_json: { detection_count: 4 } }))
const statuses: string[] = []
const completed = await waitForDetectionJob({
projectId,
initialJob: job('queued'),
intervalMs: 0,
readJob,
onStatus: (value) => statuses.push(value.status),
})
expect(completed.status).toBe('success')
expect(statuses).toEqual(['queued', 'running', 'success'])
expect(readJob).toHaveBeenCalledTimes(2)
})
it('does not reinterpret a failed model/runtime job as an empty success', async () => {
await expect(waitForDetectionJob({
projectId,
initialJob: job('failed', {
error_message: 'NVIDIA CUDA is niet beschikbaar',
result_json: { error_code: 'DETECTION_ACCELERATOR_UNAVAILABLE' },
}),
intervalMs: 0,
})).rejects.toMatchObject({
name: 'DetectionJobError',
code: 'DETECTION_ACCELERATOR_UNAVAILABLE',
message: 'NVIDIA CUDA is niet beschikbaar',
})
})
it('rejects partial and cross-project jobs instead of treating them as complete', async () => {
await expect(waitForDetectionJob({
projectId,
initialJob: job('partial'),
intervalMs: 0,
})).rejects.toBeInstanceOf(DetectionJobError)
await expect(waitForDetectionJob({
projectId,
initialJob: job('success', { project_id: 'other-project' }),
intervalMs: 0,
})).rejects.toMatchObject({ code: 'DETECTION_JOB_IDENTITY_MISMATCH' })
})
})
describe('completedDetectionResponse', () => {
it('uses the persisted count and explicitly avoids claiming that a zero result means absence', () => {
const response = completedDetectionResponse(
request,
job('success', { result_json: { detection_count: 0 } }),
run({ result_json: { detection_count: 0 } }),
)
expect(response.detection_count).toBe(0)
expect(response.status).toBe('success')
expect(response.message).toContain('bewijst niet')
})
it('fails closed when the server omits the persisted count or links another run', () => {
expect(() => completedDetectionResponse(
request,
job('success'),
run({ result_json: null }),
)).toThrowError(DetectionJobError)
expect(() => completedDetectionResponse(
request,
job('success', { result_json: { detection_count: 2 } }),
run({ job_id: 'another-job' }),
)).toThrowError(DetectionJobError)
})
})
+197
View File
@@ -0,0 +1,197 @@
import type { DetectionRunRead, DetectionRunRequest, DetectionRunResponse, JobRead } from '../types'
import { jobsApi } from './api/jobs'
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running'])
const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'partial'])
export const DETECTION_JOB_POLL_INTERVAL_MS = 1_500
export const DETECTION_JOB_TIMEOUT_MS = 30 * 60 * 1_000
export class DetectionJobError extends Error {
readonly code: string
readonly jobId: string
constructor(message: string, code: string, jobId: string) {
super(message)
this.name = 'DetectionJobError'
this.code = code
this.jobId = jobId
}
}
interface WaitForDetectionJobOptions {
projectId: string
initialJob: JobRead
signal?: AbortSignal
intervalMs?: number
timeoutMs?: number
maxConsecutiveReadErrors?: number
readJob?: (projectId: string, jobId: string) => Promise<JobRead>
onStatus?: (job: JobRead) => void
}
function abortedError(): Error {
const error = new Error('Het volgen van de detectietaak is gestopt')
error.name = 'AbortError'
return error
}
function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(abortedError())
}
if (milliseconds <= 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
const onAbort = () => {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
reject(abortedError())
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function stringValue(record: Record<string, unknown> | null | undefined, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function numberValue(record: Record<string, unknown> | null | undefined, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function assertDetectionJobIdentity(projectId: string, job: JobRead): void {
if (job.project_id !== projectId || job.job_type !== 'detection.run') {
throw new DetectionJobError(
'De server koppelde een onverwachte taak aan deze beeldanalyse',
'DETECTION_JOB_IDENTITY_MISMATCH',
job.id,
)
}
}
/**
* Follow one queued GPU run until the backend marks it terminal.
*
* A transient polling failure is retried, but an unknown or partial terminal
* state is never interpreted as a completed inference. The backend remains
* the only authority for the outcome and persisted detection count.
*/
export async function waitForDetectionJob({
projectId,
initialJob,
signal,
intervalMs = DETECTION_JOB_POLL_INTERVAL_MS,
timeoutMs = DETECTION_JOB_TIMEOUT_MS,
maxConsecutiveReadErrors = 3,
readJob = jobsApi.get,
onStatus,
}: WaitForDetectionJobOptions): Promise<JobRead> {
const startedAt = Date.now()
let job = initialJob
let consecutiveReadErrors = 0
while (true) {
if (signal?.aborted) {
throw abortedError()
}
assertDetectionJobIdentity(projectId, job)
onStatus?.(job)
if (job.status === 'success') {
return job
}
if (TERMINAL_FAILURE_STATUSES.has(job.status)) {
const code = stringValue(job.result_json, 'error_code') ?? `DETECTION_JOB_${job.status.toUpperCase()}`
const message = job.error_message
?? stringValue(job.result_json, 'message')
?? 'De GPU-taak is niet volledig uitgevoerd'
throw new DetectionJobError(message, code, job.id)
}
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
throw new DetectionJobError(
`De detectietaak heeft een onbekende status: ${job.status}`,
'DETECTION_JOB_STATUS_INVALID',
job.id,
)
}
if (Date.now() - startedAt >= timeoutMs) {
throw new DetectionJobError(
'De detectietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde detectieruns om het resultaat later te bekijken.',
'DETECTION_JOB_POLL_TIMEOUT',
job.id,
)
}
await wait(intervalMs, signal)
try {
job = await readJob(projectId, job.id)
consecutiveReadErrors = 0
} catch (error) {
if (signal?.aborted) {
throw abortedError()
}
consecutiveReadErrors += 1
if (consecutiveReadErrors >= maxConsecutiveReadErrors) {
throw error
}
}
}
}
/** Convert persisted server evidence into the existing UI summary contract. */
export function completedDetectionResponse(
request: DetectionRunRequest,
job: JobRead,
run: DetectionRunRead,
): DetectionRunResponse {
assertDetectionJobIdentity(request.project_id, job)
if (
job.status !== 'success'
|| run.status !== 'success'
|| run.project_id !== request.project_id
|| run.dataset_id !== request.dataset_id
|| run.job_id !== job.id
) {
throw new DetectionJobError(
'De bewaarde detectierun komt niet overeen met de voltooide GPU-taak',
'DETECTION_RUN_RESULT_MISMATCH',
job.id,
)
}
const detectionCount = numberValue(job.result_json, 'detection_count')
?? numberValue(run.result_json, 'detection_count')
if (detectionCount === null || !Number.isInteger(detectionCount) || detectionCount < 0) {
throw new DetectionJobError(
'De voltooide detectietaak bevat geen geldige, herleidbare objecttelling',
'DETECTION_RUN_RESULT_INCOMPLETE',
job.id,
)
}
return {
analysis_run_id: run.id,
job_id: job.id,
project_id: request.project_id,
dataset_id: request.dataset_id,
model_id: run.model_name ?? request.model_id,
status: 'success',
detection_count: detectionCount,
error_code: null,
message: detectionCount === 0
? 'Analyse voltooid zonder objecten boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied objectvrij is.'
: 'GPU-analyse voltooid; de bewaarde objecten zijn geladen.',
}
}
export function analysisRunIdFromJob(job: JobRead): string | null {
return stringValue(job.result_json, 'analysis_run_id')
}
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRunRead, SegmentationRunRequest } from '../types'
import {
completedSegmentationResponse,
SegmentationJobError,
waitForSegmentationJob,
} from './segmentationJob'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
function job(status: string, overrides: Partial<JobRead> = {}): JobRead {
return {
id: jobId,
job_type: 'segmentation.run',
status,
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
...overrides,
}
}
function run(overrides: Partial<SegmentationRunRead> = {}): SegmentationRunRead {
return {
id: 'run-1',
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'segmentation',
status: 'success',
model_name: 'yolo-seg-configured',
parameters_json: {},
result_json: { segmentation_count: 4 },
...overrides,
}
}
const request: SegmentationRunRequest = {
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-seg-configured',
confidence_threshold: 0.5,
tile_manifest_path: '/tiles/manifest.json',
}
describe('waitForSegmentationJob', () => {
it('polls the project-bound job until the GPU task succeeds', async () => {
const readJob = vi.fn()
.mockResolvedValueOnce(job('running'))
.mockResolvedValueOnce(job('success', { result_json: { segmentation_count: 4 } }))
const statuses: string[] = []
const completed = await waitForSegmentationJob({
projectId,
initialJob: job('queued'),
intervalMs: 0,
readJob,
onStatus: (value) => statuses.push(value.status),
})
expect(completed.status).toBe('success')
expect(statuses).toEqual(['queued', 'running', 'success'])
expect(readJob).toHaveBeenNthCalledWith(1, projectId, jobId)
expect(readJob).toHaveBeenCalledTimes(2)
})
it('keeps server failure and timeout distinct from a valid empty result', async () => {
await expect(waitForSegmentationJob({
projectId,
initialJob: job('failed', {
error_message: 'NVIDIA CUDA is niet beschikbaar',
result_json: { error_code: 'SEGMENTATION_ACCELERATOR_UNAVAILABLE' },
}),
intervalMs: 0,
})).rejects.toMatchObject({
name: 'SegmentationJobError',
code: 'SEGMENTATION_ACCELERATOR_UNAVAILABLE',
message: 'NVIDIA CUDA is niet beschikbaar',
})
await expect(waitForSegmentationJob({
projectId,
initialJob: job('running'),
intervalMs: 0,
timeoutMs: 0,
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_POLL_TIMEOUT' })
})
it('rejects partial, cross-project and wrong-task jobs', async () => {
await expect(waitForSegmentationJob({
projectId,
initialJob: job('partial'),
intervalMs: 0,
})).rejects.toBeInstanceOf(SegmentationJobError)
await expect(waitForSegmentationJob({
projectId,
initialJob: job('success', { project_id: 'other-project' }),
intervalMs: 0,
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_IDENTITY_MISMATCH' })
await expect(waitForSegmentationJob({
projectId,
initialJob: job('success', { job_type: 'detection.run' }),
intervalMs: 0,
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_IDENTITY_MISMATCH' })
})
})
describe('completedSegmentationResponse', () => {
it('accepts a persisted zero-result run without claiming that the area is empty', () => {
const response = completedSegmentationResponse(
request,
job('success', { result_json: { segmentation_count: 0 } }),
run({ result_json: { segmentation_count: 0 } }),
)
expect(response.segmentation_count).toBe(0)
expect(response.status).toBe('success')
expect(response.message).toContain('bewijst niet')
})
it('fails closed for missing counts or a mismatched persisted run', () => {
expect(() => completedSegmentationResponse(
request,
job('success'),
run({ result_json: null }),
)).toThrowError(SegmentationJobError)
expect(() => completedSegmentationResponse(
request,
job('success', { result_json: { segmentation_count: 2 } }),
run({ project_id: 'other-project' }),
)).toThrowError(SegmentationJobError)
expect(() => completedSegmentationResponse(
request,
job('success', { result_json: { segmentation_count: 2 } }),
run({ model_name: 'sam-configured' }),
)).toThrowError(SegmentationJobError)
})
})
+193
View File
@@ -0,0 +1,193 @@
import type { JobRead, SegmentationRunRead, SegmentationRunRequest, SegmentationRunResponse } from '../types'
import { jobsApi } from './api/jobs'
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running'])
const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'partial'])
export const SEGMENTATION_JOB_POLL_INTERVAL_MS = 1_500
export const SEGMENTATION_JOB_TIMEOUT_MS = 30 * 60 * 1_000
export class SegmentationJobError extends Error {
readonly code: string
readonly jobId: string
constructor(message: string, code: string, jobId: string) {
super(message)
this.name = 'SegmentationJobError'
this.code = code
this.jobId = jobId
}
}
interface WaitForSegmentationJobOptions {
projectId: string
initialJob: JobRead
signal?: AbortSignal
intervalMs?: number
timeoutMs?: number
maxConsecutiveReadErrors?: number
readJob?: (projectId: string, jobId: string) => Promise<JobRead>
onStatus?: (job: JobRead) => void
}
function abortedError(): Error {
const error = new Error('Het volgen van de segmentatietaak is gestopt')
error.name = 'AbortError'
return error
}
function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(abortedError())
}
if (milliseconds <= 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
const onAbort = () => {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
reject(abortedError())
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function stringValue(record: Record<string, unknown> | null | undefined, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function numberValue(record: Record<string, unknown> | null | undefined, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function assertSegmentationJobIdentity(projectId: string, job: JobRead): void {
if (job.project_id !== projectId || job.job_type !== 'segmentation.run') {
throw new SegmentationJobError(
'De server koppelde een onverwachte taak aan deze segmentatie',
'SEGMENTATION_JOB_IDENTITY_MISMATCH',
job.id,
)
}
}
/** Follow one queued GPU segmentation until the backend marks it terminal. */
export async function waitForSegmentationJob({
projectId,
initialJob,
signal,
intervalMs = SEGMENTATION_JOB_POLL_INTERVAL_MS,
timeoutMs = SEGMENTATION_JOB_TIMEOUT_MS,
maxConsecutiveReadErrors = 3,
readJob = jobsApi.get,
onStatus,
}: WaitForSegmentationJobOptions): Promise<JobRead> {
const startedAt = Date.now()
let job = initialJob
let consecutiveReadErrors = 0
while (true) {
if (signal?.aborted) {
throw abortedError()
}
assertSegmentationJobIdentity(projectId, job)
onStatus?.(job)
if (job.status === 'success') {
return job
}
if (TERMINAL_FAILURE_STATUSES.has(job.status)) {
const code = stringValue(job.result_json, 'error_code') ?? `SEGMENTATION_JOB_${job.status.toUpperCase()}`
const message = job.error_message
?? stringValue(job.result_json, 'message')
?? 'De GPU-taak is niet volledig uitgevoerd'
throw new SegmentationJobError(message, code, job.id)
}
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
throw new SegmentationJobError(
`De segmentatietaak heeft een onbekende status: ${job.status}`,
'SEGMENTATION_JOB_STATUS_INVALID',
job.id,
)
}
if (Date.now() - startedAt >= timeoutMs) {
throw new SegmentationJobError(
'De segmentatietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde segmentatieruns om het resultaat later te bekijken.',
'SEGMENTATION_JOB_POLL_TIMEOUT',
job.id,
)
}
await wait(intervalMs, signal)
try {
job = await readJob(projectId, job.id)
consecutiveReadErrors = 0
} catch (error) {
if (signal?.aborted) {
throw abortedError()
}
consecutiveReadErrors += 1
if (consecutiveReadErrors >= maxConsecutiveReadErrors) {
throw error
}
}
}
}
/** Convert persisted server evidence into the UI summary contract. */
export function completedSegmentationResponse(
request: SegmentationRunRequest,
job: JobRead,
run: SegmentationRunRead,
): SegmentationRunResponse {
assertSegmentationJobIdentity(request.project_id, job)
if (
job.status !== 'success'
|| run.status !== 'success'
|| run.analysis_type !== 'segmentation'
|| run.project_id !== request.project_id
|| run.dataset_id !== request.dataset_id
|| run.job_id !== job.id
|| (run.model_name != null && run.model_name !== request.model_id)
) {
throw new SegmentationJobError(
'De bewaarde segmentatierun komt niet overeen met de voltooide GPU-taak',
'SEGMENTATION_RUN_RESULT_MISMATCH',
job.id,
)
}
const segmentationCount = numberValue(job.result_json, 'segmentation_count')
?? numberValue(run.result_json, 'segmentation_count')
if (segmentationCount === null || !Number.isInteger(segmentationCount) || segmentationCount < 0) {
throw new SegmentationJobError(
'De voltooide segmentatietaak bevat geen geldige, herleidbare vlakkentelling',
'SEGMENTATION_RUN_RESULT_INCOMPLETE',
job.id,
)
}
return {
analysis_run_id: run.id,
job_id: job.id,
project_id: request.project_id,
dataset_id: request.dataset_id,
model_id: run.model_name ?? request.model_id,
status: 'success',
segmentation_count: segmentationCount,
error_code: null,
message: segmentationCount === 0
? 'Segmentatie voltooid zonder vlakken boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied geen relevante objecten bevat.'
: 'GPU-segmentatie voltooid; de bewaarde vlakken zijn geladen.',
}
}
export function analysisRunIdFromSegmentationJob(job: JobRead): string | null {
return stringValue(job.result_json, 'analysis_run_id')
}