fix(ai): bind model scope to immutable geometry
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-08-09 10:54:52 +02:00
parent f41392a415
commit b76cd1837b
19 changed files with 431 additions and 58 deletions
@@ -25,9 +25,9 @@ def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -
assert "Lokaal modelbestand" in lab
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
assert "Gevalideerde YOLO-profielen" in lab
assert "Historische YOLO-controleprofielen" in lab
assert "DETECTION_OPERATOR_PROFILES" in lab
assert "kandidaat, extra controle vereist" in lab
assert "historisch, geen releasebewijs" in lab
assert "will_download_models" in lab
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promoted_profile() -> None:
def test_detection_operator_profiles_define_explicit_historical_yolo_controls_without_promotion_claim() -> None:
profiles = ROOT / "frontend" / "src" / "components" / "detection" / "detectionProfiles.ts"
source = profiles.read_text(encoding="utf-8")
@@ -17,16 +17,17 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
assert "conservative-review" in source
assert "confidenceThreshold: 0.15" in source
assert "confidenceThreshold: 0.35" in source
assert "defaultApproved: true" in source
assert "promotionRecommendation: 'promote_candidate'" in source
assert "defaultApproved" not in source
assert "promotionRecommendation" not in source
assert "independentTestProven: false" in source
assert "positiveSampleCount: 7" in source
assert "precision: 0.6140895327792112" in source
assert "recall: 0.6062221049337548" in source
assert "f1: 0.6068607646002744" in source
assert "f1: 0.5432865390636915" in source
assert "maxBackgroundDetections: 0" in source
assert "lege-achtergrondtest is geslaagd" in source
assert "Postel blijft met 47,5% F1" in source
assert "Slechts drie pure-achtergrondbeelden" in source
assert "ruimtelijke onafhankelijkheid niet bewezen" in source
assert "controlekandidaat en niet als grondwaarheid" in source
@@ -39,11 +40,11 @@ def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> Non
)
assert "DETECTION_OPERATOR_PROFILES" in lab
assert "Gevalideerde YOLO-profielen" in lab
assert "Historische YOLO-controleprofielen" in lab
assert "profile.displayName" in lab
assert "profile.confidenceThreshold" in lab
assert "kandidaat, extra controle vereist" in lab
assert "standaardprofiel" in lab
assert "historisch, geen releasebewijs" in lab
assert "historische F1" in lab
assert "Profiel gebruiken" in lab
assert "onApplyOperatorProfile(profile)" in lab
assert "Recommended starting threshold: 0.25" not in lab
@@ -72,7 +72,7 @@ def test_visible_ai_and_quality_labels_are_end_user_facing() -> None:
providers = read("frontend/src/components/providers/ProviderPanel.tsx")
assert "Aanbevolen controleprofiel kleine gebouwen" in profiles
assert "Postel blijft met 47,5% F1" in profiles
assert "Slechts drie pure-achtergrondbeelden" in profiles
assert "controlekandidaat en niet als grondwaarheid" in profiles
assert "qualityStatusLabel" in quality
assert "nog niet uitgevoerd" in quality
+57 -4
View File
@@ -8,6 +8,8 @@ from types import SimpleNamespace
from uuid import uuid4
import pytest
from geoalchemy2.shape import from_shape
from shapely.geometry import box, mapping
from app.core.config import Settings
from app.core.errors import AppError
@@ -15,6 +17,7 @@ from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, Sour
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
from app.services.detection_service import DetectionService
from app.services.model_registry_service import ModelRegistryService
from app.services.model_validation_scope_service import ModelValidationScopeService
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
from app.services.yolo_adapter import YoloDetectionAdapter
@@ -207,6 +210,28 @@ def _settings(tmp_path: Path, **overrides) -> Settings:
return Settings(**values)
def _scope_settings(tmp_path: Path, scope_geometry=None, **overrides) -> Settings:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"scope-bound-model")
payload = {
"schema_version": ModelValidationScopeService.SCHEMA_VERSION,
"model_id": "yolo-configured",
"model_sha256": sha256(model_path.read_bytes()).hexdigest(),
"scope_key": "mol-kempen-test",
"crs": "EPSG:4326",
"geometry": mapping(scope_geometry or box(4.0, 50.8, 5.5, 52.0)),
}
manifest_path = tmp_path / "model-validation-scope.json"
manifest_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
values = {
"yolo_model_path": str(model_path),
"yolo_validation_scope_manifest_path": str(manifest_path),
"yolo_validation_scope_manifest_sha256": sha256(manifest_path.read_bytes()).hexdigest(),
}
values.update(overrides)
return _settings(tmp_path, **values)
def _write_model_sidecar(
model_path: Path,
settings: Settings,
@@ -399,21 +424,49 @@ def test_yolo_runtime_rejects_cpu_device_when_cuda_is_required(tmp_path: Path, m
def test_yolo_validation_scope_requires_persisted_validated_area(tmp_path: Path) -> None:
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
wrong_area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Brussels", geometry="MULTIPOLYGON EMPTY")
wrong_area = Area(
id=dataset.area_id,
project_id=dataset.project_id,
name="Mol validation bypass",
geometry=from_shape(box(-74.1, 40.6, -73.8, 40.9), srid=4326),
)
db = FakeSession(objects={(Area, dataset.area_id): wrong_area})
with pytest.raises(AppError) as exc_info:
DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_UNAVAILABLE"
def test_yolo_validation_scope_accepts_bound_mol_area(tmp_path: Path) -> None:
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Gemeente Mol", geometry="MULTIPOLYGON EMPTY")
area = Area(
id=dataset.area_id,
project_id=dataset.project_id,
name="Een wijzigbare weergavenaam",
geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
)
db = FakeSession(objects={(Area, dataset.area_id): area})
DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
def test_yolo_validation_scope_rejects_tampered_manifest(tmp_path: Path) -> None:
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
area = Area(
id=dataset.area_id,
project_id=dataset.project_id,
name="Gemeente Mol",
geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
)
settings = _scope_settings(tmp_path)
Path(settings.yolo_validation_scope_manifest_path).write_text("{}", encoding="utf-8")
db = FakeSession(objects={(Area, dataset.area_id): area})
with pytest.raises(AppError) as exc_info:
DetectionService._validate_model_area_scope(db, dataset, settings)
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH"
def test_yolo_run_requires_tile_manifest_path(tmp_path: Path) -> None: