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
+7
View File
@@ -142,6 +142,7 @@ bash scripts/live_migration_smoke.sh
- `GET /api/v1/detection/models`
- `GET /api/v1/detection/model-assets`
- `POST /api/v1/detection/run`
- `POST /api/v1/detection/run-async` (production browser path)
- `GET /api/v1/detection/runs/{analysis_run_id}`
- `GET /api/v1/detection/runs/{analysis_run_id}/detections`
- YOLO/PyTorch real inference is not enabled in Sprint 8.
@@ -184,6 +185,7 @@ bash scripts/live_migration_smoke.sh
- Added segmentation endpoints:
- `GET /api/v1/segmentation/models`
- `POST /api/v1/segmentation/run`
- `POST /api/v1/segmentation/run-async` (production browser path)
- `GET /api/v1/segmentation/runs`
- `GET /api/v1/segmentation/runs/{analysis_run_id}`
- `GET /api/v1/segmentation/runs/{analysis_run_id}/segmentations`
@@ -191,6 +193,11 @@ bash scripts/live_migration_smoke.sh
- `POST /api/v1/segmentation/runs/{analysis_run_id}/qa/reference`
- Real SAM and YOLO-seg inference are not enabled in Sprint 9.
- Mask paths are provenance/debug artifacts; persisted PostGIS geometry is authoritative for QA, map display and GeoJSON.
- Current configured detection and segmentation run through the async analysis
worker (`GEOINTEL_ANALYSIS_WORKER_ENABLED`) and are followed through
`GET /api/v1/projects/{project_id}/jobs/{job_id}`. The Unraid profile sets
`YOLO_REQUIRE_CUDA=true`, so both pipelines fail closed instead of silently
falling back from NVIDIA CUDA to CPU.
## Sprint 17 additions
- Added export foundation backed by the existing `exports` table.
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from uuid import UUID
from fastapi import Request
from app.core.errors import AppError
def guest_project_scope(request: Request) -> UUID | None:
principal = getattr(request.state, "auth_principal", None)
if getattr(principal, "role", None) != "guest":
return None
project_id = getattr(principal, "project_id", None)
if isinstance(project_id, UUID):
return project_id
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def assert_guest_project_scope(request: Request, project_id: UUID) -> None:
guest_project_id = guest_project_scope(request)
if guest_project_id is not None and project_id != guest_project_id:
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def guest_scoped_project_filter(
request: Request,
requested_project_id: UUID | None,
) -> UUID | None:
guest_project_id = guest_project_scope(request)
if guest_project_id is None:
return requested_project_id
if requested_project_id is not None:
assert_guest_project_scope(request, requested_project_id)
return guest_project_id
+57 -7
View File
@@ -2,9 +2,14 @@ from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import (
assert_guest_project_scope,
guest_project_scope,
guest_scoped_project_filter,
)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
@@ -25,6 +30,7 @@ from app.schemas import (
YoloPreflightResponse,
)
from app.services.detection_comparison_service import DetectionComparisonService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService
@@ -60,7 +66,12 @@ def get_yolo_preflight(
@router.post("/run", response_model=Envelope[DetectionRunResponse])
def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
def run_detection(
payload: DetectionRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
assert_guest_project_scope(request, payload.project_id)
result = DetectionService.run_detection(
db=db,
project_id=payload.project_id,
@@ -76,7 +87,11 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
def queue_detection(
payload: DetectionRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Queue a detection run for the background worker.
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it
@@ -84,6 +99,7 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db))
``GET /jobs/{id}`` for the queued run instead.
"""
assert_guest_project_scope(request, payload.project_id)
job = DetectionService.enqueue_detection(
db=db,
project_id=payload.project_id,
@@ -100,12 +116,14 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db))
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
def list_detection_runs(
request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope(
DetectionService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
@@ -114,8 +132,14 @@ def list_detection_runs(
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(DetectionService.get_run(db, analysis_run_id).model_dump())
def get_detection_run(
analysis_run_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(run.model_dump())
@router.get(
@@ -124,6 +148,7 @@ def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> d
)
def list_detection_run_detections(
analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -136,6 +161,9 @@ def list_detection_run_detections(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.list_detections(
db,
@@ -155,6 +183,7 @@ def list_detection_run_detections(
)
def list_dataset_detections(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -167,6 +196,9 @@ def list_dataset_detections(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.list_detections(
db,
@@ -181,8 +213,14 @@ def list_dataset_detections(
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(DetectionService.get_detection(db, detection_id).model_dump())
def get_detection(
detection_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
detection = DetectionService.get_detection(db, detection_id)
assert_guest_project_scope(request, detection.project_id)
return envelope(detection.model_dump())
@router.get(
@@ -191,6 +229,7 @@ def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
)
def get_detection_run_geojson(
analysis_run_id: UUID,
request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
@@ -201,6 +240,9 @@ def get_detection_run_geojson(
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
@@ -218,6 +260,7 @@ def get_detection_run_geojson(
)
def get_dataset_detection_geojson(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -229,6 +272,9 @@ def get_dataset_detection_geojson(
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
@@ -269,8 +315,12 @@ def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = De
def compare_detection_run_with_reference(
analysis_run_id: UUID,
payload: DetectionQaRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.compare_detections_with_reference(
db=db,
+52 -9
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.guest_scope import assert_guest_project_scope, guest_project_scope
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas import Envelope
@@ -20,13 +21,30 @@ from app.schemas.export import (
ReportExportRequest,
)
from app.services.export_service import ExportService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/exports", tags=["exports"])
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
def export_geojson(
payload: GeoJsonExportRequest,
request: Request,
db: Session = Depends(get_db),
):
if guest_project_scope(request) is not None:
if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None:
dataset = DatasetService.get_dataset(db, payload.dataset_id)
assert_guest_project_scope(request, dataset.project_id)
elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
run = DetectionService.get_run(db, payload.analysis_run_id)
assert_guest_project_scope(request, run.project_id)
elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
run = SegmentationService.get_run(db, payload.analysis_run_id)
assert_guest_project_scope(request, run.project_id)
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
return envelope(
ExportService.export_vector_selection_geojson(
@@ -61,17 +79,32 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)):
def export_project_metadata(
payload: MetadataExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/report", response_model=Envelope[ExportCreateResponse])
def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)):
def export_project_report(
payload: ReportExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
def export_map_result(
payload: MapResultExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
@@ -81,25 +114,35 @@ def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get
)
def list_project_exports(
project_id: UUID,
request: Request,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, project_id)
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
@router.get("/{export_id}", response_model=Envelope[ExportRead])
def get_export(export_id: UUID, db: Session = Depends(get_db)):
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
return envelope(export.model_dump(mode="json"))
@router.get("/{export_id}/download")
def download_export(export_id: UUID, db: Session = Depends(get_db)):
def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
if guest_project_scope(request) is not None:
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
path = ExportService.get_export_download_path(db, export_id)
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
return FileResponse(path, filename=path.name, media_type=media_type)
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
def get_export_content(export_id: UUID, db: Session = Depends(get_db)):
def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)):
if guest_project_scope(request) is not None:
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
+57 -7
View File
@@ -2,9 +2,14 @@ from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import (
assert_guest_project_scope,
guest_project_scope,
guest_scoped_project_filter,
)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
@@ -21,6 +26,7 @@ from app.schemas import (
SegmentationRunResponse,
)
from app.services.model_registry_service import ModelRegistryService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
@@ -34,7 +40,12 @@ def list_segmentation_models() -> dict:
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
def run_segmentation(
payload: SegmentationRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
assert_guest_project_scope(request, payload.project_id)
result = SegmentationService.run_segmentation(
db=db,
project_id=payload.project_id,
@@ -49,13 +60,18 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
def queue_segmentation(
payload: SegmentationRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Queue a segmentation run for the background worker.
Configured segmentation walks the same tile manifest as detection and is
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
"""
assert_guest_project_scope(request, payload.project_id)
job = SegmentationService.enqueue_segmentation(
db=db,
project_id=payload.project_id,
@@ -71,12 +87,14 @@ def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(ge
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
def list_segmentation_runs(
request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope(
SegmentationService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
@@ -85,8 +103,14 @@ def list_segmentation_runs(
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump())
def get_segmentation_run(
analysis_run_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(run.model_dump())
@router.get(
@@ -95,6 +119,7 @@ def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -
)
def list_segmentation_run_outputs(
analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -107,6 +132,9 @@ def list_segmentation_run_outputs(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
@@ -126,6 +154,7 @@ def list_segmentation_run_outputs(
)
def list_dataset_segmentations(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -138,6 +167,9 @@ def list_dataset_segmentations(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
@@ -152,8 +184,14 @@ def list_dataset_segmentations(
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
def get_segmentation(
segmentation_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
segmentation = SegmentationService.get_segmentation(db, segmentation_id)
assert_guest_project_scope(request, segmentation.project_id)
return envelope(segmentation.model_dump())
@router.get(
@@ -162,6 +200,7 @@ def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> di
)
def get_segmentation_run_geojson(
analysis_run_id: UUID,
request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
@@ -172,6 +211,9 @@ def get_segmentation_run_geojson(
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
@@ -189,6 +231,7 @@ def get_segmentation_run_geojson(
)
def get_dataset_segmentation_geojson(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -200,6 +243,9 @@ def get_dataset_segmentation_geojson(
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
@@ -219,8 +265,12 @@ def get_dataset_segmentation_geojson(
def compare_segmentation_run_with_reference(
analysis_run_id: UUID,
payload: SegmentationQaRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.compare_segmentations_with_reference(
db=db,
+2
View File
@@ -264,7 +264,9 @@ def create_app() -> FastAPI:
}
guest_scoped_analysis_post_paths = {
f"{settings.api_prefix}/detection/run",
f"{settings.api_prefix}/detection/run-async",
f"{settings.api_prefix}/segmentation/run",
f"{settings.api_prefix}/segmentation/run-async",
f"{settings.api_prefix}/qa/detections-vs-reference",
f"{settings.api_prefix}/exports/geojson",
f"{settings.api_prefix}/exports/metadata",
+5
View File
@@ -102,4 +102,9 @@ class SegmentationRead(BaseModel):
class SegmentationListResponse(BaseModel):
items: list[SegmentationRead]
# ``total`` describes the complete filtered population; ``items`` is one
# stable confidence-ranked page of it.
total: int
limit: int | None = None
offset: int = 0
truncated: bool = False
+55 -6
View File
@@ -18,7 +18,7 @@ import socket
from collections.abc import Callable
from typing import Any
from urllib.parse import urlparse
from urllib.request import HTTPRedirectHandler, build_opener, urlopen
from urllib.request import HTTPRedirectHandler, build_opener
from app.core.errors import AppError
@@ -38,12 +38,30 @@ class _RejectRedirects(HTTPRedirectHandler):
return None
class _ValidatedRedirects(HTTPRedirectHandler):
"""Validate a redirect target before urllib opens the next connection."""
def __init__(self, expected_url: str) -> None:
super().__init__()
self.expected_url = expected_url
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102
assert_same_origin_redirect(self.expected_url, newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def no_redirect_opener():
"""An opener that will not follow a redirect anywhere."""
return build_opener(_RejectRedirects())
def validated_redirect_opener(expected_url: str):
"""An opener that validates each redirect before following it."""
return build_opener(_ValidatedRedirects(expected_url))
def _reject(code: str, message: str, **details: Any) -> AppError:
return AppError(code=code, message=message, details=details or None, status_code=502)
@@ -88,6 +106,20 @@ def assert_public_http_url(url: str) -> None:
host = parsed.hostname
if not host:
raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url)
if parsed.username is not None or parsed.password is not None:
raise _reject(
"OUTBOUND_URL_NOT_ALLOWED",
"Bounded acquisition refuses credentials embedded in an outbound URL.",
host=host,
)
try:
parsed.port
except ValueError as error:
raise _reject(
"OUTBOUND_URL_NOT_ALLOWED",
"Outbound request contains an invalid port.",
host=host,
) from error
literal = host.strip("[]")
candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host)
@@ -133,15 +165,28 @@ def assert_same_origin_redirect(original_url: str, final_url: str) -> None:
"The official endpoint redirected from HTTPS to an unprotected scheme.",
redirect_scheme=final.scheme,
)
# This catches embedded credentials, invalid ports and non-public
# resolutions before the redirect handler can construct the next request.
assert_public_http_url(final_url)
original_port = original.port or (443 if original.scheme == "https" else 80)
final_port = final.port or (443 if final.scheme == "https" else 80)
same_scheme_port = final.scheme == original.scheme and final_port == original_port
safe_https_upgrade = original.scheme == "http" and final.scheme == "https" and final_port == 443
if not (same_scheme_port or safe_https_upgrade):
raise _reject(
"OUTBOUND_REDIRECT_NOT_ALLOWED",
"The official endpoint redirected to a different network origin.",
expected_port=original_port,
redirect_port=final_port,
)
def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]:
"""An ``urlopen`` replacement that verifies where the response came from.
"""An ``urlopen`` replacement that keeps redirects on the expected origin.
``urlopen`` has already followed the redirect chain by the time it returns,
so the check is on ``response.url``: the body is still unread, and raising
here means nothing off-origin is ever parsed or persisted.
Redirect targets are validated by the handler *before* urllib opens the
next connection. The final response URL is checked again as a defensive
invariant for injected/custom transports.
``allow_redirect=False`` refuses any redirect at all, which is what the
paged OGC feature readers want: a page URL they built themselves should be
@@ -151,7 +196,11 @@ def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callabl
assert_public_http_url(expected_url)
default_transport = urlopen if allow_redirect else no_redirect_opener().open
default_transport = (
validated_redirect_opener(expected_url).open
if allow_redirect
else no_redirect_opener().open
)
def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
response = (_transport or default_transport)(request, *args, **kwargs)
@@ -61,6 +61,41 @@ class _UltralyticsSegmentationAdapterBase:
message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
status_code=503,
)
self.validate_runtime()
def validate_runtime(self) -> None:
"""Fail closed when the deployment contract requires NVIDIA CUDA.
Detection and segmentation share ``YOLO_DEVICE`` and
``YOLO_REQUIRE_CUDA``. Without this check segmentation could advertise
a GPU job while Ultralytics silently used CPU or failed only after the
model had already been loaded.
"""
if not self.settings.yolo_require_cuda:
return
try:
import torch
except Exception as exc:
raise AppError(
code="SEGMENTATION_ACCELERATOR_UNAVAILABLE",
message="NVIDIA CUDA is required for configured segmentation, but PyTorch is not importable.",
status_code=503,
) from exc
if not torch.cuda.is_available():
raise AppError(
code="SEGMENTATION_ACCELERATOR_UNAVAILABLE",
message="NVIDIA CUDA is required for configured segmentation, but no CUDA device is available.",
details={"configured_device": self.settings.yolo_device},
status_code=503,
)
if not str(self.settings.yolo_device).lower().startswith(("cuda", "0", "1", "2", "3")):
raise AppError(
code="SEGMENTATION_ACCELERATOR_MISCONFIGURED",
message="NVIDIA CUDA is required, but YOLO_DEVICE does not select a CUDA device.",
details={"configured_device": self.settings.yolo_device},
status_code=503,
)
def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]:
if not tile_path.exists() or not tile_path.is_file():
+15 -3
View File
@@ -272,6 +272,8 @@ class SegmentationService:
dataset_id: uuid.UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int | None = None,
offset: int = 0,
) -> SegmentationListResponse:
if analysis_run_id is not None:
run = db.get(AnalysisRun, analysis_run_id)
@@ -284,8 +286,19 @@ class SegmentationService:
class_name=class_name,
min_confidence=min_confidence,
)
items = [SegmentationRead.model_validate(row) for row in rows]
return SegmentationListResponse(items=items, total=len(items))
resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit)
page, total, truncated = DetectionService.paginate(
rows,
limit=resolved_limit,
offset=offset,
)
return SegmentationListResponse(
items=[SegmentationRead.model_validate(row) for row in page],
total=total,
limit=resolved_limit,
offset=max(0, int(offset)),
truncated=truncated,
)
@staticmethod
def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead:
@@ -847,7 +860,6 @@ class SegmentationService:
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold),
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
"runtime_model_provenance": runtime_model_provenance.as_dict(),
}
+1
View File
@@ -93,6 +93,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
),
"shell": (
"App.tsx",
"WorkbenchApp.tsx",
"components/shell/WorkbenchNavigation.tsx",
"components/shell/SecondaryDisplay.tsx",
"components/inspector/WorkbenchInspector.tsx",
+347
View File
@@ -0,0 +1,347 @@
from __future__ import annotations
from pathlib import Path
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.db.session import get_db
from app.main import create_app
from app.models import AnalysisRun, Dataset, Detection, Export, Job, Segmentation
from app.schemas import (
DetectionRunListResponse,
DetectionRunResponse,
SegmentationRunListResponse,
SegmentationRunResponse,
)
from app.services.auth_service import AuthService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
GUEST_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000123")
OTHER_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000999")
DATASET_ID = UUID("00000000-0000-0000-0000-000000000201")
DETECTION_RUN_ID = UUID("00000000-0000-0000-0000-000000000202")
SEGMENTATION_RUN_ID = UUID("00000000-0000-0000-0000-000000000203")
DETECTION_ID = UUID("00000000-0000-0000-0000-000000000204")
SEGMENTATION_ID = UUID("00000000-0000-0000-0000-000000000205")
EXPORT_ID = UUID("00000000-0000-0000-0000-000000000206")
JOB_ID = UUID("00000000-0000-0000-0000-000000000207")
class FakeSession:
def __init__(self, objects: dict[tuple[type, UUID], object]) -> None:
self.objects = objects
def get(self, model, row_id):
return self.objects.get((model, row_id))
def _guest_client(monkeypatch, db: FakeSession) -> TestClient:
password_hash = AuthService.hash_password(
"operator-password",
salt=b"guest-scope-test-salt",
iterations=100_000,
)
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true")
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
monkeypatch.setenv(
"GEOINTEL_AUTH_SESSION_SECRET",
"guest-scope-test-session-secret-value",
)
monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true")
monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast")
client = TestClient(create_app())
def fake_db():
yield db
client.app.dependency_overrides[get_db] = fake_db
token = AuthService.create_session_token(
"Gast",
get_settings(),
role="guest",
project_id=GUEST_PROJECT_ID,
)
client.cookies.set("geointel_session", token)
return client
def _project_objects(project_id: UUID, export_path: Path) -> dict[tuple[type, UUID], object]:
return {
(Dataset, DATASET_ID): Dataset(
id=DATASET_ID,
project_id=project_id,
name="scope-test.tif",
dataset_type="raster",
source="fixture",
),
(AnalysisRun, DETECTION_RUN_ID): AnalysisRun(
id=DETECTION_RUN_ID,
project_id=project_id,
dataset_id=DATASET_ID,
analysis_type="detection",
status="success",
parameters_json={},
),
(AnalysisRun, SEGMENTATION_RUN_ID): AnalysisRun(
id=SEGMENTATION_RUN_ID,
project_id=project_id,
dataset_id=DATASET_ID,
analysis_type="segmentation",
status="success",
parameters_json={},
),
(Detection, DETECTION_ID): Detection(
id=DETECTION_ID,
project_id=project_id,
dataset_id=DATASET_ID,
analysis_run_id=DETECTION_RUN_ID,
model_name="fixture-detector",
class_name="building",
confidence=0.9,
geometry="SRID=4326;POINT (5 51)",
),
(Segmentation, SEGMENTATION_ID): Segmentation(
id=SEGMENTATION_ID,
project_id=project_id,
dataset_id=DATASET_ID,
analysis_run_id=SEGMENTATION_RUN_ID,
model_name="fixture-segmenter",
class_name="building",
confidence=0.9,
geometry="SRID=4326;MULTIPOLYGON (((5 51, 5.1 51, 5.1 51.1, 5 51)))",
),
(Export, EXPORT_ID): Export(
id=EXPORT_ID,
project_id=project_id,
export_type="dataset_geojson",
storage_path=str(export_path),
metadata_json={},
),
}
@pytest.mark.parametrize(
"path",
[
f"/api/v1/detection/runs/{DETECTION_RUN_ID}",
f"/api/v1/detection/runs/{DETECTION_RUN_ID}/detections",
f"/api/v1/detection/runs/{DETECTION_RUN_ID}/geojson",
f"/api/v1/detection/datasets/{DATASET_ID}/detections",
f"/api/v1/detection/datasets/{DATASET_ID}/geojson",
f"/api/v1/detection/detections/{DETECTION_ID}",
f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}",
f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/segmentations",
f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/geojson",
f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations",
f"/api/v1/segmentation/datasets/{DATASET_ID}/geojson",
f"/api/v1/segmentation/segmentations/{SEGMENTATION_ID}",
f"/api/v1/exports/{EXPORT_ID}",
f"/api/v1/exports/{EXPORT_ID}/content",
f"/api/v1/exports/{EXPORT_ID}/download",
f"/api/v1/exports/projects/{OTHER_PROJECT_ID}/exports",
],
)
def test_matching_guest_query_cannot_authorize_another_projects_resource(
path: str,
tmp_path: Path,
monkeypatch,
) -> None:
artifact = tmp_path / "other-project.geojson"
artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact)))
response = client.get(f"{path}?project_id={GUEST_PROJECT_ID}")
assert response.status_code == 403
assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
@pytest.mark.parametrize(
("path", "payload"),
[
(
"/api/v1/detection/run",
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
),
(
"/api/v1/detection/run-async",
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
),
(
"/api/v1/segmentation/run",
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
),
(
"/api/v1/segmentation/run-async",
{"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
),
(
"/api/v1/detection/runs/{run_id}/qa/reference".format(run_id=DETECTION_RUN_ID),
{"reference_dataset_id": str(DATASET_ID)},
),
(
"/api/v1/segmentation/runs/{run_id}/qa/reference".format(run_id=SEGMENTATION_RUN_ID),
{"reference_dataset_id": str(DATASET_ID)},
),
(
"/api/v1/exports/geojson",
{"export_kind": "dataset", "dataset_id": str(DATASET_ID)},
),
(
"/api/v1/exports/geojson",
{"export_kind": "detection_run", "analysis_run_id": str(DETECTION_RUN_ID)},
),
(
"/api/v1/exports/geojson",
{"export_kind": "segmentation_run", "analysis_run_id": str(SEGMENTATION_RUN_ID)},
),
(
"/api/v1/exports/metadata",
{"project_id": str(OTHER_PROJECT_ID)},
),
(
"/api/v1/exports/report",
{"project_id": str(OTHER_PROJECT_ID)},
),
(
"/api/v1/exports/map-result",
{
"project_id": str(OTHER_PROJECT_ID),
"mode": "current",
"dataset_id": str(DATASET_ID),
"bbox": {"min_x": 5.0, "min_y": 51.0, "max_x": 5.1, "max_y": 51.1, "crs": "EPSG:4326"},
},
),
],
)
def test_matching_guest_query_cannot_override_post_body_or_target_scope(
path: str,
payload: dict,
tmp_path: Path,
monkeypatch,
) -> None:
artifact = tmp_path / "other-project.geojson"
artifact.write_text("{}", encoding="utf-8")
client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact)))
response = client.post(f"{path}?project_id={GUEST_PROJECT_ID}", json=payload)
assert response.status_code == 403
assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
def test_guest_can_still_read_and_download_its_own_resources(
tmp_path: Path,
monkeypatch,
) -> None:
artifact = tmp_path / "demo.geojson"
artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact)))
suffix = f"?project_id={GUEST_PROJECT_ID}"
detection = client.get(f"/api/v1/detection/runs/{DETECTION_RUN_ID}{suffix}")
segmentation = client.get(f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}{suffix}")
export = client.get(f"/api/v1/exports/{EXPORT_ID}{suffix}")
download = client.get(f"/api/v1/exports/{EXPORT_ID}/download{suffix}")
assert detection.status_code == 200
assert segmentation.status_code == 200
assert export.status_code == 200
assert download.status_code == 200
assert download.json()["type"] == "FeatureCollection"
def test_guest_run_lists_and_new_runs_remain_bound_to_the_session_project(
tmp_path: Path,
monkeypatch,
) -> None:
artifact = tmp_path / "demo.geojson"
artifact.write_text("{}", encoding="utf-8")
client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact)))
observed: list[UUID] = []
def detection_list(_db, *, project_id, **_kwargs):
observed.append(project_id)
return DetectionRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False)
def segmentation_list(_db, *, project_id, **_kwargs):
observed.append(project_id)
return SegmentationRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False)
def detection_run(**kwargs):
observed.append(kwargs["project_id"])
return DetectionRunResponse(
analysis_run_id=DETECTION_RUN_ID,
job_id=JOB_ID,
project_id=kwargs["project_id"],
dataset_id=kwargs["dataset_id"],
model_id=kwargs["model_id"],
status="success",
detection_count=0,
message="Demo run completed",
)
def segmentation_run(**kwargs):
observed.append(kwargs["project_id"])
return SegmentationRunResponse(
analysis_run_id=SEGMENTATION_RUN_ID,
job_id=JOB_ID,
project_id=kwargs["project_id"],
dataset_id=kwargs["dataset_id"],
model_id=kwargs["model_id"],
status="success",
segmentation_count=0,
message="Demo run completed",
)
monkeypatch.setattr(DetectionService, "list_runs", detection_list)
monkeypatch.setattr(SegmentationService, "list_runs", segmentation_list)
monkeypatch.setattr(DetectionService, "run_detection", detection_run)
monkeypatch.setattr(SegmentationService, "run_segmentation", segmentation_run)
def enqueue_detection(**kwargs):
observed.append(kwargs["project_id"])
return Job(
id=JOB_ID,
job_type="detection.run",
status="queued",
project_id=kwargs["project_id"],
dataset_id=kwargs["dataset_id"],
parameters_json={},
)
monkeypatch.setattr(DetectionService, "enqueue_detection", enqueue_detection)
def enqueue_segmentation(**kwargs):
observed.append(kwargs["project_id"])
return Job(
id=JOB_ID,
job_type="segmentation.run",
status="queued",
project_id=kwargs["project_id"],
dataset_id=kwargs["dataset_id"],
parameters_json={},
)
monkeypatch.setattr(SegmentationService, "enqueue_segmentation", enqueue_segmentation)
query = f"?project_id={GUEST_PROJECT_ID}"
payload = {"project_id": str(GUEST_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}
responses = [
client.get(f"/api/v1/detection/runs{query}"),
client.get(f"/api/v1/segmentation/runs{query}"),
client.post(f"/api/v1/detection/run{query}", json=payload),
client.post(f"/api/v1/detection/run-async{query}", json=payload),
client.post(f"/api/v1/segmentation/run{query}", json=payload),
client.post(f"/api/v1/segmentation/run-async{query}", json=payload),
]
assert all(response.status_code == 200 for response in responses)
assert observed == [GUEST_PROJECT_ID] * 6
@@ -18,8 +18,10 @@ import pytest
from app.core.errors import AppError
from app.services.outbound_request_guard import (
_ValidatedRedirects,
assert_public_http_url,
assert_same_origin_redirect,
validated_redirect_opener,
)
@@ -89,6 +91,30 @@ class TestRedirects:
def test_an_upgrade_to_https_stays_allowed(self) -> None:
assert_same_origin_redirect("http://geo.example.be/wcs", "https://geo.example.be/wcs")
def test_a_redirect_to_another_port_is_refused(self) -> None:
with pytest.raises(AppError) as exc_info:
assert_same_origin_redirect(
"https://geo.api.vlaanderen.be/wcs",
"https://geo.api.vlaanderen.be:8443/wcs",
)
assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
def test_embedded_credentials_are_refused(self) -> None:
with pytest.raises(AppError) as exc_info:
assert_public_http_url("https://operator:secret@geo.example.be/wcs")
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
def test_a_redirect_with_an_invalid_port_fails_closed(self) -> None:
with pytest.raises(AppError) as exc_info:
assert_same_origin_redirect(
"https://geo.api.vlaanderen.be/wcs",
"https://geo.api.vlaanderen.be:not-a-port/wcs",
)
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
def test_the_guard_opener_refuses_a_cross_host_redirect() -> None:
"""The opener is what the acquisition services actually call."""
@@ -249,6 +275,26 @@ def test_a_refused_redirect_is_never_requested() -> None:
assert "_RejectRedirects" in handlers
def test_the_default_guard_validates_before_following_a_redirect() -> None:
opener = validated_redirect_opener("https://geo.api.vlaanderen.be/wcs")
handlers = [type(handler).__name__ for handler in opener.handlers]
assert "_ValidatedRedirects" in handlers
handler = _ValidatedRedirects("https://geo.api.vlaanderen.be/wcs")
with pytest.raises(AppError) as exc_info:
handler.redirect_request(
None,
None,
302,
"Found",
{},
"http://169.254.169.254/latest/meta-data/",
)
assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
def test_the_rejecting_handler_returns_no_new_request() -> None:
from app.services.outbound_request_guard import _RejectRedirects
@@ -0,0 +1,64 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.core.config import Settings
from app.core.errors import AppError
from app.services.segmentation_adapter import YoloSegmentationAdapter
def _settings(*, require_cuda: bool, device: str) -> Settings:
return Settings(
_env_file=None,
YOLO_REQUIRE_CUDA=require_cuda,
YOLO_DEVICE=device,
)
def test_segmentation_runtime_allows_cpu_only_when_cuda_is_not_required() -> None:
adapter = YoloSegmentationAdapter(_settings(require_cuda=False, device="cpu"))
adapter.validate_runtime()
def test_segmentation_runtime_rejects_missing_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(
__import__("sys").modules,
"torch",
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
)
adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0"))
with pytest.raises(AppError) as exc_info:
adapter.validate_runtime()
assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_UNAVAILABLE"
def test_segmentation_runtime_rejects_cpu_device_when_cuda_is_required(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setitem(
__import__("sys").modules,
"torch",
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)),
)
adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cpu"))
with pytest.raises(AppError) as exc_info:
adapter.validate_runtime()
assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_MISCONFIGURED"
def test_segmentation_runtime_accepts_configured_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(
__import__("sys").modules,
"torch",
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)),
)
adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0"))
adapter.validate_runtime()
@@ -0,0 +1,151 @@
"""Regression coverage for bounded, stable segmentation result listings."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import UUID
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import segmentation as segmentation_routes
from app.db.session import get_db
from app.schemas.segmentation import SegmentationListResponse
from app.services.segmentation_service import SegmentationService
RUN_ID = UUID("00000000-0000-0000-0000-000000000101")
DATASET_ID = UUID("00000000-0000-0000-0000-000000000102")
PROJECT_ID = UUID("00000000-0000-0000-0000-000000000103")
def _segmentation(index: int) -> SimpleNamespace:
return SimpleNamespace(
id=UUID(int=index + 1),
project_id=PROJECT_ID,
dataset_id=DATASET_ID,
analysis_run_id=RUN_ID,
job_id=None,
model_name="segmentation-test-model",
model_version="1",
class_name="building",
confidence=0.99 - index / 100,
bbox_json=None,
area_m2=float(index + 1),
mask_path=None,
source_tile_path=None,
tile_index=index,
properties_json={},
provenance_json={},
created_at=datetime(2026, 8, 23, tzinfo=UTC),
)
class _Session:
def get(self, _model, identifier):
if identifier == RUN_ID:
return SimpleNamespace(analysis_type="segmentation")
return None
def test_service_returns_one_stable_page_with_complete_metadata(monkeypatch) -> None:
rows = [_segmentation(index) for index in range(5)]
monkeypatch.setattr(
SegmentationService,
"_query_segmentation_rows",
staticmethod(lambda _db, **_filters: rows),
)
result = SegmentationService.list_segmentations(
_Session(),
analysis_run_id=RUN_ID,
dataset_id=DATASET_ID,
limit=2,
offset=1,
)
assert [item.id for item in result.items] == [rows[1].id, rows[2].id]
assert result.total == 5
assert result.limit == 2
assert result.offset == 1
assert result.truncated is True
def test_service_pages_cover_the_stably_ordered_population_once(monkeypatch) -> None:
rows = [_segmentation(index) for index in range(5)]
monkeypatch.setattr(
SegmentationService,
"_query_segmentation_rows",
staticmethod(lambda _db, **_filters: rows),
)
seen = []
for offset in (0, 2, 4):
result = SegmentationService.list_segmentations(
_Session(),
dataset_id=DATASET_ID,
limit=2,
offset=offset,
)
seen.extend(item.id for item in result.items)
assert result.total == len(rows)
assert result.offset == offset
assert seen == [row.id for row in rows]
@pytest.mark.parametrize(
("path", "expected_run_id", "expected_dataset_id"),
[
(f"/api/v1/segmentation/runs/{RUN_ID}/segmentations", RUN_ID, None),
(f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", None, DATASET_ID),
],
)
def test_both_listing_routes_forward_the_page_window_and_return_it(
monkeypatch,
path: str,
expected_run_id: UUID | None,
expected_dataset_id: UUID | None,
) -> None:
calls: list[dict] = []
def _list(_db, analysis_run_id=None, **parameters):
calls.append({"analysis_run_id": analysis_run_id, **parameters})
return SegmentationListResponse(
items=[],
total=9,
limit=2,
offset=4,
truncated=True,
)
monkeypatch.setattr(SegmentationService, "list_segmentations", staticmethod(_list))
app = FastAPI()
app.include_router(segmentation_routes.router, prefix="/api/v1")
app.dependency_overrides[get_db] = lambda: object()
response = TestClient(app).get(
path,
params={"limit": 2, "offset": 4, "class_name": "building", "min_confidence": 0.5},
)
assert response.status_code == 200
assert response.json()["data"] == {
"items": [],
"total": 9,
"limit": 2,
"offset": 4,
"truncated": True,
}
assert calls == [
{
"analysis_run_id": expected_run_id,
"limit": 2,
"offset": 4,
"dataset_id": expected_dataset_id,
"class_name": "building",
"min_confidence": 0.5,
}
]
@@ -15,7 +15,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action()
assert "detectionRunBlockedReason" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Klaar om gebouwen te zoeken" in lab
assert "disabled={runningDetection || !detectionRunReady}" in lab
assert "disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}" in lab
assert "detectionJob?.status === 'queued' || detectionJob?.status === 'running'" in lab
def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action() -> None:
@@ -26,7 +27,8 @@ def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action
assert "segmentationRunBlockedReason" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Analyse" in lab
assert "disabled={runningSegmentation || !segmentationRunReady}" in lab
assert "disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}" in lab
assert "segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'" in lab
def test_ai_lab_guardrail_styles_remain_compact() -> None:
@@ -28,7 +28,12 @@ def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None:
def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
app = "\n".join(
(
(ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "WorkbenchApp.tsx").read_text(encoding="utf-8"),
)
)
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
@@ -40,7 +45,7 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl
assert "setSelectedDetectionDatasetId(selectedDataset.id)" in app
assert "setSelectedDetectionModelId('yolo-configured')" in app
assert "setDetectionConfidenceThreshold(0.25)" in app
assert "loadYoloPreflight(manifestPath).catch(() => null)" in app
assert "loadYoloPreflight(manifestPath).catch(() => meldLaadfout('modelcontrole'))" in app
assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))]
assert "Gekoppelde beeldtegels" in lab
assert "Gekoppelde beeldtegels" in lab
@@ -26,8 +26,10 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None:
assert "effectiveModelId" in hook
assert "effectiveModelAssetId" in hook
assert "await loadDetectionResults(result.analysis_run_id)" in hook
assert "model_id: selectedDetectionModelId" in hook
assert "model_asset_id: selectedModelAssetId || null" in hook
assert "model_id: modelId" in hook
assert "model_asset_id: modelAssetId || null" in hook
assert "effectiveModelId" in hook
assert "effectiveModelAssetId" in hook
def test_guided_detection_upload_uses_existing_dataset_persistence_boundary() -> None:
@@ -63,7 +65,8 @@ def test_detection_qa_remains_persisted_and_primary_not_parallel() -> None:
assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
assert "als kwaliteitscontrole in de database bewaard" in lab
assert "detectionApi.compareWithReference" in hook
assert "await loadQualityChecks(selectedProjectId)" in hook
assert "await loadQualityChecks(projectId)" in hook
assert "detectionQaRequestSequence.current" in hook
assert "Minimale IoU voor een match" in lab
assert "detectionQaResult.iou_threshold.toFixed(2)" in lab
+44 -8
View File
@@ -1528,7 +1528,10 @@ Response:
## Detection Lab
Sprint 8 implements Detection Lab foundation only. YOLO/PyTorch real inference is not enabled, no model is downloaded, and fixture detections require explicit fixture mode.
Detection Lab exposes the governed local YOLO/PyTorch runtime only when model,
dependencies and the configured NVIDIA accelerator pass preflight. GeoIntel
never downloads a model implicitly; fixture detections still require explicit
fixture mode and are not production inference.
### Guided browser orchestration
@@ -1537,11 +1540,17 @@ The current frontend offers one guided building-analysis action, but does not ad
1. optional explicit `POST /api/v1/projects/{project_id}/datasets/upload` for a georeferenced GeoTIFF;
2. `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/raster/tile` with 512 px tiles and 64 px overlap;
3. `GET /api/v1/detection/yolo/preflight` with the returned manifest and selected local model asset;
4. `POST /api/v1/detection/run` only after successful preflight;
5. persisted run, Detection list and Detection GeoJSON reads;
6. optional persisted reference QA through the existing detection QA endpoint.
4. `POST /api/v1/detection/run-async` only after successful preflight;
5. project-bound polling through
`GET /api/v1/projects/{project_id}/jobs/{job_id}` until a terminal state;
6. persisted run, Detection list and Detection GeoJSON reads;
7. optional persisted reference QA through the existing detection QA endpoint.
The strict `POST /api/v1/detection/run` contract still requires `tile_manifest_path` for configured YOLO. The frontend does not create fake tiles, bypass tile limits, fetch external imagery or download model weights.
The strict async request contract still requires `tile_manifest_path` for
configured YOLO. The production frontend does not fall back to the synchronous
inference route, create fake tiles, bypass tile limits, fetch external imagery
or download model weights. A zero-count success remains a completed inference,
not proof that the selected area contains no objects.
### GET `/api/v1/detection/models`
@@ -1758,8 +1767,12 @@ rejected immediately rather than by a job that fails minutes later.
Queued jobs are executed by the background analysis worker
(`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval
`GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS`), which claims a job before dispatching
it so the same run is never started twice. Poll `GET /api/v1/jobs/{id}` for
progress. `POST /api/v1/segmentation/run-async` behaves identically.
it so the same run is never started twice. Poll the project-bound
`GET /api/v1/projects/{project_id}/jobs/{job_id}` endpoint for progress.
`POST /api/v1/segmentation/run-async` behaves identically. Guest sessions may
queue and read analysis only for the project id embedded in their signed
session; query parameters never authorize a run, result or export belonging to
another project.
Unavailable model response:
@@ -2032,7 +2045,12 @@ Same pattern as object detection, but output includes masks and polygonized geom
## Segmentation Lab
Sprint 9 implements Segmentation Lab foundation only. Real SAM and YOLO-seg inference are not enabled, no model is downloaded, and fixture segmentations require explicit fixture mode.
Segmentation Lab exposes a configured local YOLO-seg or SAM runtime when its
model file, immutable runtime provenance and dependencies validate. No model is
downloaded. On the NVIDIA server, `YOLO_REQUIRE_CUDA=true` makes both configured
segmentation adapters fail closed when CUDA is absent or `YOLO_DEVICE` selects
CPU. Fixture segmentations remain explicit test-only data and the production
browser never queues that model.
### GET `/api/v1/segmentation/models`
@@ -2043,6 +2061,9 @@ Returns segmentation model capability descriptors:
- `yolo-seg-configured`: `not_configured`
- `sam-configured`: `not_configured`
The two configured entries become `configured` only when their corresponding
enable flag, local model file and provenance sidecar validate.
### POST `/api/v1/segmentation/run`
Creates a segmentation job and segmentation analysis run. If the requested model is unavailable, the job and analysis run are marked `failed` with `SEGMENTATION_MODEL_UNAVAILABLE`.
@@ -2063,6 +2084,16 @@ Request:
Fixture segmenter mode is test/demo-only. It persists only explicit `parameters_json.fixture_segmentations` entries when `parameters_json.fixture_mode=true`; it is never invoked automatically and does not represent production inference.
The production frontend uses `POST /api/v1/segmentation/run-async`, then polls
`GET /api/v1/projects/{project_id}/jobs/{job_id}` and reconciles the terminal
job with its persisted `AnalysisRun` and polygon records. It does not fall back
to the synchronous route. A configured model requires an existing
`tile_manifest_path`; missing CUDA fails with
`SEGMENTATION_ACCELERATOR_UNAVAILABLE` or
`SEGMENTATION_ACCELERATOR_MISCONFIGURED` when CUDA is required. A valid
zero-polygon run is shown as an empty model result, never as proof that the AOI
contains no relevant objects.
Validation errors:
- `INVALID_DATASET_TYPE` when the dataset is not raster.
@@ -2086,6 +2117,11 @@ Returns persisted segmentation records for a segmentation analysis run. Optional
- `dataset_id`
- `class_name`
- `min_confidence`
- `limit` (`0` means every matching record, otherwise capped at `50000`)
- `offset`
The response reports `total`, `limit`, `offset` and `truncated`; clients must
not present a truncated page as the complete polygon population.
### GET `/api/v1/segmentation/datasets/{dataset_id}/segmentations`
+68
View File
@@ -12867,3 +12867,71 @@ Open:
- Browser emulation covers responsive layout and interaction; certification on
physical touch hardware and with a screen reader remains a separate human QA
activity.
## 2026-08-23 - Sol Ultra product-, runtime- en betrouwbaarheidsronde
### Delivered
- Split the public landing foundation from the lazy workbench and MapLibre
styles. The initial production CSS payload dropped from roughly 219 kB to
38.38 kB while the authenticated workbench keeps its complete styling.
- Extended the reproducible browser audit to cover the landing and workbench at
390 x 844, 1366 x 768 and 2560 x 1080, including mobile navigation,
keyboard tabs, loading state, advanced map flow and every guest workspace.
- Corrected the smartphone shell hierarchy: topbar, guest banner and page
heading no longer overlap, and the live Selecteer/Bronnen/Verwerk/Controleer
rail now sits below the map actions instead of behind the fixed navigation.
- Kept full workspace titles for headings and accessible names while shortening
the two mobile navigation labels to `AI-beeld` and `Export`; the browser gate
now rejects any visible sidebar label whose text box is clipped.
- Made map-analysis failures outrank empty states and added an explicit retry;
new selections clear stale coverage immediately.
- Replaced synchronous browser inference with governed async detection and
segmentation queues, project-bound job polling and persisted-run
reconciliation. Detection has NVIDIA preflight; segmentation now fails
closed under the same server CUDA contract. Zero-result runs are communicated
without claiming that the AOI is object-free.
- Bound guest detection, segmentation and export reads/writes to the signed
demo project at the resource level. Matching query parameters can no longer
authorize another project's run, dataset, result or download.
- Closed stale-response races in temporal comparison and the local GeoAI
assistant, plus detection/segmentation run, result and QA flows across project
switches. Previously visited workspaces no longer reload together after every
navigation change.
- Added keyboard-complete pipeline tabs and React-driven model-dialog state,
initial focus and trigger-focus restoration. Landing scrolling now respects
`prefers-reduced-motion`.
- Hardened outbound acquisition redirects before the redirected request is
opened, including origin/port and embedded-credential rejection, and fixed
bounded pagination for segmentation result lists.
- Fixed segmentation readiness and section status: configured production
models now require a real tile manifest before queueing, fixture mode is
visibly test-only, and queued/running NVIDIA work has an explicit live state.
- Localised known model registrations and availability states in the Dutch UI;
raw English backend placeholder copy no longer leaks into the primary model
selector or readiness guidance.
### Verification
- Frontend TypeScript check and production build passed.
- Complete frontend suite: 36 files / 151 tests passed.
- Relevant backend release set: 121 tests passed, covering async analysis jobs,
atomic claims, guest/resource isolation, redirect policy, segmentation
pagination, NVIDIA runtime enforcement and current AI-lab contracts.
- Ruff passed over every changed backend Python module and test.
- Browser evidence passed across three landing and three authenticated
workbench viewports with zero horizontal overflow, console errors or failed
API requests in `.codex-artifacts/sol-ultra-final-l/manifest.json`; focused
AI-workspace and segmentation screenshots are stored beside it.
- Production build passed. Initial landing CSS remains 38.38 kB (8.22 kB
gzip); the lazy workbench JS is 479.76 kB (129.25 kB gzip) and MapLibre stays
isolated in its own lazy chunk.
### Boundaries
- This pass improves runtime correctness and presentation; it does not invent a
new accuracy claim or promote a model checkpoint. Existing governed model
evidence and regional release gates remain authoritative.
- Physical touch-device and screen-reader certification remain human QA. No
commit or deployment was performed because the active execution brief
explicitly forbids committing unless requested.
+26
View File
@@ -1109,6 +1109,32 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Loading-, empty-, unavailable- en errorstates plus toetsenbord- en dialogbediening.
- [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels.
- [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie.
# Sprint 237 - Sol Ultra productupgrade (2026-08-23)
- [x] Splits publieke landing-CSS en MapLibre van de lazy werkbankbundel.
- [x] Valideer landing en werkbank op 390, 1366 en 2560 px zonder overflow,
consolefouten of mislukte API-requests.
- [x] Herstel mobiele topbar/banner/kop- en procesrailbotsingen.
- [x] Voorkom afgekapte mobiele navigatielabels met korte zichtlabels en een
automatische clipping-gate.
- [x] Toon analysefouten vóór lege states en bied een herhaalactie.
- [x] Wis oude dekkingsdata zodra een nieuwe AOI wordt opgelost.
- [x] Sluit stale-response races in tijdvergelijking en AI-vragen.
- [x] Sluit late detectie-/segmentatiejobs, resultaten en QA na een
werkruimtewissel uit.
- [x] Voer productie-detectie uitsluitend via async NVIDIA/GPU-jobs uit en
verzoen het resultaat met de bewaarde AnalysisRun.
- [x] Voer productie-segmentatie uitsluitend via async serverjobs uit, eis een
tegelmanifest en laat de NVIDIA-runtime fail-closed valideren.
- [x] Bind gast-detecties, segmentaties en downloads aan het gesigneerde
demoproject op resourceniveau.
- [x] Valideer redirects vóór netwerktoegang en begrens segmentatieresultaten.
- [x] Maak pipeline-tabs en modeldialoog volledig toetsenbordbedienbaar.
- [x] Lokaliseer bekende modelnamen en beschikbaarheidsmeldingen in de primaire
Nederlandse AI-flow.
- [ ] Voer vóór formele toegankelijkheidscertificatie nog fysieke touch- en
screenreader-QA uit; browseremulatie en automatische naamcontrole zijn groen.
- [x] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze.
- [x] 51 frontendtests en productiebuild groen.
- [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing.
+141 -5
View File
@@ -63,9 +63,34 @@ async function auditInteractiveNames(page, label) {
return unnamed.length
}
async function prepareAuditSession(page, baseUrl) {
const sessionResponse = await page.request.get(`${baseUrl}/api/v1/auth/session`)
assert(sessionResponse.ok(), `Session preflight failed with HTTP ${sessionResponse.status()}`)
const sessionEnvelope = await sessionResponse.json()
const session = sessionEnvelope?.data
if (!session?.authentication_required || session.authenticated) return session
assert.equal(
session.guest_access_enabled,
true,
'UX audit needs an authenticated session or enabled guest access',
)
const guestResponse = await page.request.post(`${baseUrl}/api/v1/auth/guest`)
assert(guestResponse.ok(), `Guest audit session failed with HTTP ${guestResponse.status()}`)
const guestEnvelope = await guestResponse.json()
return guestEnvelope?.data
}
async function layoutEvidence(page) {
return page.evaluate(() => {
const root = document.documentElement
const rect = (selector) => {
const bounds = document.querySelector(selector)?.getBoundingClientRect()
return bounds
? { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, width: bounds.width, height: bounds.height }
: null
}
const main = document.querySelector('.workbench-main')?.getBoundingClientRect()
const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect()
const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect()
@@ -75,6 +100,11 @@ async function layoutEvidence(page) {
document_width: root.scrollWidth,
body_width: document.body.scrollWidth,
horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth),
shell_navigation: rect('.workbench-sidebar'),
topbar: rect('.workbench-topbar'),
guest_banner: rect('.guest-mode-banner'),
explorer_header: rect('.geo-explorer-header'),
live_analysis_journey: rect('.live-analysis-journey'),
main: main ? { left: main.left, right: main.right, width: main.width } : null,
map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null,
theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null,
@@ -82,6 +112,57 @@ async function layoutEvidence(page) {
})
}
async function runLandingViewport(browser, baseUrl, outputDir, viewport) {
const page = await browser.newPage({ viewport })
const consoleErrors = []
const failedRequests = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => consoleErrors.push(error.message))
page.on('requestfailed', (request) => {
if (request.url().startsWith(baseUrl)) {
failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)
}
})
try {
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.locator('.landing-page').waitFor({ state: 'visible', timeout: 15_000 })
await auditInteractiveNames(page, `${viewport.width}px landing`)
const horizontalOverflow = await page.evaluate(() => (
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth)
))
assert.equal(horizontalOverflow, 0, `${viewport.width}px landing overflows horizontally`)
assert.equal(
await page.getByRole('heading', { level: 1 }).count(),
1,
`${viewport.width}px landing needs one clear primary heading`,
)
if (viewport.width <= 760) {
const menu = page.locator('.landing-menu-toggle')
assert.equal(await menu.getAttribute('aria-label'), 'Navigatie openen')
await menu.click()
assert.equal(await menu.getAttribute('aria-expanded'), 'true')
await page.getByRole('navigation', { name: 'Landingspagina' }).waitFor({ state: 'visible' })
await page.getByRole('button', { name: 'Navigatie sluiten' }).click()
}
await page.screenshot({
path: path.join(outputDir, `landing-${viewport.width}x${viewport.height}.png`),
fullPage: true,
})
return {
viewport,
horizontal_overflow_px: horizontalOverflow,
console_errors: consoleErrors,
failed_requests: failedRequests,
}
} finally {
await page.close()
}
}
async function runViewport(browser, baseUrl, outputDir, viewport) {
const page = await browser.newPage({ viewport })
const consoleErrors = []
@@ -96,14 +177,41 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
}
})
try {
await prepareAuditSession(page, baseUrl)
const startedAt = Date.now()
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
const readyMs = Date.now() - startedAt
await auditInteractiveNames(page, `${viewport.width}px map explorer`)
const layout = await layoutEvidence(page)
const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels
.filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1)
.map((label) => label.textContent?.trim() || ''))
assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`)
assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`)
assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`)
if (layout.topbar && layout.guest_banner) {
assert(
layout.topbar.bottom <= layout.guest_banner.top + 1,
`${viewport.width}px topbar overlaps the guest access banner`,
)
}
if (layout.guest_banner && layout.explorer_header) {
assert(
layout.guest_banner.bottom <= layout.explorer_header.top + 1,
`${viewport.width}px guest access banner overlaps the explorer heading`,
)
}
if (layout.shell_navigation && layout.live_analysis_journey) {
const verticalOverlap = Math.min(layout.shell_navigation.bottom, layout.live_analysis_journey.bottom)
- Math.max(layout.shell_navigation.top, layout.live_analysis_journey.top)
const horizontalOverlap = Math.min(layout.shell_navigation.right, layout.live_analysis_journey.right)
- Math.max(layout.shell_navigation.left, layout.live_analysis_journey.left)
assert(
verticalOverlap <= 1 || horizontalOverlap <= 1,
`${viewport.width}px navigation overlaps the live analysis journey`,
)
}
const currentTab = page.getByRole('tab', { name: 'Laatste toestand' })
const evolutionTab = page.getByRole('tab', { name: 'Evolutie' })
@@ -127,6 +235,7 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
viewport,
ready_ms: readyMs,
layout,
clipped_navigation_labels: clippedNavigationLabels,
console_errors: consoleErrors,
failed_requests: failedRequests,
}
@@ -144,6 +253,7 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await route.continue()
})
try {
const auditSession = await prepareAuditSession(page, baseUrl)
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 })
const loadingStatus = page.getByRole('status', { name: '' }).filter({
hasText: 'Databronnen worden gecontroleerd',
@@ -175,10 +285,30 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') })
const auditedWorkspaces = []
for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) {
const workspaceKeys = ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview']
if (auditSession?.role === 'guest') {
assert.equal(
await page.getByTestId('workspace-nav-system').count(),
0,
'Guest session exposes operator-only system settings',
)
} else {
workspaceKeys.push('system')
}
for (const workspace of workspaceKeys) {
await page.getByTestId(`workspace-nav-${workspace}`).click()
await page.waitForTimeout(100)
await auditInteractiveNames(page, `${workspace} workspace`)
if (workspace === 'ai') {
await page.screenshot({ path: path.join(outputDir, 'ai-workspace.png'), fullPage: true })
const segmentationDisclosure = page.locator('.segmentation-disclosure')
await segmentationDisclosure.scrollIntoViewIfNeeded()
await segmentationDisclosure.locator('summary').first().click()
await page.waitForTimeout(150)
await auditInteractiveNames(page, 'open segmentation lab')
await segmentationDisclosure.locator('.ai-lab-run-surface').scrollIntoViewIfNeeded()
await page.screenshot({ path: path.join(outputDir, 'ai-segmentation.png') })
}
auditedWorkspaces.push(workspace)
}
@@ -203,21 +333,27 @@ async function main() {
schema_version: 1,
base_url: args.baseUrl,
started_at: new Date().toISOString(),
landing_viewports: [],
viewports: [],
bootstrap: null,
status: 'running',
}
try {
for (const viewport of [
const viewports = [
{ width: 390, height: 844 },
{ width: 1366, height: 768 },
{ width: 2560, height: 1080 },
]) {
]
for (const viewport of viewports) {
evidence.landing_viewports.push(await runLandingViewport(browser, args.baseUrl, outputDir, viewport))
}
for (const viewport of viewports) {
evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport))
}
evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir)
const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors)
const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests)
const auditedPages = [...evidence.landing_viewports, ...evidence.viewports]
const unexpectedConsoleErrors = auditedPages.flatMap((item) => item.console_errors)
const unexpectedFailedRequests = auditedPages.flatMap((item) => item.failed_requests)
assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors')
assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests')
evidence.status = 'passed'
+20 -4
View File
@@ -84,8 +84,8 @@ const workspaceNavItems: WorkspaceNavigationItem[] = [
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
{ key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' },
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
{ key: 'ai', label: 'Beeldanalyse', navigationLabel: 'AI-beeld', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', navigationLabel: 'Export', description: 'Resultaten bewaren en delen' },
{ key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' },
]
@@ -364,6 +364,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
@@ -438,11 +439,14 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationGeoJson,
segmentationClassFilter,
segmentationMinConfidenceFilter,
@@ -986,7 +990,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<ShieldCheck aria-hidden="true" />
<div>
<strong>Tijdelijke demowerkruimte</strong>
<span>Alle analysemodellen en werkfuncties zijn beschikbaar. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.</span>
<span>De demo gebruikt dezelfde geconfigureerde analysemodellen en werkfuncties als een gebruiker. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.</span>
</div>
<span className="guest-mode-badge">Analyse-toegang</span>
</div>
@@ -1278,6 +1282,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
detectionTileManifestPath={detectionTileManifestPath}
detectionConfidenceThreshold={detectionConfidenceThreshold}
runningDetection={runningDetection}
detectionJob={detectionJob}
detectionRunResult={detectionRunResult}
detectionRunError={detectionRunError}
detectionRuns={detectionRuns}
@@ -1331,7 +1336,15 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<details className="secondary-analysis-disclosure segmentation-disclosure">
<summary>
<span>Segmentatie van beeldvlakken</span>
<strong>Nog niet geconfigureerd</strong>
<strong>
{runningSegmentation
? 'In uitvoering'
: selectedSegmentationModelId === 'fixture-segmenter'
? 'Alleen test'
: selectedSegmentationModel?.configured
? 'Beschikbaar'
: 'Niet geconfigureerd'}
</strong>
</summary>
<SegmentationLab
segmentationModels={segmentationModels}
@@ -1342,11 +1355,14 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
segmentationTileManifestPath={segmentationTileManifestPath}
segmentationConfidenceThreshold={segmentationConfidenceThreshold}
runningSegmentation={runningSegmentation}
segmentationJob={segmentationJob}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationTotal={segmentationTotal}
segmentationTruncated={segmentationTruncated}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
+11 -6
View File
@@ -79,6 +79,15 @@ export function LandingPage({
return () => document.body.classList.remove('landing-body')
}, [])
const scrollAccessPanelIntoView = () => {
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
accessPanelRef.current.scrollIntoView({
behavior: reducedMotion ? 'auto' : 'smooth',
block: 'center',
})
}
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setPendingAction('operator')
@@ -99,9 +108,7 @@ export function LandingPage({
setPendingAction('guest')
setAttempted(true)
setAuthError(null)
if (typeof accessPanelRef.current?.scrollIntoView === 'function') {
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
scrollAccessPanelIntoView()
try {
const session = await loginAsGuest()
onAuthenticated(session)
@@ -114,9 +121,7 @@ export function LandingPage({
const focusLogin = () => {
setMenuOpen(false)
if (typeof accessPanelRef.current?.scrollIntoView === 'function') {
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
scrollAccessPanelIntoView()
window.requestAnimationFrame(() => usernameRef.current?.focus())
}
@@ -23,4 +23,45 @@ describe('AiPipelineIllustration', () => {
fireEvent.click(screen.getByRole('tab', { name: /Berekening/ }))
expect(screen.getByRole('tabpanel').textContent).toContain('De herkenning draait lokaal')
})
it('moves selection and focus through the tablist with keyboard controls', () => {
render(
<AiPipelineIllustration
hasImagery
hasTiles
gpuReady
hasDetections={false}
hasQualityEvidence={false}
running={false}
/>,
)
const tabs = screen.getAllByRole('tab') as HTMLButtonElement[]
const selectedTab = screen.getByRole('tab', { name: /Detecties/ }) as HTMLButtonElement
const panel = screen.getByRole('tabpanel')
expect(selectedTab.tabIndex).toBe(0)
expect(tabs.filter((tab) => tab.tabIndex === 0)).toHaveLength(1)
expect(selectedTab.getAttribute('aria-controls')).toBe(panel.id)
expect(panel.getAttribute('aria-labelledby')).toBe(selectedTab.id)
selectedTab.focus()
fireEvent.keyDown(selectedTab, { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: /QA-bewijs/ }).getAttribute('aria-selected')).toBe('true')
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowLeft' })
const wrappedTab = screen.getByRole('tab', { name: /QA-bewijs/ })
expect(document.activeElement).toBe(wrappedTab)
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(wrappedTab.id)
})
})
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useId, useRef, useState, type KeyboardEvent } from 'react'
import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react'
interface AiPipelineIllustrationProps {
@@ -29,14 +29,47 @@ export function AiPipelineIllustration({
const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence]
const firstIncomplete = readiness.findIndex((ready) => !ready)
const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete)
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const componentId = useId()
const titleId = `${componentId}-title`
const panelId = `${componentId}-panel`
const selected = pipelineStages[selectedIndex]
const selectAndFocus = (index: number) => {
setSelectedIndex(index)
tabRefs.current[index]?.focus()
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let nextIndex: number | null = null
switch (event.key) {
case 'ArrowRight':
nextIndex = (index + 1) % pipelineStages.length
break
case 'ArrowLeft':
nextIndex = (index - 1 + pipelineStages.length) % pipelineStages.length
break
case 'Home':
nextIndex = 0
break
case 'End':
nextIndex = pipelineStages.length - 1
break
default:
return
}
event.preventDefault()
selectAndFocus(nextIndex)
}
return (
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby="ai-pipeline-title">
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby={titleId}>
<div className="ai-pipeline-heading">
<div>
<p className="eyebrow">Van pixel naar bewijs</p>
<h3 id="ai-pipeline-title">Van luchtbeeld naar controleerbare detectie</h3>
<h3 id={titleId}>Van luchtbeeld naar controleerbare detectie</h3>
<p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p>
</div>
<span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}>
@@ -49,13 +82,16 @@ export function AiPipelineIllustration({
{pipelineStages.map(({ key, label, icon: Icon }, index) => (
<button
key={key}
id={`ai-pipeline-${key}`}
id={`${componentId}-${key}`}
ref={(element) => { tabRefs.current[index] = element }}
type="button"
role="tab"
aria-selected={selectedIndex === index}
aria-controls="ai-pipeline-detail"
aria-controls={panelId}
tabIndex={selectedIndex === index ? 0 : -1}
className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'}
onClick={() => setSelectedIndex(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
>
<span><Icon aria-hidden="true" /></span>
<strong>{label}</strong>
@@ -65,10 +101,11 @@ export function AiPipelineIllustration({
</div>
<div
id="ai-pipeline-detail"
id={panelId}
className="ai-pipeline-detail"
role="tabpanel"
aria-labelledby={`ai-pipeline-${selected.key}`}
aria-labelledby={`${componentId}-${selected.key}`}
tabIndex={0}
key={selected.key}
>
<span>{String(selectedIndex + 1).padStart(2, '0')}</span>
@@ -6,6 +6,7 @@ import type {
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
JobRead,
ModelAssetRead,
QualityCheckRead,
YoloPreflightResponse,
@@ -15,7 +16,7 @@ import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './de
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
import { AiPipelineIllustration } from './AiPipelineIllustration'
import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions'
import { analysisModelAvailabilityMessage, toAnalysisModelOption } from '../models/modelOptions'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
@@ -87,6 +88,7 @@ interface DetectionLabProps {
detectionTileManifestPath: string
detectionConfidenceThreshold: number
runningDetection: boolean
detectionJob: JobRead | null
detectionRunResult: DetectionRunResponse | null
detectionRunError: string | null
detectionRuns: DetectionRunRead[]
@@ -151,6 +153,7 @@ export function DetectionLab({
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
@@ -208,12 +211,17 @@ export function DetectionLab({
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks?.enabled &&
yoloPreflight.checks?.dependencies_available &&
yoloPreflight.checks?.accelerator_ready === true &&
yoloPreflight.checks?.model_file_exists,
)
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionJobActive = detectionJob?.status === 'queued' || detectionJob?.status === 'running'
const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null
const detectionModelReady = Boolean(selectedDetectionModel?.configured)
const selectedDetectionModelAvailability = selectedDetectionModel
? analysisModelAvailabilityMessage(selectedDetectionModel)
: 'Het gekozen model is niet geconfigureerd'
const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector'
const detectionHasExplicitModelAsset =
selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0
@@ -259,7 +267,7 @@ export function DetectionLab({
: selectedDetectionModelId === 'manual-fixture-detector'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
? selectedDetectionModelAvailability
: !detectionHasExplicitModelAsset
? 'Kies een lokaal modelbestand onder beheer'
: !detectionHasTileManifest
@@ -275,7 +283,7 @@ export function DetectionLab({
: selectedDetectionModelId === 'manual-fixture-detector'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
? selectedDetectionModelAvailability
: !detectionHasExplicitModelAsset
? 'Kies een lokaal modelbestand onder beheer'
: null
@@ -499,8 +507,8 @@ export function DetectionLab({
<DetectionWorkflowStep label="3. Modelcontrole" complete={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading' || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'validating'} />
<DetectionWorkflowStep label="4. Resultaat" complete={detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading'} />
</div>
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || !guidedDetectionReady}>
{detectionWorkflowActionLabel(detectionWorkflowStage)}
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !guidedDetectionReady}>
{detectionWorkflowActionLabel(detectionWorkflowStage, detectionJob?.status)}
</button>
{!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
@@ -528,7 +536,7 @@ export function DetectionLab({
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
</div>
) : null}
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}>
Bestaande beeldtegels analyseren
</button>
</div>
@@ -537,15 +545,26 @@ export function DetectionLab({
</div>
<div className="ai-lab-state-stack">
{detectionJob && (detectionJob.status === 'queued' || detectionJob.status === 'running') ? (
<div className="result-state" role="status" aria-live="polite">
<strong>{detectionJob.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-analyse wordt uitgevoerd.'}</strong>
<p>
{detectionJob.status === 'queued'
? 'De server heeft de aanvraag veilig bewaard en start ze zodra de NVIDIA-worker beschikbaar is.'
: 'Het model verwerkt de beeldtegels op de server. Dit scherm volgt de bewaarde taak automatisch.'}
</p>
<span className="muted">Taak-ID: {detectionJob.id}</span>
</div>
) : null}
{detectionRunError ? (
<div className="result-state result-state-error">
<strong>De beeldanalyse is mislukt.</strong>
<div className="result-state result-state-error" role="alert">
<strong>{detectionJobActive ? 'Het volgen van de servertaak is onderbroken.' : 'De beeldanalyse is mislukt.'}</strong>
<p>{detectionRunError}</p>
</div>
) : null}
{detectionRunResult ? (
<div className="result-summary-card">
<p>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : detectionRunResult.status}</p>
<div className={detectionRunResult.detection_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {detectionStatusLabel(detectionRunResult.status)}</p>
<p>{detectionRunResult.message}</p>
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
@@ -602,7 +621,7 @@ export function DetectionLab({
className="primary-action"
type="button"
onClick={onRunCalibration}
disabled={runningDetectionCalibration || !calibrationRunReady}
disabled={runningDetectionCalibration || runningDetection || detectionJobActive || !calibrationRunReady}
>
Drempels vergelijken
</button>
@@ -927,7 +946,7 @@ export function DetectionLab({
) : null}
{detectionQaResult ? (
<div className="result-summary-card">
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</p>
<p>Status: {detectionStatusLabel(detectionQaResult.status)}</p>
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
@@ -1042,10 +1061,11 @@ function DetectionWorkflowStep({
)
}
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage): string {
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage, jobStatus?: string): string {
if (stage === 'tiling') return 'Beeldtegels voorbereiden...'
if (stage === 'validating') return 'Model en beeld controleren...'
if (stage === 'detecting') return 'Gebouwen zoeken...'
if (stage === 'detecting' && jobStatus === 'queued') return 'Wachten op NVIDIA GPU...'
if (stage === 'detecting') return 'Gebouwen zoeken op NVIDIA GPU...'
if (stage === 'loading') return 'Resultaat op kaart laden...'
if (stage === 'complete') return 'Analyse opnieuw uitvoeren'
return 'Gebouwen zoeken en op kaart tonen'
@@ -4,6 +4,7 @@ import type {
YoloPreflightResponse,
} from '../../types'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { analysisModelAvailabilityMessage } from '../models/modelOptions'
interface DetectionModelManagementProps {
detectionModels: DetectionModelCapability[]
@@ -39,6 +40,8 @@ function statusLabel(value: string): string {
if (value === 'configured' || value === 'ready') return 'gereed'
if (value === 'not_configured') return 'niet geconfigureerd'
if (value === 'dependency_unavailable') return 'software ontbreekt'
if (value === 'accelerator_unavailable') return 'GPU niet beschikbaar'
if (value === 'contract_incomplete') return 'provenance onvolledig'
return value.replace(/_/g, ' ')
}
@@ -65,6 +68,7 @@ export function DetectionModelManagement({
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled
&& yoloPreflight.checks.dependencies_available
&& yoloPreflight.checks.accelerator_ready === true
&& yoloPreflight.checks.model_file_exists,
)
@@ -110,7 +114,7 @@ export function DetectionModelManagement({
{statusLabel(model.status)}
</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
@@ -255,6 +255,14 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
visibleThemes,
walloniaScopeSelected,
} = view
const resultsError = (
analysisMode === 'evolution'
? [temporalComparisonError]
: [mapSelectionError, themeResultsError]
)
.filter((message): message is string => Boolean(message))
.filter((message, index, messages) => messages.indexOf(message) === index)
.join(' ')
return (
<section
@@ -406,7 +414,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
: 'Niet beschikbaar'}
</small>
</span>
<i>{active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
<i>{workspaceLoading ? 'Laden' : active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
</button>
)
})}
@@ -859,6 +867,25 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<span />
<strong>De gekozen bronnen worden begrensd geladen en geanalyseerd</strong>
</div>
) : resultsError ? (
<div className="geo-results-error" role="alert">
<strong>De analyse kon niet worden voltooid</strong>
<p>{resultsError}</p>
<button
className="secondary-action"
type="button"
disabled={analysisMode === 'evolution' ? !temporalSelectionValid : selectedThemes.length === 0}
onClick={() => {
if (analysisMode === 'evolution') {
runTemporalComparison()
} else if (mapSelectionBbox) {
void analyzeSelection(mapSelectionBbox, areaIdForSelection(mapSelectionBbox))
}
}}
>
Opnieuw proberen
</button>
</div>
) : analysisMode === 'current' && themeInsights.length === 0 && !mapSelectionResult ? (
<div className="geo-results-empty">
<strong>Nog niet geanalyseerd</strong>
@@ -1019,10 +1046,6 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
) : null}
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
<details className="geo-result-details">
<summary>Kenmerken van de gevonden objecten</summary>
@@ -18,16 +18,43 @@ describe('ModelSelector', () => {
it('opens the selector and returns an available model choice', () => {
const onChange = vi.fn()
render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />)
fireEvent.click(screen.getByRole('button', { name: /Automatisch aanbevolen/ }))
const trigger = screen.getByRole('button', { name: /Automatisch aanbevolen/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Automatisch aanbevolen/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect(onChange).toHaveBeenCalledWith('fast')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
})
it('keeps unavailable runtime models disabled', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Snel lokaal model/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true)
})
it('closes predictably and restores trigger focus after close or cancel', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Modelkeuze sluiten' }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
fireEvent(dialog, new Event('cancel', { bubbles: false, cancelable: true }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(dialog.hasAttribute('open')).toBe(false)
expect(document.activeElement).toBe(trigger)
})
})
@@ -53,7 +53,11 @@ export function ModelSelector({
advancedLabel = 'Concrete modellen',
}: ModelSelectorProps): JSX.Element {
const dialogRef = useRef<HTMLDialogElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const dialogId = useId()
const [isOpen, setIsOpen] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const allOptions = useMemo(
() => automaticOption ? [automaticOption, ...options] : options,
@@ -64,27 +68,44 @@ export function ModelSelector({
?? null
useEffect(() => {
if (!dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLElement>('[aria-checked="true"]')
selectedButton?.focus()
}, [showAdvanced])
if (!isOpen || !dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]:not(:disabled)')
const firstAvailableButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"]:not(:disabled)')
;(selectedButton ?? firstAvailableButton ?? closeButtonRef.current)?.focus()
}, [isOpen, showAdvanced, value])
const openDialog = () => {
const dialog = dialogRef.current
if (!dialog || dialog.open) return
setShowAdvanced(options.some((option) => option.id === value))
dialog.showModal()
setIsOpen(true)
}
const closeDialog = () => {
if (dialogRef.current?.open) dialogRef.current.close()
setIsOpen(false)
triggerRef.current?.focus()
}
const select = (option: ModelSelectionOption) => {
if (option.status !== 'available') return
onChange(option.id)
dialogRef.current?.close()
closeDialog()
}
return (
<div className="model-selector">
<span className="model-selector-label">{label}</span>
<button
ref={triggerRef}
type="button"
className="model-selector-trigger"
aria-haspopup="dialog"
aria-expanded={dialogRef.current?.open ?? false}
aria-expanded={isOpen}
aria-controls={dialogId}
disabled={disabled || loading || allOptions.length === 0}
onClick={() => dialogRef.current?.showModal()}
onClick={openDialog}
>
<span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span>
<span>
@@ -94,14 +115,27 @@ export function ModelSelector({
<ChevronDown aria-hidden="true" />
</button>
<dialog ref={dialogRef} className="model-selector-dialog" aria-labelledby={titleId}>
<dialog
id={dialogId}
ref={dialogRef}
className="model-selector-dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
closeDialog()
}}
onClose={() => {
setIsOpen(false)
triggerRef.current?.focus()
}}
>
<div className="model-selector-dialog-header">
<div>
<span className="section-kicker">Taakgerichte modelkeuze</span>
<h2 id={titleId}>Kies hoe GeoIntel analyseert</h2>
<p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p>
</div>
<button type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={() => dialogRef.current?.close()}>
<button ref={closeButtonRef} type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={closeDialog}>
<X aria-hidden="true" />
</button>
</div>
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { DetectionModelCapability } from '../../types'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from './modelOptions'
function model(overrides: Partial<DetectionModelCapability> = {}): DetectionModelCapability {
return {
model_id: 'segmentation-placeholder',
display_name: 'Segmentation placeholder',
framework: 'none',
task_type: 'segmentation',
supported_classes: [],
configured: false,
status: 'not_configured',
limitation_message: 'Segmentation inference is not configured for this placeholder.',
validated_regions: [],
nationally_validated: false,
operator_review_required: true,
...overrides,
}
}
describe('analysis model availability copy', () => {
it('does not expose raw English backend placeholder copy in the Dutch UI', () => {
const capability = model()
expect(analysisModelAvailabilityMessage(capability)).toContain('nog geen productiegeschikt segmentatiemodel')
expect(analysisModelDisplayName(capability)).toBe('Segmentatiemodel nog niet geconfigureerd')
expect(toAnalysisModelOption(capability).description).not.toContain('Segmentation inference')
})
it('explains an unavailable NVIDIA runtime explicitly', () => {
const capability = model({
model_id: 'yolo-configured',
task_type: 'object_detection',
status: 'accelerator_unavailable',
})
expect(analysisModelAvailabilityMessage(capability)).toContain('NVIDIA CUDA')
})
})
+53 -3
View File
@@ -1,15 +1,65 @@
import type { DetectionModelCapability } from '../../types'
import type { ModelSelectionOption } from './ModelSelector'
export function analysisModelDisplayName(model: DetectionModelCapability): string {
const knownNames: Record<string, string> = {
'yolo-configured': 'Lokaal gebouwmodel',
'manual-fixture-detector': 'Testdetectie (geen productie)',
'yolo-placeholder': 'Gebouwmodel nog niet geconfigureerd',
'segmentation-placeholder': 'Segmentatiemodel nog niet geconfigureerd',
'fixture-segmenter': 'Testsegmentatie (geen productie)',
'yolo-seg-configured': 'Lokaal YOLO-segmentatiemodel',
'sam-configured': 'Lokaal SAM-segmentatiemodel',
'yolo-seg-placeholder': 'YOLO-segmentatie nog niet geconfigureerd',
'sam-placeholder': 'SAM-segmentatie nog niet geconfigureerd',
}
return knownNames[model.model_id] ?? model.display_name
}
function supportedClassLabel(value: string): string {
const labels: Record<string, string> = {
building: 'gebouwen',
vegetation: 'vegetatie',
water: 'water',
landuse: 'landgebruik',
segment: 'algemene vlakken',
}
return labels[value.toLowerCase()] ?? value
}
export function analysisModelAvailabilityMessage(model: DetectionModelCapability): string {
const task = model.task_type === 'segmentation' ? 'segmentatiemodel' : 'detectiemodel'
if (model.model_id === 'manual-fixture-detector' || model.model_id === 'fixture-segmenter') {
return 'Alleen beschikbaar voor expliciete geautomatiseerde tests; dit is geen productie-inferentie.'
}
if (model.configured) {
return `Dit lokale ${task} is op de server geconfigureerd. Resultaten blijven operatorcontrole vereisen.`
}
if (model.status === 'accelerator_unavailable') {
return 'De vereiste NVIDIA CUDA-runtime is momenteel niet beschikbaar op de server.'
}
if (model.status === 'dependency_unavailable') {
return 'De vereiste PyTorch- of modelsoftware is nog niet beschikbaar op de server.'
}
if (model.status === 'contract_incomplete') {
return 'Het modelbestand is aanwezig, maar de versieerbare provenancecontrole is nog niet volledig.'
}
if (model.model_id.includes('placeholder')) {
return `Er is nog geen productiegeschikt ${task} aan deze registratie gekoppeld.`
}
return `Dit ${task} is nog niet volledig geconfigureerd op de server.`
}
export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption {
const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie'
const configured = model.configured && model.status !== 'not_configured'
const supportedClasses = model.supported_classes.map(supportedClassLabel)
return {
id: model.model_id,
name: model.display_name,
name: analysisModelDisplayName(model),
description: configured
? `Beschikbaar voor lokale ${task}${model.supported_classes.length ? ` van ${model.supported_classes.join(', ')}` : ''}.`
: model.limitation_message,
? `Beschikbaar voor lokale ${task}${supportedClasses.length ? ` van ${supportedClasses.join(', ')}` : ''}.`
: analysisModelAvailabilityMessage(model),
recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined,
status: configured ? 'available' : 'unavailable',
statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd',
@@ -1,5 +1,6 @@
import type {
DatasetCreateResponse,
JobRead,
SegmentationModelCapability,
SegmentationQaResult,
SegmentationRead,
@@ -7,7 +8,11 @@ import type {
SegmentationRunResponse,
} from '../../types'
import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from '../models/modelOptions'
interface SegmentationLabProps {
segmentationModels: SegmentationModelCapability[]
@@ -18,11 +23,14 @@ interface SegmentationLabProps {
segmentationTileManifestPath: string
segmentationConfidenceThreshold: number
runningSegmentation: boolean
segmentationJob: JobRead | null
segmentationRunResult: SegmentationRunResponse | null
segmentationRunError: string | null
segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string
segmentationItems: SegmentationRead[]
segmentationTotal: number
segmentationTruncated: boolean
segmentationClassFilter: string
segmentationMinConfidenceFilter: number
loadingSegmentationResults: boolean
@@ -92,11 +100,14 @@ export function SegmentationLab({
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationClassFilter,
segmentationMinConfidenceFilter,
loadingSegmentationResults,
@@ -127,8 +138,15 @@ export function SegmentationLab({
const segmentationHasTileManifest = segmentationTileManifestPath.trim().length > 0
const segmentationModelUiRunnable =
selectedSegmentationModelConfigured && selectedSegmentationModelId !== 'fixture-segmenter'
const selectedSegmentationModel = segmentationModels.find(
(model) => model.model_id === selectedSegmentationModelId,
) ?? null
const selectedSegmentationModelAvailability = selectedSegmentationModel
? analysisModelAvailabilityMessage(selectedSegmentationModel)
: selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
const segmentationRunReady =
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable && segmentationHasTileManifest
const segmentationJobActive = segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'
const segmentationRunBlockedReason = !selectedProjectId
? 'Kies eerst een werkruimte'
: !segmentationHasDataset
@@ -136,8 +154,10 @@ export function SegmentationLab({
: selectedSegmentationModelId === 'fixture-segmenter'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demos'
: !selectedSegmentationModelConfigured
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
: null
? selectedSegmentationModelAvailability
: !segmentationHasTileManifest
? 'Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand'
: null
return (
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
@@ -180,10 +200,10 @@ export function SegmentationLab({
<ul className="model-list">
{segmentationModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong>
<strong>{analysisModelDisplayName(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
@@ -219,17 +239,19 @@ export function SegmentationLab({
<span>Rasterbestand</span>
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
</div>
<div className={selectedSegmentationModelConfigured ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<div className={segmentationModelUiRunnable ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Analysemodel</span>
<strong>
{selectedSegmentationModelConfigured
{selectedSegmentationModelId === 'fixture-segmenter'
? 'Alleen beschikbaar voor geautomatiseerde tests'
: selectedSegmentationModelConfigured
? 'Het gekozen model is beschikbaar'
: selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel'}
: selectedSegmentationModelAvailability}
</strong>
</div>
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Niet vereist voor het fixturemodel'}</strong>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Koppel het tegelmanifest van het rasterbestand'}</strong>
</div>
</div>
</div>
@@ -292,29 +314,44 @@ export function SegmentationLab({
className="primary-action"
type="button"
onClick={onRunSegmentation}
disabled={runningSegmentation || !segmentationRunReady}
disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}
>
Segmentatie starten
{segmentationJob?.status === 'queued'
? 'Wachten op NVIDIA GPU…'
: runningSegmentation
? 'GPU-segmentatie wordt verwerkt…'
: 'Segmentatie starten'}
</button>
</div>
</div>
<div className="ai-lab-state-stack">
{segmentationJobActive ? (
<div className="result-state result-state-loading" role="status" aria-live="polite">
<strong>{segmentationJob?.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-segmentatie wordt uitgevoerd.'}</strong>
<p>
{segmentationJob?.status === 'queued'
? 'De server start de taak zodra de NVIDIA-worker beschikbaar is.'
: 'GeoIntel volgt de servertaak en toont na voltooiing alleen de werkelijk bewaarde polygonen.'}
</p>
<span className="muted">Taak-ID: {segmentationJob?.id}</span>
</div>
) : null}
{!selectedSegmentationModelConfigured ? (
<div className="result-state result-state-empty">
<strong>Het segmentatiemodel is nog niet gereed.</strong>
<p>{selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel.'}</p>
<p>{selectedSegmentationModelAvailability}</p>
</div>
) : null}
{segmentationRunError ? (
<div className="result-state result-state-error">
<div className="result-state result-state-error" role="alert">
<strong>De segmentatie is mislukt.</strong>
<p>{segmentationRunError}</p>
</div>
) : null}
{segmentationRunResult ? (
<div className="result-summary-card">
<p>Status: {segmentationRunResult.status === 'completed' ? 'afgerond' : segmentationRunResult.status}</p>
<div className={segmentationRunResult.segmentation_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {analysisStatusLabel(segmentationRunResult.status)}</p>
<p>{segmentationRunResult.message}</p>
<p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p>
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
@@ -382,10 +419,30 @@ export function SegmentationLab({
</div>
) : null}
<div className="ai-lab-state-stack">
<div className="result-state result-state-ready">
<strong>{segmentationItems.length} vlakken geladen</strong>
<p>{selectedSegmentationRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
</div>
{loadingSegmentationResults || segmentationRunError ? null : !selectedSegmentationRunId ? (
<div className="result-state result-state-empty">
<strong>Kies eerst een bewaarde analyse.</strong>
<p>Daarna toont GeoIntel uitsluitend de polygonen van die analyserun.</p>
</div>
) : segmentationTotal === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen bewaarde vlakken binnen deze filters.</strong>
<p>Dit bewijst niet dat het gebied geen relevante objecten bevat.</p>
</div>
) : (
<div className={segmentationTruncated ? 'result-state result-state-warning' : 'result-state result-state-ready'}>
<strong>
{segmentationTruncated
? `${segmentationItems.length} van ${segmentationTotal} vlakken geladen`
: `${segmentationTotal} vlakken geladen`}
</strong>
<p>
{segmentationTruncated
? 'De kaart en tabel tonen een begrensde pagina. Gebruik filters om het resultaat gericht te verfijnen.'
: 'Deze resultaten zijn bewaard in de database.'}
</p>
</div>
)}
</div>
{segmentationItems.length > 0 ? (
<div className="table-scroll">
@@ -15,6 +15,7 @@ import { GeoIntelMark } from '../brand/GeoIntelBrand'
export interface WorkspaceNavigationItem {
key: WorkspaceKey
label: string
navigationLabel?: string
description: string
}
@@ -78,7 +79,7 @@ export function WorkbenchNavigation({
data-testid={`workspace-nav-${item.key}`}
>
<Icon className="nav-item-icon" aria-hidden="true" strokeWidth={1.8} />
<span>{item.label}</span>
<span>{item.navigationLabel ?? item.label}</span>
</button>
)
})}
@@ -85,6 +85,28 @@ describe('useCoverageResolver', () => {
expect(result.current.coverageDurationMs).toBeNull()
})
it('clears stale coverage as soon as a different selection starts resolving', async () => {
const nextBbox = { ...bbox, min_x: 5.1, max_x: 5.2 }
const { result, rerender } = renderHook(
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
{ initialProps: { selection: bbox } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverage).toEqual(coverageResult)
rerender({ selection: nextBbox })
expect(result.current.coverage).toBeNull()
expect(result.current.loadingCoverage).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(249)
})
expect(mocks.resolveCoverage).toHaveBeenCalledTimes(1)
})
it('exposes provider failures without retaining stale results', async () => {
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
+6 -3
View File
@@ -26,11 +26,14 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
return
}
let cancelled = false
// A new AOI must never temporarily display the previous AOI's coverage.
// Clear immediately; the debounce only postpones the network request.
setCoverage(null)
setCoverageError(null)
setLoadingCoverage(true)
setCoverageDurationMs(null)
const timer = window.setTimeout(() => {
const startedAt = Date.now()
setLoadingCoverage(true)
setCoverageError(null)
setCoverageDurationMs(null)
externalApi.resolveCoverage({
projectId,
bbox: {
@@ -0,0 +1,301 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DetectionRunRead, JobRead, YoloPreflightResponse } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
listModelAssets: vi.fn(),
getYoloPreflight: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
listDetections: vi.fn(),
getRunGeoJson: vi.fn(),
getRun: vi.fn(),
compareWithReference: vi.fn(),
rasterInspect: vi.fn(),
rasterTile: vi.fn(),
upload: vi.fn(),
}))
vi.mock('../services/api', () => ({
detectionApi: {
listModels: mocks.listModels,
listModelAssets: mocks.listModelAssets,
getYoloPreflight: mocks.getYoloPreflight,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
listDetections: mocks.listDetections,
getRunGeoJson: mocks.getRunGeoJson,
getRun: mocks.getRun,
compareWithReference: mocks.compareWithReference,
},
datasetsApi: {
rasterInspect: mocks.rasterInspect,
rasterTile: mocks.rasterTile,
upload: mocks.upload,
},
}))
import { useDetectionWorkflow } from './useDetectionWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'detection.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { detection_count: 1 },
}
const persistedRun: DetectionRunRead = {
id: analysisRunId,
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'detection',
status: 'success',
model_name: 'yolo-configured',
parameters_json: {},
result_json: { detection_count: 1 },
}
function preflight(acceleratorReady: boolean): YoloPreflightResponse {
return {
model_id: 'yolo-configured',
status: acceleratorReady ? 'ready' : 'accelerator_unavailable',
message: acceleratorReady ? 'Gereed' : 'NVIDIA CUDA is niet beschikbaar',
checks: {
enabled: true,
dependencies_available: true,
accelerator_ready: acceleratorReady,
model_path_set: true,
model_file_exists: true,
model_load_requested: false,
manifest_path_set: true,
manifest_valid: true,
tile_paths_exist: true,
tile_limit_ok: true,
},
runtime: { dependencies_assumed: false, cuda_available: acceleratorReady },
tile_count: 1,
max_tiles: 256,
will_download_models: false,
will_run_inference: acceleratorReady,
}
}
function renderWorkflow() {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useDetectionWorkflow({
selectedProjectId: projectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useDetectionWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.listDetections.mockResolvedValue({ items: [], total: 1, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-configured',
display_name: 'YOLO',
framework: 'ultralytics/pytorch',
task_type: 'object_detection',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.listModelAssets.mockResolvedValue({ items: [], total: 0, model_directory: '/models' })
})
it('queues, follows and loads a persisted result without a synchronous inference fallback', async () => {
mocks.runAsync.mockResolvedValue(completedJob)
const { result, loadProjectData } = renderWorkflow()
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.runDetection()
})
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(result.current.detectionJob?.status).toBe('success')
expect(result.current.detectionRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
detection_count: 1,
status: 'success',
})
expect(result.current.detectionWorkflowStage).toBe('complete')
expect(result.current.detectionRunError).toBeNull()
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('blocks the queue when preflight says the NVIDIA accelerator is unavailable', async () => {
mocks.getYoloPreflight.mockResolvedValue(preflight(false))
const { result } = renderWorkflow()
await act(async () => {
await result.current.loadDetectionModels()
})
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.prepareAndRunDetection()
})
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.detectionWorkflowStage).toBe('failed')
expect(result.current.detectionRunError).toContain('NVIDIA CUDA')
})
it('does not let a late run list from another project overwrite the active project', async () => {
let resolveOlder!: (value: { items: DetectionRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: DetectionRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.detectionRuns).toEqual([projectTwoRun])
expect(result.current.selectedDetectionRunId).toBe('run-2')
})
it('does not let late detection results from another project overwrite the active project', async () => {
type DetectionList = { items: Array<{ id: string }>; total: number; truncated: boolean }
type DetectionGeoJson = { type: 'FeatureCollection'; features: Array<{ id: string }> }
let resolveOlderList!: (value: DetectionList) => void
let resolveNewerList!: (value: DetectionList) => void
let resolveOlderGeoJson!: (value: DetectionGeoJson) => void
let resolveNewerGeoJson!: (value: DetectionGeoJson) => void
mocks.listDetections
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeoJson = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeoJson = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionResults('run-2') })
await act(async () => {
resolveNewerList({ items: [{ id: 'result-2' }], total: 1, truncated: false })
resolveNewerGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-2' }] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [{ id: 'result-1' }], total: 1, truncated: false })
resolveOlderGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-1' }] })
await olderRequest
})
expect(result.current.detectionItems).toEqual([{ id: 'result-2' }])
expect(result.current.detectionGeoJson).toEqual({
type: 'FeatureCollection',
features: [{ id: 'feature-2' }],
})
expect(result.current.loadingDetectionResults).toBe(false)
})
it('drops a late queue response when the user has already changed project', async () => {
let resolveQueuedJob!: (value: JobRead) => void
mocks.runAsync.mockReturnValue(new Promise((resolve) => { resolveQueuedJob = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
let request!: Promise<void>
act(() => { request = result.current.runDetection() })
rerender({ selectedProjectId: 'project-2' })
await act(async () => {
resolveQueuedJob(completedJob)
await request
})
expect(result.current.detectionJob).toBeNull()
expect(result.current.detectionRunResult).toBeNull()
expect(result.current.runningDetection).toBe(false)
expect(mocks.getRun).not.toHaveBeenCalled()
})
})
+300 -64
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { datasetsApi, detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
@@ -13,6 +13,12 @@ import type {
YoloPreflightResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import {
analysisRunIdFromJob,
completedDetectionResponse,
DetectionJobError,
waitForDetectionJob,
} from '../services/detectionJob'
interface DetectionWorkflowOptions {
selectedProjectId: string | null
@@ -88,6 +94,16 @@ function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, ov
return Math.ceil(width / step) * Math.ceil(height / step)
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de detectietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useDetectionWorkflow({
selectedProjectId,
rasterDatasets,
@@ -106,6 +122,7 @@ export function useDetectionWorkflow({
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false)
const [detectionJob, setDetectionJob] = useState<JobRead | null>(null)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
@@ -130,6 +147,36 @@ export function useDetectionWorkflow({
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
const activeDetectionControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const detectionExecutionSequence = useRef(0)
const detectionRunsRequestSequence = useRef(0)
const detectionResultsRequestSequence = useRef(0)
const detectionQaRequestSequence = useRef(0)
const detectionCalibrationSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
detectionExecutionSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
setDetectionJob(null)
setRunningDetection(false)
setDetectionRunResult(null)
setDetectionRunError(null)
setDetectionWorkflowStage('idle')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
return () => {
activeDetectionControllerRef.current?.abort()
}
}, [selectedProjectId])
const loadDetectionModels = async () => {
setLoadingDetectionModels(true)
@@ -187,26 +234,36 @@ export function useDetectionWorkflow({
}
const loadDetectionRuns = async (projectId = selectedProjectId) => {
const sequence = detectionRunsRequestSequence.current + 1
detectionRunsRequestSequence.current = sequence
if (!projectId) {
setDetectionRuns([])
return
}
try {
const response = await detectionApi.listRuns({ project_id: projectId })
if (
detectionRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setDetectionRuns(response.items)
if (!selectedDetectionRunId && response.items.length > 0) {
setSelectedDetectionRunId(response.items[0].id)
}
setSelectedDetectionRunId((current) => current || response.items[0]?.id || '')
} catch (error) {
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
if (
detectionRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
}
}
}
const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => {
if (!analysisRunId) {
const sequence = detectionResultsRequestSequence.current + 1
detectionResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null)
@@ -216,7 +273,7 @@ export function useDetectionWorkflow({
setDetectionRunError(null)
try {
const params = {
project_id: selectedProjectId ?? '',
project_id: requestProjectId,
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
}
@@ -224,14 +281,28 @@ export function useDetectionWorkflow({
detectionApi.listDetections(analysisRunId, params),
detectionApi.getRunGeoJson(analysisRunId, params),
])
if (
detectionResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
setDetectionItems(detectionsResponse.items)
setDetectionTotal(detectionsResponse.total)
setDetectionTruncated(Boolean(detectionsResponse.truncated))
setDetectionGeoJson(geoJsonResponse)
} catch (error) {
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
}
} finally {
setLoadingDetectionResults(false)
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingDetectionResults(false)
}
}
}
@@ -241,23 +312,91 @@ export function useDetectionWorkflow({
manifestPath: string | null,
modelId = selectedDetectionModelId,
modelAssetId = selectedModelAssetId,
confidenceThreshold = detectionConfidenceThreshold,
parametersJson: Record<string, unknown> = {},
) => {
const result = await detectionApi.run({
if (
(activeDetectionControllerRef.current && !activeDetectionControllerRef.current.signal.aborted)
|| detectionJob?.status === 'queued'
|| detectionJob?.status === 'running'
) {
throw new DetectionJobError(
'Er wordt al een GPU-detectietaak gevolgd. Wacht tot die taak klaar is voordat u een nieuwe start.',
'DETECTION_JOB_ALREADY_ACTIVE',
detectionJob?.id ?? 'unknown',
)
}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: modelId,
model_asset_id: modelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
confidence_threshold: confidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: {},
})
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
await loadDetectionResults(result.analysis_run_id)
await loadProjectData(projectId)
return result
parameters_json: parametersJson,
}
const controller = new AbortController()
const executionSequence = detectionExecutionSequence.current + 1
detectionExecutionSequence.current = executionSequence
activeDetectionControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| detectionExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
try {
setDetectionJob(null)
const queuedJob = await detectionApi.runAsync(request)
assertExecutionCurrent()
setDetectionJob(queuedJob)
const completedJob = await waitForDetectionJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
detectionExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionJob(job)
}
},
})
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromJob(completedJob)
const run = explicitAnalysisRunId
? await detectionApi.getRun(explicitAnalysisRunId, projectId)
: (await detectionApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new DetectionJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde detectierun ontbreekt.',
'DETECTION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedDetectionResponse(request, completedJob, run)
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
assertExecutionCurrent()
await loadDetectionResults(result.analysis_run_id)
assertExecutionCurrent()
await loadProjectData(projectId)
assertExecutionCurrent()
return result
} finally {
if (activeDetectionControllerRef.current === controller) {
activeDetectionControllerRef.current = null
}
}
}
const runDetection = async () => {
@@ -265,6 +404,7 @@ export function useDetectionWorkflow({
setDetectionRunError('Kies eerst een werkruimte')
return
}
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId
if (!datasetId) {
setDetectionRunError('Kies eerst een rasterbron')
@@ -275,13 +415,19 @@ export function useDetectionWorkflow({
setRunningDetection(true)
setDetectionWorkflowStage('detecting')
try {
await executeDetection(selectedProjectId, datasetId, detectionTileManifestPath.trim() || null)
setDetectionWorkflowStage('complete')
await executeDetection(projectId, datasetId, detectionTileManifestPath.trim() || null)
if (selectedProjectIdRef.current === projectId) {
setDetectionWorkflowStage('complete')
}
} catch (error) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
}
} finally {
setRunningDetection(false)
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false)
}
}
}
@@ -290,10 +436,11 @@ export function useDetectionWorkflow({
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
}
const projectId = selectedProjectId
setDetectionRunError(null)
setDetectionWorkflowStage('uploading')
try {
const dataset = await datasetsApi.upload(selectedProjectId, {
const dataset = await datasetsApi.upload(projectId, {
file,
datasetType: 'raster',
source: 'user_upload',
@@ -302,15 +449,19 @@ export function useDetectionWorkflow({
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
})
if (selectedProjectIdRef.current !== projectId) throw abortedError()
setSelectedDetectionDatasetId(dataset.id)
setDetectionTileManifestPath('')
setDetectionRunResult(null)
setDetectionWorkflowStage('ready')
await loadProjectData(selectedProjectId)
await loadProjectData(projectId)
if (selectedProjectIdRef.current !== projectId) throw abortedError()
return true
} catch (error) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
}
return false
}
}
@@ -323,6 +474,10 @@ export function useDetectionWorkflow({
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return null
}
const projectId = selectedProjectId
const assertProjectCurrent = () => {
if (selectedProjectIdRef.current !== projectId) throw abortedError()
}
const datasetId = datasetIdOverride || selectedDetectionDatasetId
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
@@ -334,7 +489,7 @@ export function useDetectionWorkflow({
: selectedModelAssetId
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar')
setDetectionRunError('Het gekozen productie-analysemodel is niet beschikbaar; vernieuw de modelstatus en controleer de serverconfiguratie')
return null
}
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
@@ -349,7 +504,8 @@ export function useDetectionWorkflow({
let manifestPath = detectionTileManifestPath.trim()
if (!manifestPath) {
setDetectionWorkflowStage('tiling')
const inspection = await datasetsApi.rasterInspect(selectedProjectId, datasetId)
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertProjectCurrent()
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256
if (expectedTileCount === null) {
@@ -360,10 +516,11 @@ export function useDetectionWorkflow({
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(selectedProjectId, datasetId, {
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
tile_size: 512,
overlap: 64,
})
assertProjectCurrent()
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
@@ -376,6 +533,7 @@ export function useDetectionWorkflow({
tile_manifest_path: manifestPath,
model_asset_id: effectiveModelAssetId || null,
})
assertProjectCurrent()
setYoloPreflight(preflight)
setYoloPreflightError(null)
if (
@@ -383,6 +541,7 @@ export function useDetectionWorkflow({
!preflight.checks.tile_paths_exist ||
!preflight.checks.tile_limit_ok ||
!preflight.checks.dependencies_available ||
preflight.checks.accelerator_ready !== true ||
!preflight.checks.model_file_exists
) {
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
@@ -390,20 +549,25 @@ export function useDetectionWorkflow({
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
selectedProjectId,
projectId,
datasetId,
manifestPath,
effectiveModelId,
effectiveModelAssetId,
)
assertProjectCurrent()
setDetectionWorkflowStage('complete')
return result
} catch (error) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
}
return null
} finally {
setRunningDetection(false)
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false)
}
}
}
@@ -421,26 +585,51 @@ export function useDetectionWorkflow({
setDetectionQaError('Kies eerst een referentiebron')
return null
}
const projectId = selectedProjectIdRef.current
if (!projectId) {
setDetectionQaError('Kies eerst een werkruimte')
return null
}
const sequence = detectionQaRequestSequence.current + 1
detectionQaRequestSequence.current = sequence
setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null)
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(analysisRunId, selectedProjectId!, {
const result = await detectionApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
})
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
setDetectionQaResult(result)
await loadQualityChecks(selectedProjectId)
await loadQualityChecks(projectId)
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
return result
} catch (error) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
}
return null
} finally {
setRunningDetectionQa(false)
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionQa(false)
}
}
}
@@ -452,6 +641,7 @@ export function useDetectionWorkflow({
setDetectionCalibrationError('Kies eerst een werkruimte om te kalibreren')
return
}
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId
if (!datasetId) {
setDetectionCalibrationError('Kies eerst een rasterbron om te kalibreren')
@@ -461,13 +651,14 @@ export function useDetectionWorkflow({
setDetectionCalibrationError('Kies eerst een referentiebron om te kalibreren')
return
}
const referenceDatasetId = detectionReferenceDatasetId
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
setDetectionCalibrationError('Kies eerst een geconfigureerd detectiemodel; testgegevens kunnen niet gekalibreerd worden')
return
}
if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) {
setDetectionCalibrationError('Configured YOLO calibration requires a tile manifest')
setDetectionCalibrationError('Kalibratie met YOLO vereist een beeldtegelmanifest')
return
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
@@ -476,9 +667,17 @@ export function useDetectionWorkflow({
}
const thresholds = parseCalibrationThresholds(calibrationThresholdText)
if (thresholds.length === 0) {
setDetectionCalibrationError('Provide at least one valid threshold between 0 and 1')
setDetectionCalibrationError('Geef minstens één geldige drempel tussen 0 en 1 op')
return
}
const sequence = detectionCalibrationSequence.current + 1
detectionCalibrationSequence.current = sequence
const assertCalibrationCurrent = () => {
if (
detectionCalibrationSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) throw abortedError()
}
setDetectionCalibrationError(null)
setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' })))
setRunningDetectionCalibration(true)
@@ -492,24 +691,28 @@ export function useDetectionWorkflow({
setDetectionCalibrationRows((rows) =>
rows.map((row) => ({ ...row, status: 'running', message: 'Eén inferentie voor alle drempels' })),
)
const result = await detectionApi.run({
project_id: selectedProjectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
confidence_threshold: lowestThreshold,
tile_manifest_path: detectionTileManifestPath.trim() || null,
parameters_json: { calibration: true, calibration_thresholds: thresholds },
})
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
projectId,
datasetId,
detectionTileManifestPath.trim() || null,
selectedDetectionModelId,
selectedModelAssetId,
lowestThreshold,
{ calibration: true, calibration_thresholds: thresholds },
)
assertCalibrationCurrent()
setDetectionWorkflowStage('complete')
setSelectedDetectionRunId(result.analysis_run_id)
const qa = await detectionApi.compareWithReference(result.analysis_run_id, selectedProjectId, {
reference_dataset_id: detectionReferenceDatasetId,
const qa = await detectionApi.compareWithReference(result.analysis_run_id, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
min_confidence: null,
calibration_thresholds: thresholds,
})
assertCalibrationCurrent()
const sweep = new Map((qa.calibration_sweep ?? []).map((point) => [point.min_confidence, point]))
setDetectionCalibrationRows((rows) =>
@@ -536,17 +739,31 @@ export function useDetectionWorkflow({
}),
)
await loadDetectionRuns(selectedProjectId)
await loadQualityChecks(selectedProjectId)
await loadProjectData(selectedProjectId)
await loadDetectionRuns(projectId)
assertCalibrationCurrent()
await loadQualityChecks(projectId)
assertCalibrationCurrent()
await loadProjectData(projectId)
assertCalibrationCurrent()
} catch (error) {
const message = formatError(error, 'Calibration failed')
setDetectionCalibrationRows((rows) =>
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
)
setDetectionCalibrationError(message)
if (
!isAbortError(error)
&& detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
const message = formatError(error, 'Kalibratie mislukt')
setDetectionCalibrationRows((rows) =>
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
)
setDetectionCalibrationError(message)
}
} finally {
setRunningDetectionCalibration(false)
if (
detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionCalibration(false)
}
}
}
@@ -557,14 +774,32 @@ export function useDetectionWorkflow({
}
const resetDetectionForProject = () => {
detectionExecutionSequence.current += 1
detectionRunsRequestSequence.current += 1
detectionResultsRequestSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
setSelectedDetectionDatasetId('')
setDetectionRuns([])
setSelectedDetectionRunId('')
setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null)
setDetectionRunResult(null)
setDetectionJob(null)
setDetectionReferenceDatasetId('')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
setDetectionRunError(null)
setLoadingDetectionResults(false)
setRunningDetection(false)
setDetectionWorkflowStage('idle')
}
@@ -580,6 +815,7 @@ export function useDetectionWorkflow({
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
+117
View File
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantQueryResponse } from '../types'
const mocks = vi.hoisted(() => ({
status: vi.fn(),
models: vi.fn(),
query: vi.fn(),
}))
vi.mock('../services/api/assistant', () => ({
assistantApi: mocks,
}))
import { useGeoAssistant } from './useGeoAssistant'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function response(answer: string): AssistantQueryResponse {
return {
answer,
model: 'geo-model',
scope_label: 'testgebied',
context_metrics: [],
temporal_series: [],
source_dataset_ids: [],
warnings: [],
generated_at: '2026-08-23T12:00:00Z',
}
}
describe('useGeoAssistant request scope', () => {
beforeEach(() => {
window.localStorage.clear()
mocks.status.mockResolvedValue({
enabled: true,
reachable: true,
status: 'ready',
base_url: 'http://localhost',
default_model: 'geo-model',
model_count: 1,
limitation_message: '',
})
mocks.models.mockResolvedValue({
items: [{ name: 'geo-model', capabilities: ['chat'] }],
total: 1,
default_model: 'geo-model',
})
})
it('ignores an answer that returns after the active project changed', async () => {
const pending = deferred<AssistantQueryResponse>()
mocks.query.mockReturnValueOnce(pending.promise)
const { result, rerender } = renderHook(
({ projectId }) => useGeoAssistant({
selectedProjectId: projectId,
selectedAreaId: null,
selectionBbox: null,
}),
{ initialProps: { projectId: 'project-1' } },
)
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let request!: Promise<boolean>
act(() => {
request = result.current.ask('Wat staat hier?')
})
rerender({ projectId: 'project-2' })
await act(async () => {
pending.resolve(response('antwoord uit project 1'))
await request
})
expect(result.current.messages).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('lets only the newest request update a conversation', async () => {
const older = deferred<AssistantQueryResponse>()
const newer = deferred<AssistantQueryResponse>()
mocks.query
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
const { result } = renderHook(() => useGeoAssistant({
selectedProjectId: 'project-1',
selectedAreaId: null,
selectionBbox: null,
}))
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let olderRequest!: Promise<boolean>
let newerRequest!: Promise<boolean>
act(() => { olderRequest = result.current.ask('Eerste vraag') })
act(() => { newerRequest = result.current.ask('Tweede vraag') })
await act(async () => {
newer.resolve(response('nieuwste antwoord'))
await newerRequest
})
await act(async () => {
older.resolve(response('verouderd antwoord'))
await olderRequest
})
const assistantMessages = result.current.messages.filter((message) => message.role === 'assistant')
expect(assistantMessages.map((message) => message.content)).toEqual(['nieuwste antwoord'])
expect(result.current.loading).toBe(false)
})
})
+100 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { assistantApi } from '../services/api/assistant'
import type {
@@ -36,15 +36,61 @@ function readStoredPreference(): string {
}
}
function assistantScopeKey(
projectId: string | null,
areaId: string | null,
bbox: VectorSelectionBBox | null,
): string {
return JSON.stringify([
projectId,
areaId,
bbox?.min_x ?? null,
bbox?.min_y ?? null,
bbox?.max_x ?? null,
bbox?.max_y ?? null,
bbox?.crs ?? null,
])
}
interface AssistantConversationState {
scopeKey: string
messages: GeoAssistantMessage[]
}
interface AssistantRequestState {
scopeKey: string
requestId: number
loading: boolean
error: string | null
}
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
const scopeKey = assistantScopeKey(selectedProjectId, selectedAreaId, selectionBbox)
const activeScopeRef = useRef(scopeKey)
const latestRequestIdRef = useRef(0)
if (activeScopeRef.current !== scopeKey) {
activeScopeRef.current = scopeKey
latestRequestIdRef.current += 1
}
const [status, setStatus] = useState<AssistantStatus | null>(null)
const [models, setModels] = useState<AssistantModelRead[]>([])
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
const [defaultModel, setDefaultModel] = useState('')
const [messages, setMessages] = useState<GeoAssistantMessage[]>([])
const [loading, setLoading] = useState(false)
const [conversation, setConversation] = useState<AssistantConversationState>({ scopeKey, messages: [] })
const [requestState, setRequestState] = useState<AssistantRequestState>({
scopeKey,
requestId: 0,
loading: false,
error: null,
})
const [loadingModels, setLoadingModels] = useState(false)
const [error, setError] = useState<string | null>(null)
const [modelError, setModelError] = useState<string | null>(null)
const messages = conversation.scopeKey === scopeKey ? conversation.messages : []
const loading = requestState.scopeKey === scopeKey && requestState.loading
const queryError = requestState.scopeKey === scopeKey ? requestState.error : null
const error = queryError ?? modelError
const selectedModel = useMemo(() => {
const available = new Set(models.map((model) => model.name))
@@ -62,7 +108,7 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
const loadModels = async () => {
setLoadingModels(true)
setError(null)
setModelError(null)
try {
const currentStatus = await assistantApi.status()
setStatus(currentStatus)
@@ -82,22 +128,39 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
setStatus(null)
setModels([])
setDefaultModel('')
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
setModelError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
} finally {
setLoadingModels(false)
}
}
useEffect(() => { void loadModels() }, [])
useEffect(() => { setMessages([]); setError(null) }, [selectedProjectId])
useEffect(() => {
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}, [scopeKey])
const ask = async (question: string): Promise<boolean> => {
const trimmed = question.trim()
if (!selectedProjectId || !trimmed || !selectedModel) return false
const requestId = latestRequestIdRef.current + 1
latestRequestIdRef.current = requestId
const requestScopeKey = scopeKey
const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed }
setMessages((current) => [...current, userMessage])
setLoading(true)
setError(null)
setConversation((current) => ({
scopeKey: requestScopeKey,
messages: [...(current.scopeKey === requestScopeKey ? current.messages : []), userMessage],
}))
setRequestState({ scopeKey: requestScopeKey, requestId, loading: true, error: null })
const isLatestRequest = () => (
latestRequestIdRef.current === requestId
&& activeScopeRef.current === requestScopeKey
)
try {
const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
const result = await assistantApi.query(selectedProjectId, {
@@ -107,17 +170,40 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
area_id: selectedAreaId,
history,
})
setMessages((current) => [...current, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }])
if (!isLatestRequest()) return false
setConversation((current) => current.scopeKey === requestScopeKey ? {
scopeKey: requestScopeKey,
messages: [...current.messages, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }],
} : current)
return true
} catch (requestError) {
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'))
if (!isLatestRequest()) return false
setRequestState({
scopeKey: requestScopeKey,
requestId,
loading: false,
error: formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'),
})
return false
} finally {
setLoading(false)
if (isLatestRequest()) {
setRequestState((current) => current.scopeKey === requestScopeKey && current.requestId === requestId
? { ...current, loading: false }
: current)
}
}
}
const clear = () => { setMessages([]); setError(null) }
const clear = () => {
latestRequestIdRef.current += 1
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}
return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel }
}
@@ -0,0 +1,220 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
getRun: vi.fn(),
listSegmentations: vi.fn(),
getRunGeoJson: vi.fn(),
compareWithReference: vi.fn(),
}))
vi.mock('../services/api', () => ({
segmentationApi: {
listModels: mocks.listModels,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
getRun: mocks.getRun,
listSegmentations: mocks.listSegmentations,
getRunGeoJson: mocks.getRunGeoJson,
compareWithReference: mocks.compareWithReference,
},
}))
import { useSegmentationWorkflow } from './useSegmentationWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'segmentation.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { analysis_run_id: analysisRunId, segmentation_count: 2 },
}
const persistedRun: SegmentationRunRead = {
id: analysisRunId,
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: 2 },
}
function renderWorkflow(selectedProjectId = projectId) {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useSegmentationWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-seg-configured',
display_name: 'YOLO segmentatie',
framework: 'ultralytics/pytorch',
task_type: 'segmentation',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.runAsync.mockResolvedValue(completedJob)
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.getRun.mockResolvedValue(persistedRun)
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
})
it('queues, follows and reconciles a persisted segmentation result', async () => {
const { result, loadProjectData } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => {
result.current.setSelectedSegmentationDatasetId(datasetId)
result.current.setSegmentationTileManifestPath('/tiles/manifest.json')
})
await act(async () => { await result.current.runSegmentation() })
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-seg-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(mocks.getRun).toHaveBeenCalledWith(analysisRunId, projectId)
expect(result.current.segmentationRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
segmentation_count: 2,
status: 'success',
})
expect(result.current.segmentationRunError).toBeNull()
expect(result.current.segmentationTotal).toBe(0)
expect(result.current.segmentationTruncated).toBe(false)
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('does not queue a configured model without a tile manifest', async () => {
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
})
it('ignores a late run list after the active project changes', async () => {
let resolveOlder!: (value: { items: SegmentationRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: SegmentationRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadSegmentationRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadSegmentationRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.segmentationRuns).toEqual([projectTwoRun])
expect(result.current.selectedSegmentationRunId).toBe('run-2')
})
it('ignores late polygons from another project and clears an empty selection loader', async () => {
let resolveOlderList!: (value: { items: SegmentationRead[]; total: number }) => void
let resolveNewerList!: (value: { items: SegmentationRead[]; total: number }) => void
let resolveOlderGeo!: (value: GeoJSON.FeatureCollection) => void
let resolveNewerGeo!: (value: GeoJSON.FeatureCollection) => void
mocks.listSegmentations
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeo = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeo = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
const oldItem: SegmentationRead = {
id: 'segment-1', project_id: 'project-1', analysis_run_id: 'run-1', model_name: 'model', class_name: 'building',
}
const newItem: SegmentationRead = {
id: 'segment-2', project_id: 'project-2', analysis_run_id: 'run-2', model_name: 'model', class_name: 'building',
}
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadSegmentationResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadSegmentationResults('run-2') })
await act(async () => {
resolveNewerList({ items: [newItem], total: 1 })
resolveNewerGeo({ type: 'FeatureCollection', features: [] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [oldItem], total: 1 })
resolveOlderGeo({ type: 'FeatureCollection', features: [] })
await olderRequest
})
expect(result.current.segmentationItems).toEqual([newItem])
await act(async () => { await result.current.loadSegmentationResults('') })
expect(result.current.loadingSegmentationResults).toBe(false)
expect(result.current.segmentationItems).toEqual([])
})
})
+246 -29
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { segmentationApi } from '../services/api'
import type {
DatasetCreateResponse,
JobRead,
QualityCheckRead,
SegmentationModelCapability,
SegmentationQaResult,
@@ -10,6 +11,12 @@ import type {
SegmentationRunResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import {
analysisRunIdFromSegmentationJob,
completedSegmentationResponse,
SegmentationJobError,
waitForSegmentationJob,
} from '../services/segmentationJob'
interface SegmentationWorkflowOptions {
selectedProjectId: string | null
@@ -19,6 +26,16 @@ interface SegmentationWorkflowOptions {
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de segmentatietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useSegmentationWorkflow({
selectedProjectId,
rasterDatasets,
@@ -34,11 +51,14 @@ export function useSegmentationWorkflow({
const [segmentationTileManifestPath, setSegmentationTileManifestPath] = useState('')
const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5)
const [runningSegmentation, setRunningSegmentation] = useState(false)
const [segmentationJob, setSegmentationJob] = useState<JobRead | null>(null)
const [segmentationRunResult, setSegmentationRunResult] = useState<SegmentationRunResponse | null>(null)
const [segmentationRunError, setSegmentationRunError] = useState<string | null>(null)
const [segmentationRuns, setSegmentationRuns] = useState<SegmentationRunRead[]>([])
const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('')
const [segmentationItems, setSegmentationItems] = useState<SegmentationRead[]>([])
const [segmentationTotal, setSegmentationTotal] = useState(0)
const [segmentationTruncated, setSegmentationTruncated] = useState(false)
const [segmentationGeoJson, setSegmentationGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
const [segmentationClassFilter, setSegmentationClassFilter] = useState('')
const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0)
@@ -47,6 +67,41 @@ export function useSegmentationWorkflow({
const [segmentationQaResult, setSegmentationQaResult] = useState<SegmentationQaResult | null>(null)
const [segmentationQaError, setSegmentationQaError] = useState<string | null>(null)
const [runningSegmentationQa, setRunningSegmentationQa] = useState(false)
const activeSegmentationControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const segmentationExecutionSequence = useRef(0)
const segmentationRunsRequestSequence = useRef(0)
const segmentationResultsRequestSequence = useRef(0)
const segmentationQaRequestSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeSegmentationControllerRef.current?.abort()
activeSegmentationControllerRef.current = null
segmentationExecutionSequence.current += 1
segmentationRunsRequestSequence.current += 1
segmentationResultsRequestSequence.current += 1
segmentationQaRequestSequence.current += 1
setSelectedSegmentationDatasetId('')
setSegmentationRuns([])
setSelectedSegmentationRunId('')
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setSegmentationRunResult(null)
setSegmentationRunError(null)
setSegmentationJob(null)
setRunningSegmentation(false)
setLoadingSegmentationResults(false)
setSegmentationTileManifestPath('')
setSegmentationQaResult(null)
setSegmentationQaError(null)
setRunningSegmentationQa(false)
return () => {
activeSegmentationControllerRef.current?.abort()
}
}, [selectedProjectId])
const selectedSegmentationModel = useMemo(
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
@@ -79,32 +134,54 @@ export function useSegmentationWorkflow({
}
const loadSegmentationRuns = async (projectId = selectedProjectId) => {
const sequence = segmentationRunsRequestSequence.current + 1
segmentationRunsRequestSequence.current = sequence
if (!projectId) {
setSegmentationRuns([])
setSelectedSegmentationRunId('')
return
}
try {
const response = await segmentationApi.listRuns({ project_id: projectId })
if (
segmentationRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setSegmentationRuns(response.items)
if (!selectedSegmentationRunId && response.items.length > 0) {
setSelectedSegmentationRunId(response.items[0].id)
}
setSelectedSegmentationRunId((current) => (
response.items.some((run) => run.id === current) ? current : response.items[0]?.id ?? ''
))
} catch (error) {
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
if (
segmentationRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
}
}
}
const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => {
if (!analysisRunId) {
const sequence = segmentationResultsRequestSequence.current + 1
segmentationResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setLoadingSegmentationResults(false)
return
}
setLoadingSegmentationResults(true)
setSegmentationRunError(null)
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
try {
const params = {
project_id: selectedProjectId ?? '',
project_id: requestProjectId,
class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
}
@@ -112,12 +189,33 @@ export function useSegmentationWorkflow({
segmentationApi.listSegmentations(analysisRunId, params),
segmentationApi.getRunGeoJson(analysisRunId, params),
])
if (
segmentationResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
if (segmentationsResponse.items.some((item) => (
item.project_id !== requestProjectId || item.analysis_run_id !== analysisRunId
))) {
throw new Error('De server retourneerde segmentaties uit een andere werkruimte of analyserun')
}
setSegmentationItems(segmentationsResponse.items)
setSegmentationTotal(segmentationsResponse.total)
setSegmentationTruncated(Boolean(segmentationsResponse.truncated))
setSegmentationGeoJson(geoJsonResponse)
} catch (error) {
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
if (
segmentationResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
}
} finally {
setLoadingSegmentationResults(false)
if (
segmentationResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingSegmentationResults(false)
}
}
}
@@ -135,31 +233,109 @@ export function useSegmentationWorkflow({
setSegmentationRunError('Het gekozen segmentatiemodel is niet geconfigureerd')
return
}
if (selectedSegmentationModelId === 'fixture-segmenter') {
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
return
}
if (!segmentationTileManifestPath.trim()) {
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
return
}
if (
(activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|| segmentationJob?.status === 'queued'
|| segmentationJob?.status === 'running'
) {
setSegmentationRunError('Er wordt al een GPU-segmentatietaak verwerkt. Wacht tot die taak klaar is.')
return
}
const projectId = selectedProjectId
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters,
}
const controller = new AbortController()
const executionSequence = segmentationExecutionSequence.current + 1
segmentationExecutionSequence.current = executionSequence
activeSegmentationControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| segmentationExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
setSegmentationRunError(null)
setSegmentationRunResult(null)
setRunningSegmentation(true)
setSegmentationJob(null)
try {
const parameters =
selectedSegmentationModelId === 'fixture-segmenter'
? { fixture_mode: true, fixture_segmentations: [] }
: {}
const result = await segmentationApi.run({
project_id: selectedProjectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters,
const queuedJob = await segmentationApi.runAsync(request)
assertExecutionCurrent()
setSegmentationJob(queuedJob)
const completedJob = await waitForSegmentationJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationJob(job)
}
},
})
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromSegmentationJob(completedJob)
const run = explicitAnalysisRunId
? await segmentationApi.getRun(explicitAnalysisRunId, projectId)
: (await segmentationApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new SegmentationJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde segmentatierun ontbreekt.',
'SEGMENTATION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedSegmentationResponse(request, completedJob, run)
setSegmentationRunError(null)
setSegmentationRunResult(result)
setSelectedSegmentationRunId(result.analysis_run_id)
await loadSegmentationRuns(selectedProjectId)
await loadSegmentationRuns(projectId)
assertExecutionCurrent()
await loadSegmentationResults(result.analysis_run_id)
await loadProjectData(selectedProjectId)
assertExecutionCurrent()
await loadProjectData(projectId)
} catch (error) {
setSegmentationRunError(formatError(error, 'Segmentation run failed'))
if (
!isAbortError(error)
&& segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationRunError(formatError(error, 'De segmentatie is mislukt'))
}
} finally {
setRunningSegmentation(false)
if (activeSegmentationControllerRef.current === controller) {
activeSegmentationControllerRef.current = null
}
if (
segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setRunningSegmentation(false)
}
}
}
@@ -172,33 +348,71 @@ export function useSegmentationWorkflow({
setSegmentationQaError('Kies eerst een referentiebron')
return
}
const projectId = selectedProjectIdRef.current
if (!projectId) {
setSegmentationQaError('Kies eerst een werkruimte')
return
}
const analysisRunId = selectedSegmentationRunId
const referenceDatasetId = segmentationReferenceDatasetId
const sequence = segmentationQaRequestSequence.current + 1
segmentationQaRequestSequence.current = sequence
setSegmentationQaError(null)
setSegmentationQaResult(null)
setRunningSegmentationQa(true)
try {
const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, selectedProjectId!, {
reference_dataset_id: segmentationReferenceDatasetId,
const result = await segmentationApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
})
if (
segmentationQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setSegmentationQaResult(result)
await loadQualityChecks(selectedProjectId)
await loadQualityChecks(projectId)
} catch (error) {
setSegmentationQaError(formatError(error, 'Segmentation QA failed'))
if (
segmentationQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationQaError(formatError(error, 'De segmentatiecontrole is mislukt'))
}
} finally {
setRunningSegmentationQa(false)
if (
segmentationQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningSegmentationQa(false)
}
}
}
const resetSegmentationForProject = () => {
activeSegmentationControllerRef.current?.abort()
activeSegmentationControllerRef.current = null
segmentationExecutionSequence.current += 1
segmentationRunsRequestSequence.current += 1
segmentationResultsRequestSequence.current += 1
segmentationQaRequestSequence.current += 1
setSelectedSegmentationDatasetId('')
setSegmentationRuns([])
setSelectedSegmentationRunId('')
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setSegmentationRunResult(null)
setSegmentationRunError(null)
setSegmentationJob(null)
setRunningSegmentation(false)
setLoadingSegmentationResults(false)
setSegmentationTileManifestPath('')
setSegmentationQaResult(null)
setSegmentationQaError(null)
setRunningSegmentationQa(false)
}
return {
@@ -211,11 +425,14 @@ export function useSegmentationWorkflow({
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationGeoJson,
segmentationClassFilter,
segmentationMinConfidenceFilter,
@@ -69,4 +69,34 @@ describe('useTemporalComparison', () => {
preview_limit: 500,
})
})
it('keeps a newer comparison when an older request finishes last', async () => {
const resolvers: Array<(value: TemporalComparisonResponse) => void> = []
mocks.compare.mockImplementation(() => new Promise<TemporalComparisonResponse>((resolve) => {
resolvers.push(resolve)
}))
const older = { earlier_dataset_id: 'older' } as unknown as TemporalComparisonResponse
const newer = { earlier_dataset_id: 'newer' } as unknown as TemporalComparisonResponse
const { result } = renderHook(() => useTemporalComparison('project-1'))
let olderRequest: Promise<TemporalComparisonResponse | null>
let newerRequest: Promise<TemporalComparisonResponse | null>
await act(async () => {
olderRequest = result.current.compareTemporalSnapshots('older', 'later', bbox)
newerRequest = result.current.compareTemporalSnapshots('newer', 'later', bbox)
await Promise.resolve()
})
await act(async () => {
resolvers[1](newer)
await newerRequest!
})
expect(result.current.temporalComparison).toEqual(newer)
await act(async () => {
resolvers[0](older)
await olderRequest!
})
expect(result.current.temporalComparison).toEqual(newer)
expect(result.current.temporalComparisonLoading).toBe(false)
})
})
+18 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { temporalApi } from '../services/api/temporal'
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
@@ -7,15 +7,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
const [temporalComparison, setTemporalComparison] = useState<TemporalComparisonResponse | null>(null)
const [temporalComparisonLoading, setTemporalComparisonLoading] = useState(false)
const [temporalComparisonError, setTemporalComparisonError] = useState<string | null>(null)
const requestSequence = useRef(0)
useEffect(() => {
requestSequence.current += 1
setTemporalComparison(null)
setTemporalComparisonError(null)
setTemporalComparisonLoading(false)
}, [selectedProjectId])
const clearTemporalComparison = () => {
requestSequence.current += 1
setTemporalComparison(null)
setTemporalComparisonError(null)
setTemporalComparisonLoading(false)
}
const compareTemporalSnapshots = async (
@@ -24,6 +29,8 @@ export function useTemporalComparison(selectedProjectId: string | null) {
bbox: VectorSelectionBBox,
areaId?: string,
): Promise<TemporalComparisonResponse | null> => {
const sequence = requestSequence.current + 1
requestSequence.current = sequence
if (!selectedProjectId) {
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
return null
@@ -43,14 +50,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
area_id: areaId || null,
preview_limit: 500,
})
setTemporalComparison(result)
if (requestSequence.current === sequence) {
setTemporalComparison(result)
}
return result
} catch (error) {
setTemporalComparison(null)
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
if (requestSequence.current === sequence) {
setTemporalComparison(null)
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
}
return null
} finally {
setTemporalComparisonLoading(false)
if (requestSequence.current === sequence) {
setTemporalComparisonLoading(false)
}
}
}
@@ -97,19 +97,25 @@ describe('useWorkbenchBootstrap', () => {
await waitFor(() => expect(systeem.loadCapabilities).toHaveBeenCalledOnce())
})
it('blijft geladen wanneer de gebruiker terugkeert naar de kaart', async () => {
it('herlaadt bezochte werkbladen niet wanneer een ander werkblad opent', async () => {
const state = options('project-1', 'ai')
const { rerender } = renderHook((props: { werkblad: string }) =>
useWorkbenchBootstrap({ ...state, activeWorkspace: props.werkblad }), {
initialProps: { werkblad: 'ai' },
})
await waitFor(() => expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1'))
const naEerste = state.loadDetectionRuns.mock.calls.length
const detectionRunCalls = state.loadDetectionRuns.mock.calls.length
const detectionResultCalls = state.loadDetectionResults.mock.calls.length
rerender({ werkblad: 'map' })
// Een bezocht werkblad blijft bijgewerkt worden; het wordt niet opnieuw
// dichtgezet zodra de gebruiker wegklikt.
expect(state.loadDetectionRuns.mock.calls.length).toBeGreaterThanOrEqual(naEerste)
rerender({ werkblad: 'exports' })
await waitFor(() => expect(state.loadExports).toHaveBeenCalledOnce())
rerender({ werkblad: 'analysis' })
await waitFor(() => expect(state.loadQualityChecks).toHaveBeenCalledOnce())
expect(state.loadDetectionRuns).toHaveBeenCalledTimes(detectionRunCalls)
expect(state.loadDetectionResults).toHaveBeenCalledTimes(detectionResultCalls)
expect(state.loadExports).toHaveBeenCalledOnce()
})
it('meldt een mislukte laadactie in plaats van haar weg te slikken', async () => {
+7 -14
View File
@@ -74,24 +74,17 @@ export function useWorkbenchBootstrap({
return null
}
// Welke werkbladen welke gegevens nodig hebben. Alles werd voorheen bij het
// opstarten opgehaald, ook voor werkbladen die de gebruiker nooit opent; dat
// waren 27 verzoeken in drie golven voordat de kaart bruikbaar was.
const bezocht = useRef(new Set<string>())
bezocht.current.add(activeWorkspace)
const geopend = (werkblad: string): boolean => bezocht.current.has(werkblad)
useEffect(() => {
loadProjects().catch(meld('werkruimtes'))
}, [restrictedMode])
useEffect(() => {
if (!geopend('system')) return
if (activeWorkspace !== 'system') return
loadCapabilities().catch(meld('bronkoppelingen'))
}, [restrictedMode, activeWorkspace])
useEffect(() => {
if (!geopend('ai')) return
if (activeWorkspace !== 'ai') return
loadDetectionModels().catch(meld('detectiemodellen'))
loadSegmentationModels().catch(meld('segmentatiemodellen'))
}, [restrictedMode, activeWorkspace])
@@ -114,28 +107,28 @@ export function useWorkbenchBootstrap({
}, [restrictedMode, selectedProjectId])
useEffect(() => {
if (!selectedProjectId || !geopend('analysis')) return
if (!selectedProjectId || activeWorkspace !== 'analysis') return
loadQualityChecks(selectedProjectId).catch(meld('kwaliteitscontroles'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!selectedProjectId || !geopend('ai')) return
if (!selectedProjectId || activeWorkspace !== 'ai') return
loadDetectionRuns(selectedProjectId).catch(meld('detectieruns'))
loadSegmentationRuns(selectedProjectId).catch(meld('segmentatieruns'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!selectedProjectId || !geopend('exports')) return
if (!selectedProjectId || activeWorkspace !== 'exports') return
loadExports(selectedProjectId).catch(meld('downloads'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!geopend('ai')) return
if (activeWorkspace !== 'ai') return
loadDetectionResults().catch(meld('detectieresultaten'))
}, [restrictedMode, activeWorkspace, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
useEffect(() => {
if (!geopend('ai')) return
if (activeWorkspace !== 'ai') return
loadSegmentationResults().catch(meld('segmentatieresultaten'))
}, [restrictedMode, activeWorkspace, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
}
+1 -1
View File
@@ -10,7 +10,7 @@ import '@fontsource/public-sans/latin-400.css'
import '@fontsource/public-sans/latin-500.css'
import '@fontsource/public-sans/latin-600.css'
import '@fontsource/public-sans/latin-700.css'
import './styles/app.css'
import './styles/base.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
@@ -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')
}
+139
View File
@@ -0,0 +1,139 @@
/*
* Kleine, route-onafhankelijke basis.
*
* De kaartwerkbank importeert zijn omvangrijke app.css zelf via de lazy
* WorkbenchApp-chunk. Houd hier alleen de globale regels die ook het
* aanmeldscherm en de korte laadstatus nodig hebben; MapLibre hoort niet in de
* publieke landing-bundel.
*/
:root {
--bg: #f4f7f5;
--panel: #ffffff;
--panel-soft: #fafcfb;
--surface-raised: #ffffff;
--surface-sunken: #f7faf8;
--text: #132018;
--muted: #5f6f67;
--line: #dbe4de;
--line-strong: #b8c8bf;
--accent: #0f766e;
--accent-strong: #115e59;
--accent-soft: #e3f4ef;
--focus-ring: #0f766e;
--focus-ring-soft: rgba(15, 118, 110, 0.2);
--warning: #b45309;
--danger: #991b1b;
--shadow: 0 10px 26px rgba(33, 48, 41, 0.06);
--shadow-soft: 0 6px 18px rgba(33, 48, 41, 0.045);
/* De landing gebruikt dezelfde vormtaal, maar laadt het volledige
werkbank-designsysteem bewust pas na authenticatie. */
--gi-radius-sm: 6px;
--gi-radius-md: 10px;
--gi-radius-lg: 14px;
--gi-radius-xl: 20px;
--gi-radius-pill: 999px;
--gi-shadow-md: 0 12px 28px rgba(6, 37, 31, 0.1);
--gi-shadow-lg: 0 24px 60px rgba(6, 37, 31, 0.16);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
overflow-x: hidden;
background: var(--bg);
}
body {
overflow-x: hidden;
margin: 0;
font-family: 'Public Sans', 'Segoe UI', Arial, sans-serif;
color: var(--text);
background:
linear-gradient(180deg, rgba(15, 118, 110, 0.08), rgba(238, 244, 241, 0) 18rem),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
min-height: 2.35rem;
border: 1px solid var(--line-strong);
border-radius: var(--gi-radius-sm);
padding: 0.52rem 0.78rem;
background: linear-gradient(180deg, #ffffff, #eef8f6);
color: var(--text);
cursor: pointer;
font-weight: 600;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { cursor: not-allowed; opacity: 0.52; }
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible {
border-color: var(--focus-ring);
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
box-shadow: 0 0 0 5px var(--focus-ring-soft);
}
input,
select,
textarea {
width: 100%;
min-height: 2.35rem;
border: 1px solid var(--line-strong);
border-radius: var(--gi-radius-sm);
padding: 0.52rem 0.62rem;
background: #ffffff;
color: var(--text);
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.16);
}
h1,
h2,
h3,
p { overflow-wrap: anywhere; }
h1 {
max-width: 42rem;
margin: 0;
font-size: clamp(1.85rem, 2.6vw, 2.7rem);
letter-spacing: 0;
line-height: 1.02;
}
h2 { margin: 0 0 0.9rem; font-size: 1.28rem; letter-spacing: 0; line-height: 1.15; }
h3 { margin: 1.1rem 0 0.55rem; font-size: 1rem; letter-spacing: 0; line-height: 1.2; }
p { line-height: 1.45; }
+49
View File
@@ -918,6 +918,24 @@
text-align: center;
}
/* Een mislukte analyse is geen lege toestand: houd de resultatenlade open en
maak de herstelactie bereikbaar zonder hover op de smalle ladegreep. */
.geo-results-error {
display: grid;
gap: var(--gi-space-3);
place-content: center;
justify-items: center;
min-height: 15rem;
padding: var(--gi-space-6) var(--gi-space-4);
border: 1px solid var(--gi-danger-soft);
border-radius: var(--gi-radius-sm);
background: color-mix(in srgb, var(--gi-danger-soft) 26%, var(--gi-surface));
text-align: center;
}
.geo-results-error strong { color: var(--gi-danger); }
.geo-results-error p { max-width: 22rem; margin: 0; color: var(--gi-ink-600); }
/* -- 3. Bedieningspaneel compacter ----------------------------------------- */
/* Het statuslabel stond in een derde kolom en duwde de titel kapot
@@ -2368,6 +2386,7 @@ button.overview-command-card { cursor: pointer; }
@media (max-width: 600px) {
.workbench-layout { display: block; min-height: 100dvh; }
.workbench-topbar { top: 0; }
.workbench-sidebar {
position: fixed; z-index: 120; top: auto; right: 0; bottom: 0; left: 0; width: 100%;
min-height: 4.15rem; max-height: 4.15rem; border-top: 1px solid rgba(153, 218, 202, 0.22);
@@ -2415,6 +2434,17 @@ button.overview-command-card { cursor: pointer; }
.geo-map-actions button { min-width: 0; justify-content: center; padding-inline: 0.45rem; }
.geo-map-actions button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.geo-map-actions button:last-child span { display: none; }
.live-analysis-journey {
top: 4.4rem;
right: 3.25rem;
bottom: auto;
left: 0.5rem;
width: auto;
min-width: 0;
padding: 0.45rem 0.55rem;
}
.live-analysis-status-card { display: none; }
.live-analysis-steps { width: 100%; }
.geo-results-panel {
position: fixed; z-index: 130; top: 0; right: 0; bottom: 4.15rem;
width: min(31rem, calc(100% - 2.75rem)); height: auto; max-height: none;
@@ -2760,6 +2790,25 @@ body:not([data-theme='light']) .workbench-shell :where(input, select, textarea)
}
}
@media (max-width: 600px) {
/* De primaire navigatie staat op smartphones onderaan. De kop toont daarom
alleen merk, werkstand en sessie; de werkcontext staat direct eronder in
het kaartscherm. Dit voorkomt dat logo en afgekorte contextlabels in
dezelfde smalle rastercel over elkaar heen worden getekend. */
.workbench-topbar {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.workbench-topbar .context-bar,
.workbench-topbar .context-health {
display: none;
}
.workbench-topbar .mobile-brand {
display: flex;
}
}
/* ============================================================================
AI-vragen: raster zonder botsingen
----------------------------------------------------------------------------
+1
View File
@@ -1321,6 +1321,7 @@ export interface ModelAssetListResponse {
export interface YoloPreflightChecks {
enabled: boolean
dependencies_available?: boolean | null
accelerator_ready?: boolean | null
model_path_set?: boolean | null
model_file_exists?: boolean | null
model_load_requested: boolean