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