Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
# Build context exclusions for deploy/unraid/Dockerfile.all-in-one.
|
||||||
|
# Everything listed here is NOT sent to the Docker daemon.
|
||||||
|
# Keep this aggressive: the all-in-one image only needs
|
||||||
|
# backend/, frontend/, fixtures/, scripts/, deploy/ and VERSION.
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
|
||||||
|
# Node dependencies at every level (root test harness + frontend).
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/*.tsbuildinfo
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
frontend-src.tar.gz
|
||||||
|
|
||||||
|
backend/.pytest_cache
|
||||||
|
backend/**/*.pyc
|
||||||
|
backend/**/__pycache__
|
||||||
|
|
||||||
|
# Accidental self-copy of the repository into itself.
|
||||||
|
# Without this the build context is duplicated and the build stalls.
|
||||||
|
/geointel
|
||||||
|
|
||||||
|
# Local work products, never needed inside the image.
|
||||||
|
.codex-input
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
artifacts
|
||||||
|
RELEASE_NOTES
|
||||||
|
adr
|
||||||
|
docs
|
||||||
|
checklists
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data and secrets: mounted at runtime, never baked in.
|
||||||
|
storage
|
||||||
|
postgres-data
|
||||||
|
backups
|
||||||
|
datasets/raw
|
||||||
|
datasets/processed
|
||||||
|
datasets/cache
|
||||||
|
exports
|
||||||
|
models
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
# Backend
|
||||||
|
GEOINTEL_ENV=development
|
||||||
|
GEOINTEL_API_PREFIX=/api/v1
|
||||||
|
DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1
|
||||||
|
STORAGE_ROOT=./storage
|
||||||
|
MAX_UPLOAD_MB=500
|
||||||
|
GEOINTEL_MAX_IN_MEMORY_VECTOR_MB=64
|
||||||
|
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
|
||||||
|
|
||||||
|
# Optional single-operator access gate. Store only a PBKDF2-SHA256 hash and
|
||||||
|
# a unique 32+ character signing secret. Guest access is opt-in and should be
|
||||||
|
# enabled only on a dedicated demo installation with bounded public data.
|
||||||
|
GEOINTEL_AUTH_ENABLED=false
|
||||||
|
GEOINTEL_AUTH_REQUIRE_HTTPS=false
|
||||||
|
GEOINTEL_AUTH_USERNAME=
|
||||||
|
GEOINTEL_AUTH_PASSWORD_HASH=
|
||||||
|
GEOINTEL_AUTH_SESSION_SECRET=
|
||||||
|
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
|
||||||
|
GEOINTEL_PUBLIC_BASE_URL=http://localhost:1202
|
||||||
|
GEOINTEL_AUTHENTIK_ISSUER=
|
||||||
|
GEOINTEL_AUTHENTIK_CLIENT_ID=
|
||||||
|
GEOINTEL_AUTHENTIK_CLIENT_SECRET=
|
||||||
|
GEOINTEL_AUTHENTIK_ALLOWED_EMAIL=
|
||||||
|
GEOINTEL_GUEST_ACCESS_ENABLED=false
|
||||||
|
GEOINTEL_GUEST_DISPLAY_NAME=Gast
|
||||||
|
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
|
||||||
|
ORTHOPHOTO_ENABLED=true
|
||||||
|
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
|
||||||
|
SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer
|
||||||
|
BRUSSELS_ORTHOPHOTO_WMS_URL=https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows
|
||||||
|
ORTHOPHOTO_WMS_LAYER=Ortho
|
||||||
|
ORTHOPHOTO_RESOLUTION_M=1.0
|
||||||
|
ORTHOPHOTO_MIN_SIDE_M=128
|
||||||
|
ORTHOPHOTO_MAX_SIDE_M=1024
|
||||||
|
ORTHOPHOTO_CACHE_TTL_HOURS=24
|
||||||
|
SOURCE_CATALOG_PROBE_ENABLED=true
|
||||||
|
SOURCE_CATALOG_GRB_WFS_URL=https://geo.api.vlaanderen.be/GRB/wfs
|
||||||
|
GRB_ENABLED=true
|
||||||
|
GRB_OGC_API_URL=https://geo.api.vlaanderen.be/GRB/ogc/features/v1
|
||||||
|
GRB_MIN_SIDE_M=10
|
||||||
|
GRB_MAX_SIDE_M=20000
|
||||||
|
GRB_PAGE_SIZE=1000
|
||||||
|
GRB_MAX_PAGES=200
|
||||||
|
GRB_MAX_FEATURES=150000
|
||||||
|
GRB_TIMEOUT_SECONDS=180
|
||||||
|
GRB_MAX_RESPONSE_MB=20
|
||||||
|
GRB_MAX_TOTAL_RESPONSE_MB=256
|
||||||
|
GRB_CACHE_TTL_HOURS=24
|
||||||
|
OFFICIAL_VECTOR_ENABLED=true
|
||||||
|
BWK_WFS_URL=https://geo.api.vlaanderen.be/BWK/wfs
|
||||||
|
DOV_SOIL_WFS_URL=https://www.dov.vlaanderen.be/geoserver/wfs
|
||||||
|
SPW_PICC_ENABLED=true
|
||||||
|
SPW_PICC_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/TOPOGRAPHIE/PICC_VDIFF/MapServer
|
||||||
|
SPW_FLOOD_HAZARD_ENABLED=true
|
||||||
|
SPW_FLOOD_HAZARD_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/EAU/ALEA_INOND/MapServer
|
||||||
|
URBIS_ENABLED=true
|
||||||
|
URBIS_WFS_URL=https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows
|
||||||
|
OFFICIAL_VECTOR_MIN_SIDE_M=10
|
||||||
|
OFFICIAL_VECTOR_MAX_SIDE_M=20000
|
||||||
|
OFFICIAL_VECTOR_PAGE_SIZE=1000
|
||||||
|
OFFICIAL_VECTOR_MAX_PAGES=200
|
||||||
|
OFFICIAL_VECTOR_MAX_FEATURES=100000
|
||||||
|
OFFICIAL_VECTOR_TIMEOUT_SECONDS=180
|
||||||
|
OFFICIAL_VECTOR_MAX_RESPONSE_MB=20
|
||||||
|
OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB=256
|
||||||
|
OFFICIAL_VECTOR_CACHE_TTL_HOURS=24
|
||||||
|
SOURCE_CATALOG_STATBEL_DCAT_URL=https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl
|
||||||
|
SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5
|
||||||
|
SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen
|
||||||
|
SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10
|
||||||
|
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2
|
||||||
|
SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900
|
||||||
|
DHMV_ENABLED=true
|
||||||
|
DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs
|
||||||
|
DHMV_RESOLUTION_M=5.0
|
||||||
|
DHMV_MIN_SIDE_M=10
|
||||||
|
DHMV_MAX_SIDE_M=20000
|
||||||
|
DHMV_MAX_PIXELS=12000000
|
||||||
|
DHMV_TIMEOUT_SECONDS=300
|
||||||
|
DHMV_MAX_RESPONSE_MB=160
|
||||||
|
FLOOD_HAZARD_ENABLED=true
|
||||||
|
FLOOD_HAZARD_WCS_URL=https://geoservice.waterinfo.be/OGRK/wcs
|
||||||
|
FLOOD_HAZARD_RESOLUTION_M=5.0
|
||||||
|
FLOOD_HAZARD_MIN_SIDE_M=10
|
||||||
|
FLOOD_HAZARD_MAX_SIDE_M=20000
|
||||||
|
FLOOD_HAZARD_MAX_PIXELS=12000000
|
||||||
|
FLOOD_HAZARD_TIMEOUT_SECONDS=300
|
||||||
|
FLOOD_HAZARD_MAX_RESPONSE_MB=160
|
||||||
|
BATHYMETRY_PROFILES_ENABLED=true
|
||||||
|
BATHYMETRY_PROFILES_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0
|
||||||
|
BATHYMETRY_WATERCOURSE_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1
|
||||||
|
BATHYMETRY_PROFILES_PAGE_SIZE=1000
|
||||||
|
BATHYMETRY_PROFILES_MAX_PAGES=200
|
||||||
|
BATHYMETRY_PROFILES_MAX_FEATURES=50000
|
||||||
|
BATHYMETRY_PROFILES_TIMEOUT_SECONDS=120
|
||||||
|
BATHYMETRY_PROFILES_MAX_RESPONSE_MB=32
|
||||||
|
MDK_BATHYMETRY_PROBE_ENABLED=true
|
||||||
|
MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs
|
||||||
|
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
|
||||||
|
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
|
||||||
|
# Bounded MDK acquisition stays fail-closed until the readiness probe reports
|
||||||
|
# "reachable" and an advertised coverage id is configured explicitly.
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED=false
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID=
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS=EPSG:4326
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2=0.25
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS=120
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB=160
|
||||||
|
THEMATIC_RASTER_ENABLED=true
|
||||||
|
THEMATIC_RASTER_WCS_URL=https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs
|
||||||
|
THEMATIC_RASTER_MIN_SIDE_M=100
|
||||||
|
THEMATIC_RASTER_MAX_SIDE_M=60000
|
||||||
|
THEMATIC_RASTER_MAX_PIXELS=30000000
|
||||||
|
THEMATIC_RASTER_TIMEOUT_SECONDS=300
|
||||||
|
THEMATIC_RASTER_MAX_RESPONSE_MB=160
|
||||||
|
WALOUS_ENABLED=true
|
||||||
|
WALOUS_SOURCE_DIR=/app/storage/source-cache/walous
|
||||||
|
WALOUS_ANALYSIS_RESOLUTION_M=10
|
||||||
|
WALOUS_MAX_SIDE_M=60000
|
||||||
|
WALOUS_MAX_PIXELS=36000000
|
||||||
|
YOLO_ENABLED=false
|
||||||
|
YOLO_MODELS_DIR=/app/models
|
||||||
|
YOLO_MODEL_PATH=
|
||||||
|
YOLO_MODEL_ID=yolo-configured
|
||||||
|
YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
|
||||||
|
YOLO_MODEL_VERSION=
|
||||||
|
YOLO_MODEL_CLASSES=building
|
||||||
|
YOLO_ENFORCE_VALIDATION_SCOPE=false
|
||||||
|
# Required when scope enforcement is enabled. The manifest is bound to exact
|
||||||
|
# model bytes and contains the allowed EPSG:4326 validation geometry.
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
|
||||||
|
# Deprecated display metadata; never used as an inference authorization gate.
|
||||||
|
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
|
||||||
|
YOLO_CONFIG_DIR=./storage/ultralytics
|
||||||
|
YOLO_DEVICE=cpu
|
||||||
|
YOLO_REQUIRE_CUDA=false
|
||||||
|
YOLO_IMAGE_SIZE=640
|
||||||
|
YOLO_MAX_TILES=100
|
||||||
|
YOLO_MAX_DETECTIONS=1000
|
||||||
|
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
|
# Tiles per model.predict call. 1 leaves most of a modern GPU idle on a run of
|
||||||
|
# a hundred tiles; raise it until VRAM is the limit.
|
||||||
|
YOLO_BATCH_SIZE=1
|
||||||
|
# Drop boxes truncated by an interior tile edge. Because tiles overlap, the
|
||||||
|
# neighbouring tile saw the same object whole, so the truncated half is a
|
||||||
|
# duplicate and a shape error at once. Boxes on the outer raster edge are kept.
|
||||||
|
YOLO_SUPPRESS_TILE_EDGE_DETECTIONS=true
|
||||||
|
# Intersection over the smaller box. scripts/evaluate_belgium_building_candidate.py
|
||||||
|
# freezes this during calibration (--containment-nms) before the protected test.
|
||||||
|
# Serving a promoted model at a different value means the runtime suppresses
|
||||||
|
# detections its gate counted, so set this to the value the candidate was gated at.
|
||||||
|
YOLO_CONTAINMENT_NMS_THRESHOLD=0.85
|
||||||
|
# Segmentation carries its own value: masks and boxes overlap differently,
|
||||||
|
# so one threshold need not fit both.
|
||||||
|
SEGMENTATION_CONTAINMENT_NMS_THRESHOLD=0.85
|
||||||
|
|
||||||
|
# Local segmentation models. GeoIntel never downloads model weights
|
||||||
|
# automatically; point these to existing local files to enable inference.
|
||||||
|
YOLO_SEG_ENABLED=false
|
||||||
|
YOLO_SEG_MODEL_PATH=
|
||||||
|
YOLO_SEG_MODEL_ID=yolo-seg-configured
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME=Configured YOLO segmentation
|
||||||
|
YOLO_SEG_MODEL_VERSION=
|
||||||
|
SAM_ENABLED=false
|
||||||
|
SAM_MODEL_PATH=
|
||||||
|
SAM_MODEL_ID=sam-configured
|
||||||
|
SAM_MODEL_DISPLAY_NAME=Configured SAM segmentation
|
||||||
|
SAM_MODEL_VERSION=
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE=300
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
|
ENABLE_GRB_WFS=false
|
||||||
|
GRB_WFS_URL=
|
||||||
|
OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter
|
||||||
|
|
||||||
|
# Install backend raster dependencies when needed:
|
||||||
|
# python -m pip install rasterio
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||||
|
# Leave empty to use the local/demo OpenStreetMap fallback with visible attribution.
|
||||||
|
# Set this to a managed MapLibre style URL for production or heavier tile traffic.
|
||||||
|
VITE_MAP_STYLE_URL=
|
||||||
|
|
||||||
|
# Docker Compose / Unraid
|
||||||
|
GEOINTEL_FRONTEND_PORT=1202
|
||||||
|
GEOINTEL_BACKEND_PORT=8000
|
||||||
|
GEOINTEL_INSTALL_AI=false
|
||||||
|
GEOINTEL_STORAGE_PATH=./storage
|
||||||
|
GEOINTEL_BACKUPS_PATH=./backups
|
||||||
|
GEOINTEL_MODELS_PATH=./models
|
||||||
|
GEOINTEL_POSTGIS_DATA_PATH=./postgres-data
|
||||||
|
GEOINTEL_POSTGRES_DB=geointel
|
||||||
|
GEOINTEL_POSTGRES_USER=geointel
|
||||||
|
GEOINTEL_POSTGRES_PASSWORD=geointel
|
||||||
|
GEOINTEL_CORS_ORIGINS=https://geointel.example.com,http://localhost:1202,http://127.0.0.1:1202
|
||||||
|
GEOINTEL_MAX_UPLOAD_MB=500
|
||||||
|
GEOINTEL_AOI_WORKER_ENABLED=false
|
||||||
|
GEOINTEL_AOI_WORKER_POLL_SECONDS=2
|
||||||
|
# Executes queued detection.run / segmentation.run jobs from POST
|
||||||
|
# /detection/run-async, so tiled GPU inference never blocks an HTTP request.
|
||||||
|
GEOINTEL_ANALYSIS_WORKER_ENABLED=false
|
||||||
|
GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS=2
|
||||||
|
# Analysis consumes only artifacts under STORAGE_ROOT: a tile manifest path
|
||||||
|
# arrives in the request and a manifest entry may name an absolute tile
|
||||||
|
# path, so without this an API field is an unbounded filesystem reference.
|
||||||
|
# Provisioning workflows that stage tiles elsewhere before ingest can opt out.
|
||||||
|
GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS=false
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
deploy/unraid/gosu-setpriv text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.ini text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
|
||||||
|
# Retained audit exports preserve source bytes, including line endings and
|
||||||
|
# tool-emitted whitespace.
|
||||||
|
artifacts/evidence/accuracy/** -text -whitespace
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Managed validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
profile:
|
||||||
|
description: Allowlisted validation profile
|
||||||
|
required: true
|
||||||
|
default: full
|
||||||
|
type: choice
|
||||||
|
options: [test, lint, typecheck, build, security, full]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
full:
|
||||||
|
# Gitea Actions does not consistently evaluate the GitHub-style `||`
|
||||||
|
# expression for pull-request runs without workflow inputs.
|
||||||
|
name: Managed repository validation
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements-ci.lock
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Validate the requested profile against the real projects
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
profile="${REQUESTED_PROFILE:-full}"
|
||||||
|
case "${profile}" in
|
||||||
|
test|lint|typecheck|build|security|full) ;;
|
||||||
|
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||||
|
echo "Unresolved merge markers detected" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python scripts/verify_repository_layout.py
|
||||||
|
|
||||||
|
python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-ci.lock
|
||||||
|
python -m pip install --disable-pip-version-check --no-deps -e backend
|
||||||
|
(cd frontend && npm ci)
|
||||||
|
|
||||||
|
case "${profile}" in
|
||||||
|
test)
|
||||||
|
(cd backend && python -m pytest -W error::DeprecationWarning)
|
||||||
|
(cd frontend && npm run test:unit)
|
||||||
|
;;
|
||||||
|
lint)
|
||||||
|
python -m ruff check backend scripts tests
|
||||||
|
(cd frontend && npm run lint --if-present)
|
||||||
|
;;
|
||||||
|
typecheck)
|
||||||
|
(cd frontend && npm run typecheck)
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
python -m compileall backend/app
|
||||||
|
(cd frontend && npm run build)
|
||||||
|
;;
|
||||||
|
security)
|
||||||
|
python -m pip install --disable-pip-version-check pip-audit==2.10.1
|
||||||
|
bash scripts/audit_python_dependencies.sh
|
||||||
|
(cd frontend && npm audit --audit-level=high)
|
||||||
|
;;
|
||||||
|
full)
|
||||||
|
PYTHON_BIN=python bash scripts/run_readiness_check.sh
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
name: GeoIntel release gates
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: geointel-release-${{ gitea.ref }}
|
||||||
|
# A cancelled HTTP caller does not terminate the allowlisted controller
|
||||||
|
# process that already owns the production lock. Queue a newer revision
|
||||||
|
# instead of orphaning an in-flight backup or deploy.
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
name: Compile, test, contracts and builds
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Secret scan
|
||||||
|
run: >-
|
||||||
|
docker run --rm
|
||||||
|
--volume "$PWD:/repo:ro"
|
||||||
|
trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3
|
||||||
|
filesystem /repo --only-verified --no-update
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements-ci.lock
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Install locked backend dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-ci.lock
|
||||||
|
python -m pip install --disable-pip-version-check --no-deps -e backend
|
||||||
|
- name: Install locked frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
- name: Verify dependency lock policy
|
||||||
|
run: python scripts/verify_python_lock.py
|
||||||
|
- name: Run complete release readiness gate
|
||||||
|
env:
|
||||||
|
PYTHON_BIN: python
|
||||||
|
run: bash scripts/run_readiness_check.sh
|
||||||
|
- name: Render migration and Compose evidence
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cd backend
|
||||||
|
python -m alembic upgrade head --sql > ../artifacts/alembic-upgrade.sql
|
||||||
|
cd ..
|
||||||
|
docker compose config > artifacts/docker-compose.resolved.yml
|
||||||
|
- name: Publish quality evidence
|
||||||
|
if: always()
|
||||||
|
# Gitea Actions currently exposes the GHES-compatible artifact API;
|
||||||
|
# upload-artifact v4 deliberately refuses that API.
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: quality-evidence
|
||||||
|
path: |
|
||||||
|
artifacts/alembic-upgrade.sql
|
||||||
|
artifacts/docker-compose.resolved.yml
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
dependency-audit:
|
||||||
|
name: Python and npm vulnerability policy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements-ci.lock
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Audit locked Python dependencies
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
python -m pip install --disable-pip-version-check pip-audit==2.10.1
|
||||||
|
bash scripts/audit_python_dependencies.sh
|
||||||
|
- name: Audit locked frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm audit --audit-level=high --json > ../artifacts/npm-audit.json
|
||||||
|
- name: Publish dependency evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: dependency-audits
|
||||||
|
path: |
|
||||||
|
artifacts/pip-audit-full.json
|
||||||
|
artifacts/pip-audit-policy.json
|
||||||
|
artifacts/npm-audit.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
container:
|
||||||
|
name: Production AI image, SBOM and container scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Build production AI release image
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
APP_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||||
|
docker build \
|
||||||
|
-f deploy/unraid/Dockerfile.all-in-one \
|
||||||
|
--build-arg GEOINTEL_INSTALL_AI=true \
|
||||||
|
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
|
||||||
|
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
|
||||||
|
--build-arg GEOINTEL_APP_VERSION="$APP_VERSION" \
|
||||||
|
-t "geointel-ci:$RELEASE_SHA-ai" \
|
||||||
|
.
|
||||||
|
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")"
|
||||||
|
printf '%s\n' "$IMAGE_ID" > artifacts/image-id.txt
|
||||||
|
docker image inspect "$IMAGE_ID" > artifacts/image-inspect.json
|
||||||
|
- name: Generate SPDX SBOM
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ gitea.sha }}
|
||||||
|
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||||
|
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||||
|
SYFT_PARALLELISM: "1"
|
||||||
|
run: |
|
||||||
|
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||||
|
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||||
|
bash scripts/generate_container_sbom.sh "$IMAGE_ID"
|
||||||
|
- name: Enforce container vulnerability policy
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ gitea.sha }}
|
||||||
|
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||||
|
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||||
|
run: |
|
||||||
|
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||||
|
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||||
|
bash scripts/scan_container_image.sh "$IMAGE_ID"
|
||||||
|
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||||
|
- name: Remove temporary image archive
|
||||||
|
if: always()
|
||||||
|
run: >-
|
||||||
|
rm -f -- artifacts/geointel-image.tar
|
||||||
|
artifacts/geointel-image.tar.image-id
|
||||||
|
artifacts/geointel-image.tar.partial.*
|
||||||
|
- name: Publish container evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: container-evidence
|
||||||
|
path: |
|
||||||
|
artifacts/image-inspect.json
|
||||||
|
artifacts/image-id.txt
|
||||||
|
artifacts/geointel-sbom.spdx.json
|
||||||
|
artifacts/geointel-container-vulnerabilities.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy exact gated revision to Unraid
|
||||||
|
needs: [quality, dependency-audit, container]
|
||||||
|
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
|
||||||
|
runs-on: unraid-deploy
|
||||||
|
# The first byte-complete storage snapshot can exceed 100 GiB. Keep the
|
||||||
|
# gated caller attached for the full conservative backup/build window;
|
||||||
|
# the controller and deploy script still serialize every mutation.
|
||||||
|
timeout-minutes: 720
|
||||||
|
steps:
|
||||||
|
- name: Deploy only after every release gate is green
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker exec gitea-deploy-control \
|
||||||
|
/opt/gitea-deploy/deploy.py deploy \
|
||||||
|
"${{ gitea.repository }}" "${{ gitea.sha }}"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report a reproducible defect
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
## Actual behavior
|
||||||
|
|
||||||
|
## Affected module
|
||||||
|
|
||||||
|
- [ ] Backend
|
||||||
|
- [ ] Frontend
|
||||||
|
- [ ] Database
|
||||||
|
- [ ] Raster
|
||||||
|
- [ ] Vector
|
||||||
|
- [ ] AI/Detection
|
||||||
|
- [ ] QA/QC
|
||||||
|
- [ ] Export
|
||||||
|
- [ ] Docs
|
||||||
|
|
||||||
|
## Logs/screenshots
|
||||||
|
|
||||||
|
## Data involved
|
||||||
|
|
||||||
|
- Dataset:
|
||||||
|
- CRS:
|
||||||
|
- Geometry type:
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
- [ ] Blocks build
|
||||||
|
- [ ] Data correctness issue
|
||||||
|
- [ ] UX issue
|
||||||
|
- [ ] Documentation issue
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Propose an improvement without breaking scope
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
## Proposed solution
|
||||||
|
|
||||||
|
## Scope category
|
||||||
|
|
||||||
|
- [ ] V1 in scope
|
||||||
|
- [ ] V1 adjacent
|
||||||
|
- [ ] V2+
|
||||||
|
- [ ] RFC required
|
||||||
|
|
||||||
|
## Affected modules
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
## Notes
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Summary
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Meets pass prompt
|
||||||
|
- [ ] Meets M6 quality gates
|
||||||
|
- [ ] Tests run
|
||||||
|
- [ ] Docs updated
|
||||||
|
- [ ] No architecture drift
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# commands
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
## Next pass recommendation
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: GeoIntel release gates
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop, "codex/**", "build/**"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: geointel-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
name: Compile, test, contracts and builds
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Secret scan
|
||||||
|
run: >-
|
||||||
|
docker run --rm
|
||||||
|
--volume "$PWD:/repo:ro"
|
||||||
|
trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3
|
||||||
|
filesystem /repo --only-verified --no-update
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements-ci.lock
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Install locked backend dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-ci.lock
|
||||||
|
python -m pip install --disable-pip-version-check --no-deps -e backend
|
||||||
|
- name: Install locked frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
- name: Verify dependency lock policy
|
||||||
|
run: python scripts/verify_python_lock.py
|
||||||
|
- name: Run complete release readiness gate
|
||||||
|
env:
|
||||||
|
PYTHON_BIN: python
|
||||||
|
run: bash scripts/run_readiness_check.sh
|
||||||
|
- name: Render migration and Compose evidence
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cd backend
|
||||||
|
python -m alembic upgrade head --sql > ../artifacts/alembic-upgrade.sql
|
||||||
|
cd ..
|
||||||
|
docker compose config > artifacts/docker-compose.resolved.yml
|
||||||
|
- name: Publish quality evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: quality-evidence
|
||||||
|
path: |
|
||||||
|
artifacts/alembic-upgrade.sql
|
||||||
|
artifacts/docker-compose.resolved.yml
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
dependency-audit:
|
||||||
|
name: Python and npm vulnerability policy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements-ci.lock
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Audit locked Python dependencies
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
python -m pip install --disable-pip-version-check pip-audit==2.10.1
|
||||||
|
bash scripts/audit_python_dependencies.sh
|
||||||
|
- name: Audit locked frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm audit --audit-level=high --json > ../artifacts/npm-audit.json
|
||||||
|
- name: Publish dependency evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: dependency-audits
|
||||||
|
path: |
|
||||||
|
artifacts/pip-audit-full.json
|
||||||
|
artifacts/pip-audit-policy.json
|
||||||
|
artifacts/npm-audit.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
container:
|
||||||
|
name: Production AI image, SBOM and container scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Build production AI release image
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
APP_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||||
|
docker build \
|
||||||
|
-f deploy/unraid/Dockerfile.all-in-one \
|
||||||
|
--build-arg GEOINTEL_INSTALL_AI=true \
|
||||||
|
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
|
||||||
|
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
|
||||||
|
--build-arg GEOINTEL_APP_VERSION="$APP_VERSION" \
|
||||||
|
-t "geointel-ci:$RELEASE_SHA-ai" \
|
||||||
|
.
|
||||||
|
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")"
|
||||||
|
printf '%s\n' "$IMAGE_ID" > artifacts/image-id.txt
|
||||||
|
docker image inspect "$IMAGE_ID" > artifacts/image-inspect.json
|
||||||
|
- name: Generate SPDX SBOM
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ github.sha }}
|
||||||
|
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||||
|
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||||
|
SYFT_PARALLELISM: "1"
|
||||||
|
run: |
|
||||||
|
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||||
|
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||||
|
bash scripts/generate_container_sbom.sh "$IMAGE_ID"
|
||||||
|
- name: Enforce container vulnerability policy
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ github.sha }}
|
||||||
|
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||||
|
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||||
|
run: |
|
||||||
|
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||||
|
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||||
|
bash scripts/scan_container_image.sh "$IMAGE_ID"
|
||||||
|
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||||
|
- name: Remove temporary image archive
|
||||||
|
if: always()
|
||||||
|
run: >-
|
||||||
|
rm -f -- artifacts/geointel-image.tar
|
||||||
|
artifacts/geointel-image.tar.image-id
|
||||||
|
artifacts/geointel-image.tar.partial.*
|
||||||
|
- name: Publish container evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: container-evidence
|
||||||
|
path: |
|
||||||
|
artifacts/image-inspect.json
|
||||||
|
artifacts/image-id.txt
|
||||||
|
artifacts/geointel-sbom.spdx.json
|
||||||
|
artifacts/geointel-container-vulnerabilities.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local source-transfer archives; preserve them outside version control.
|
||||||
|
/frontend-src.tar.gz
|
||||||
|
|
||||||
|
# Generated evidence, model output and operational snapshots stay outside Git.
|
||||||
|
/artifacts/
|
||||||
|
/.cache/
|
||||||
|
/datasets/raw/*
|
||||||
|
/datasets/processed/*
|
||||||
|
/datasets/cache/*
|
||||||
|
/storage/uploads/*
|
||||||
|
/storage/tiles/*
|
||||||
|
/storage/masks/*
|
||||||
|
/storage/reports/*
|
||||||
|
/storage/exports/*
|
||||||
|
/storage/rasters/*
|
||||||
|
/storage/models/*
|
||||||
|
/storage/operator-data/*
|
||||||
|
/storage/operator-evidence/*
|
||||||
|
/storage/release-evidence/*
|
||||||
|
/storage/previews/*
|
||||||
|
/storage/training/*
|
||||||
|
/storage/ultralytics/*
|
||||||
|
/exports/*
|
||||||
|
/models/*
|
||||||
|
/backend/storage/uploads/*
|
||||||
|
/backend/storage/tiles/*
|
||||||
|
/backend/storage/masks/*
|
||||||
|
/backend/storage/reports/*
|
||||||
|
/backend/storage/exports/*
|
||||||
|
/backups/*
|
||||||
|
/postgres-data/*
|
||||||
|
|
||||||
|
# Keep folder placeholders
|
||||||
|
!**/.gitkeep
|
||||||
|
!**/README.md
|
||||||
|
|
||||||
|
# Runtime-generated operator documentation is not repository documentation.
|
||||||
|
/storage/operator-data/README.md
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Local investigation scratch is never repository input.
|
||||||
|
/.codex-input/
|
||||||
|
/.codex-artifacts/
|
||||||
|
/.playwright-mcp/
|
||||||
|
/.mcp.json
|
||||||
|
/data/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
[[allowlists]]
|
||||||
|
description = "Public registry identifiers that resemble generic API keys"
|
||||||
|
regexTarget = "match"
|
||||||
|
regexes = [
|
||||||
|
'''key="population_density_2019"''',
|
||||||
|
'''source_key="inbo_bwk_natura2000"''',
|
||||||
|
'''product_key": "population_density_2019"''',
|
||||||
|
'''key="spw_flood_hazard_2021"''',
|
||||||
|
'''key="phase2-mutation"''',
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# AI Agent Instructions for GeoIntel
|
||||||
|
|
||||||
|
## Project identity
|
||||||
|
|
||||||
|
GeoIntel is a GeoAI Workbench for Belgium and the Belgian North Sea, not a
|
||||||
|
generic CRUD app and not a generic dashboard. Mol and the Kempen remain golden
|
||||||
|
regression areas, not the product boundary.
|
||||||
|
|
||||||
|
## Required behavior
|
||||||
|
|
||||||
|
- Read `docs/CODEX_BOOTSTRAP_PROMPT.md` first.
|
||||||
|
- Respect `docs/RC_SCOPE_FREEZE_BELGIUM_NORTH_SEA.md`.
|
||||||
|
- Use `docs/API_CONTRACTS.md` as source of truth for endpoints.
|
||||||
|
- Use `docs/DATABASE_IMPLEMENTATION_PLAN.md` as source of truth for persistence.
|
||||||
|
- Use `docs/DEFINITION_OF_DONE.md` to decide whether work is complete.
|
||||||
|
|
||||||
|
## Agent roles
|
||||||
|
|
||||||
|
### Architecture Agent
|
||||||
|
|
||||||
|
Owns repository layout, API contracts, database migrations and service boundaries.
|
||||||
|
|
||||||
|
### GIS Agent
|
||||||
|
|
||||||
|
Owns GeoPandas, Shapely, Rasterio, CRS, clipping, buffering, spatial joins and metadata extraction.
|
||||||
|
|
||||||
|
### AI Agent
|
||||||
|
|
||||||
|
Owns YOLO/SAM abstractions, inference contracts, model configuration, detection/segmentation persistence and `not_configured` behavior.
|
||||||
|
|
||||||
|
### QA Agent
|
||||||
|
|
||||||
|
Owns tests, QA/QC metrics, regression checks and acceptance criteria.
|
||||||
|
|
||||||
|
### Frontend Agent
|
||||||
|
|
||||||
|
Owns React, TypeScript, MapLibre, API client, UI states and workbench UX.
|
||||||
|
|
||||||
|
## Never do this
|
||||||
|
|
||||||
|
- Do not fake production AI outputs.
|
||||||
|
- Do not silently skip geospatial validation.
|
||||||
|
- Do not add auth/multi-user/LiDAR/training before V1 foundation is stable.
|
||||||
|
- Do not remove documentation to avoid conflicts.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable public changes are recorded here. Internal deployment events,
|
||||||
|
runtime evidence and model-review artifacts are intentionally not part of the
|
||||||
|
public repository.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Prepared the repository for public release under Apache-2.0.
|
||||||
|
- Reworked the README around user workflows, capabilities and supported setup.
|
||||||
|
- Removed generated accuracy evidence, production snapshots and internal
|
||||||
|
execution records from the public source tree.
|
||||||
|
- Bound guest access to a reserved public-demo identity, protected that project
|
||||||
|
from operator uploads and mutations, and added guest compute limits.
|
||||||
|
- Required production provenance before HTTP-triggered model loading.
|
||||||
|
- Added decoded-raster resource budgets and downsampled preview reads.
|
||||||
|
- Reduced unauthenticated health responses to coarse status information.
|
||||||
|
- Pinned production container base images by digest.
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Initial public GeoIntel workbench for Belgium and the Belgian North Sea.
|
||||||
|
- Map-first raster/vector workflows, governed acquisitions, GeoAI adapters,
|
||||||
|
QA/QC, provenance, exports and an Unraid-oriented container deployment.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CODEX START — Use This First
|
||||||
|
|
||||||
|
This is the shortest possible entry point for the first implementation run.
|
||||||
|
|
||||||
|
## Mandatory order
|
||||||
|
|
||||||
|
1. Read `docs/00-start/START_HERE.md`.
|
||||||
|
2. Read `docs/30-codex-optimization/CODEX_RUN_CHECKLIST.md`.
|
||||||
|
3. Read `docs/30-codex-optimization/PROMPT_DISCIPLINE.md`.
|
||||||
|
4. Read `docs/20-run-readiness/RUN_READINESS_FINAL.md`.
|
||||||
|
5. Read `docs/20-run-readiness/CODEX_TOMORROW_RUNBOOK.md`.
|
||||||
|
6. Use `prompts/codex/m14/CODEX_FIRST_DAY_MASTER_PROMPT.md` as the first Codex prompt.
|
||||||
|
7. Follow `docs/20-run-readiness/PASS_SEQUENCE_FINAL.md` exactly.
|
||||||
|
8. Select the relevant skill from `skills/` for the active pass.
|
||||||
|
|
||||||
|
## First build objective
|
||||||
|
|
||||||
|
Build the V1 foundation vertical slice:
|
||||||
|
|
||||||
|
Project → Area → Dataset metadata → Reference polygons → Predicted detections → QA/QC → GeoJSON export → Minimal UI.
|
||||||
|
|
||||||
|
Do not start with heavy AI inference, LiDAR, training, MLOps, Sentinel automation, or advanced report generation before the foundation passes.
|
||||||
|
|
||||||
|
## Pass completion rule
|
||||||
|
|
||||||
|
A pass is not done until:
|
||||||
|
|
||||||
|
- commands were run;
|
||||||
|
- tests/smoke checks were attempted;
|
||||||
|
- docs/status were updated;
|
||||||
|
- limitations are explicit;
|
||||||
|
- next pass is clear.
|
||||||
|
|
||||||
|
|
||||||
|
## M13 additions
|
||||||
|
|
||||||
|
Before implementing, Codex must respect:
|
||||||
|
|
||||||
|
- `docs/30-codex-optimization/CODEX_OPTIMIZATION_OVERVIEW.md`
|
||||||
|
- `docs/30-codex-optimization/TOKEN_BUDGET_POLICY.md`
|
||||||
|
- `docs/30-codex-optimization/SECRETS_AND_ENV_POLICY.md`
|
||||||
|
- `docs/30-codex-optimization/PARALLEL_AGENT_STRATEGY.md` when using multiple agents/worktrees
|
||||||
|
- `docs/30-codex-optimization/CODEX_SKILLS_INDEX.md`
|
||||||
|
|
||||||
|
The preferred first prompt is now:
|
||||||
|
|
||||||
|
- `prompts/codex/m14/CODEX_FIRST_DAY_MASTER_PROMPT.md`
|
||||||
|
|
||||||
|
|
||||||
|
## M14 launch controls
|
||||||
|
|
||||||
|
Before the first implementation pass, Codex must read:
|
||||||
|
|
||||||
|
- `docs/40-build-launch/SPRINT_1_SCOPE_FREEZE.md`
|
||||||
|
- `docs/40-build-launch/BUILD_SUCCESS_DEFINITION.md`
|
||||||
|
- `docs/40-build-launch/BUILD_ORDER_GRAPH.md`
|
||||||
|
- `docs/40-build-launch/CODEX_STOP_RULES.md`
|
||||||
|
- `docs/40-build-launch/MODULE_ACCEPTANCE_CRITERIA.md`
|
||||||
|
|
||||||
|
The first implementation run is Sprint 1 only. Do not implement detection, segmentation, Sentinel, LiDAR, training, AI Copilot or advanced reports during Sprint 1.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Keep GeoIntel changes reproducible, privacy-preserving and free from generated workspace state.
|
||||||
|
|
||||||
|
- Branch from the protected default branch.
|
||||||
|
- Use synthetic or explicitly redistributable imagery, coordinates and datasets in tests and documentation.
|
||||||
|
- Do not commit `.codex-input` contents, SQLite/WAL/SHM files, archives, contact sheets, model outputs, private maps, credentials or local deployment data.
|
||||||
|
- Document the origin, license and intended use of every external dataset, model and media asset.
|
||||||
|
- Add tests for behavioural changes and run the documented backend, frontend, geospatial and managed validation gates.
|
||||||
|
- Explain data-model, coordinate-system, model, privacy and deployment impact in the pull request.
|
||||||
|
|
||||||
|
Report security issues according to `SECURITY.md`.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 Jens / ITWorx.tech
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
docs/18-ultra-prep/README.md
|
||||||
|
docs/18-ultra-prep/AUTONOMOUS_BUILD_CHARTER.md
|
||||||
|
docs/18-ultra-prep/BUILD_PASS_TEMPLATE.md
|
||||||
|
docs/18-ultra-prep/CRITICAL_PATH_TO_V1.md
|
||||||
|
docs/18-ultra-prep/CODEX_START_HERE.md
|
||||||
|
prompts/codex/M10_MASTER_AUTONOMOUS_PROMPT.md
|
||||||
|
prompts/codex/M10_PASS_SEQUENCE.md
|
||||||
|
docs/18-ultra-prep/FEATURE_FLAG_STRATEGY.md
|
||||||
|
docs/18-ultra-prep/ERROR_TAXONOMY.md
|
||||||
|
docs/18-ultra-prep/GEOMETRY_CONTRACTS.md
|
||||||
|
docs/18-ultra-prep/CRS_POLICY.md
|
||||||
|
docs/18-ultra-prep/SECURITY_AND_SECRET_HANDLING.md
|
||||||
|
docs/18-ultra-prep/PERFORMANCE_BUDGETS.md
|
||||||
|
docs/18-ultra-prep/OBSERVABILITY_PLAN.md
|
||||||
|
docs/18-ultra-prep/CONNECTOR_IMPLEMENTATION_GUIDE.md
|
||||||
|
docs/18-ultra-prep/MODEL_ADAPTER_GUIDE.md
|
||||||
|
docs/18-ultra-prep/QA_QC_MATCHING_ALGORITHM.md
|
||||||
|
docs/18-ultra-prep/FRONTEND_STATE_MACHINE.md
|
||||||
|
docs/18-ultra-prep/UI_COPY_BANK.md
|
||||||
|
docs/18-ultra-prep/REPO_HYGIENE_RULES.md
|
||||||
|
docs/18-ultra-prep/RELEASE_GATE_V1.md
|
||||||
|
docs/18-ultra-prep/KNOWN_LIMITATIONS_TEMPLATE.md
|
||||||
|
docs/18-ultra-prep/FINAL_PRE_CODEX_CHECKLIST.md
|
||||||
|
tickets/TICKET_INDEX.md
|
||||||
|
tickets/T-001-backend-skeleton.md
|
||||||
|
tickets/T-002-database-foundation.md
|
||||||
|
tickets/T-003-project-area-domain.md
|
||||||
|
tickets/T-010-dataset-manager.md
|
||||||
|
tickets/T-011-vector-processing.md
|
||||||
|
tickets/T-012-raster-processing.md
|
||||||
|
tickets/T-020-frontend-foundation.md
|
||||||
|
tickets/T-021-map-workbench.md
|
||||||
|
tickets/T-022-dataset-ui.md
|
||||||
|
tickets/T-030-detection-adapter.md
|
||||||
|
tickets/T-031-qaqc-engine.md
|
||||||
|
tickets/T-032-export-engine.md
|
||||||
|
tickets/T-033-demo-workflow.md
|
||||||
|
contracts/api/examples/project_create.json
|
||||||
|
contracts/api/examples/area_create.geojson
|
||||||
|
contracts/api/examples/error_feature_disabled.json
|
||||||
|
contracts/api/examples/qaqc_result.json
|
||||||
|
scripts/smoke_m10.sh
|
||||||
|
docs/TODO.md
|
||||||
|
RELEASE_NOTES/M10_ultra_preparation.md
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
M11 Architect Audit & Control Layer
|
||||||
|
|
||||||
|
Added:
|
||||||
|
- docs/00-start/START_HERE.md
|
||||||
|
- docs/governance/GEOINTEL_CONSTITUTION.md
|
||||||
|
- docs/governance/FORBIDDEN_DECISIONS.md
|
||||||
|
- docs/governance/ARCHITECTURE_INVARIANTS.md
|
||||||
|
- docs/governance/DECISION_PRECEDENCE.md
|
||||||
|
- docs/specs/CANONICAL_DOMAIN_MODELS.md
|
||||||
|
- docs/specs/GIS_STANDARDS.md
|
||||||
|
- docs/specs/RASTER_STANDARDS.md
|
||||||
|
- docs/specs/STATE_MACHINES.md
|
||||||
|
- docs/specs/DATA_LIFECYCLE.md
|
||||||
|
- docs/specs/ERROR_CATALOG.md
|
||||||
|
- docs/specs/PERFORMANCE_BUDGETS_CANONICAL.md
|
||||||
|
- docs/workflows/GOLDEN_PATHS.md
|
||||||
|
- docs/build/BUILD_ORDER_DEPENDENCY_GRAPH.md
|
||||||
|
- docs/build/CODEX_OPERATING_SYSTEM.md
|
||||||
|
- docs/19-architect-audit/ARCHITECT_AUDIT_REPORT_M11.md
|
||||||
|
- prompts/codex/M11_ARCHITECT_MASTER_PROMPT.md
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- README.md now points to the single canonical M11 start path.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
M12 Final Run Readiness Layer
|
||||||
|
|
||||||
|
Added:
|
||||||
|
- CODEX_START.md
|
||||||
|
- docs/20-run-readiness/RUN_READINESS_FINAL.md
|
||||||
|
- docs/20-run-readiness/PASS_SEQUENCE_FINAL.md
|
||||||
|
- docs/20-run-readiness/CODEX_TOMORROW_RUNBOOK.md
|
||||||
|
- docs/20-run-readiness/IMPLEMENTATION_READINESS_CHECKLIST.md
|
||||||
|
- docs/20-run-readiness/REPO_CONFLICT_RESOLUTION.md
|
||||||
|
- prompts/codex/final/DAY_1_MASTER_PROMPT.md
|
||||||
|
- prompts/codex/final/PASS_00_REPO_AUDIT_FINAL.md
|
||||||
|
- prompts/codex/final/PASS_01_BACKEND_FOUNDATION_FINAL.md
|
||||||
|
- prompts/codex/final/PASS_02_DOMAIN_DATABASE_FINAL.md
|
||||||
|
- scripts/preimplementation_audit.py
|
||||||
|
- scripts/run_readiness_check.sh
|
||||||
|
- Makefile
|
||||||
|
- RELEASE_NOTES/v0.12-m12-final-run-readiness.md
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- README.md
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
M13 — Codex Optimization Pack
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Improve Codex execution quality after M12 final run readiness.
|
||||||
|
- Add reusable skills, prompt discipline, token policy, secrets policy, parallel agent strategy and pass completion prompts.
|
||||||
|
|
||||||
|
Added:
|
||||||
|
- docs/30-codex-optimization/CODEX_OPTIMIZATION_OVERVIEW.md
|
||||||
|
- docs/30-codex-optimization/CODEX_RUN_CHECKLIST.md
|
||||||
|
- docs/30-codex-optimization/PROMPT_DISCIPLINE.md
|
||||||
|
- docs/30-codex-optimization/TOKEN_BUDGET_POLICY.md
|
||||||
|
- docs/30-codex-optimization/SECRETS_AND_ENV_POLICY.md
|
||||||
|
- docs/30-codex-optimization/PARALLEL_AGENT_STRATEGY.md
|
||||||
|
- docs/30-codex-optimization/CODEX_SKILLS_INDEX.md
|
||||||
|
- docs/30-codex-optimization/M13_HANDOFF_SUMMARY.md
|
||||||
|
- skills/*/SKILL.md
|
||||||
|
- prompts/codex/m13/*.md
|
||||||
|
- scripts/validate_m13_codex_assets.py
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
- README.md
|
||||||
|
- CODEX_START.md
|
||||||
|
- Makefile
|
||||||
|
- scripts/run_readiness_check.sh
|
||||||
|
- CHANGELOG.md
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
M14 Build Launch Package
|
||||||
|
|
||||||
|
Added:
|
||||||
|
- docs/40-build-launch/BUILD_SUCCESS_DEFINITION.md
|
||||||
|
- docs/40-build-launch/SPRINT_1_SCOPE_FREEZE.md
|
||||||
|
- docs/40-build-launch/DATA_ACQUISITION_PLAYBOOK.md
|
||||||
|
- docs/40-build-launch/GOLDEN_DATASET_PACKAGE.md
|
||||||
|
- docs/40-build-launch/BUILD_ORDER_GRAPH.md
|
||||||
|
- docs/40-build-launch/MODULE_ACCEPTANCE_CRITERIA.md
|
||||||
|
- docs/40-build-launch/CODEX_STOP_RULES.md
|
||||||
|
- docs/40-build-launch/RELEASE_STRATEGY.md
|
||||||
|
- docs/40-build-launch/RISK_REGISTER.md
|
||||||
|
- docs/40-build-launch/BACKLOG_PRIORITIES_MOSCOW.md
|
||||||
|
- docs/40-build-launch/FOLDER_OWNERSHIP.md
|
||||||
|
- prompts/codex/m14/CODEX_FIRST_DAY_MASTER_PROMPT.md
|
||||||
|
- checklists/SPRINT_1_OPERATOR_CHECKLIST.md
|
||||||
|
- release/v0.1-foundation-target.md
|
||||||
|
- scripts/validate_m14_launch_assets.py
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
- README.md
|
||||||
|
- CODEX_START.md
|
||||||
|
- docs/00-start/START_HERE.md
|
||||||
|
- Makefile
|
||||||
|
- scripts/run_readiness_check.sh
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
docs/OBSERVABILITY_PLAN.md
|
||||||
|
docs/TROUBLESHOOTING_RUNBOOK.md
|
||||||
|
docs/RELEASE_PROCESS.md
|
||||||
|
docs/ROLLBACK_AND_RECOVERY.md
|
||||||
|
docs/DEPENDENCY_LOCK_PLAN.md
|
||||||
|
docs/SECURITY_CHECKLIST.md
|
||||||
|
docs/DATA_PRIVACY_AND_LICENSING.md
|
||||||
|
docs/EXTERNAL_SERVICES_ADAPTERS.md
|
||||||
|
docs/GEOSPATIAL_VALIDATION_RULES.md
|
||||||
|
docs/BUILD_GOVERNANCE.md
|
||||||
|
docs/M5_OPERATIONAL_READINESS.md
|
||||||
|
docs/CI_CD_SPECIFICATION.md
|
||||||
|
docs/HEALTHCHECK_CONTRACTS.md
|
||||||
|
docs/CODEX_PASS_0_REPO_AUDIT.md
|
||||||
|
docs/CODEX_PASS_1_BACKEND_FOUNDATION.md
|
||||||
|
docs/CODEX_PASS_2_DATABASE_AND_MODELS.md
|
||||||
|
docs/CODEX_PASS_3_DATASET_MANAGER.md
|
||||||
|
docs/CODEX_PASS_4_RASTER_VECTOR_CORE.md
|
||||||
|
docs/CODEX_PASS_5_FRONTEND_WORKBENCH_SHELL.md
|
||||||
|
docs/CODEX_PASS_6_DETECTION_QA_SKELETON.md
|
||||||
|
docs/CODEX_PROMPT_M5_LONG_AUTONOMOUS_BUILD.md
|
||||||
|
scripts/check_repo_structure.sh
|
||||||
|
scripts/smoke_backend_import.sh
|
||||||
|
scripts/smoke_docs.py
|
||||||
|
scripts/smoke_contracts.py
|
||||||
|
scripts/validate_fixtures.py
|
||||||
|
RELEASE_NOTES/v0.5-m5-operational-readiness.md
|
||||||
|
CHANGELOG.md
|
||||||
|
docs/TODO.md
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# M9 Update Manifest
|
||||||
|
|
||||||
|
New/changed files:
|
||||||
|
|
||||||
|
- `docs/17-max-prep/M9_MAX_PREPARATION_PACK.md`
|
||||||
|
- `prompts/codex/M9_DAY_ONE_MASTER_PROMPT.md`
|
||||||
|
- `docs/17-max-prep/M9_AUTONOMOUS_BUILD_DOCTRINE.md`
|
||||||
|
- `docs/17-max-prep/M9_PASS_SCORECARDS.md`
|
||||||
|
- `docs/17-max-prep/M9_BUILD_BLOCKERS_AND_RECOVERY.md`
|
||||||
|
- `docs/17-max-prep/M9_REAL_VS_DEMO_DATA_POLICY.md`
|
||||||
|
- `docs/17-max-prep/M9_DATA_CONTRACTS_DETAILED.md`
|
||||||
|
- `docs/17-max-prep/M9_GEOSPATIAL_EDGE_CASES.md`
|
||||||
|
- `docs/17-max-prep/M9_UI_STATE_SPEC.md`
|
||||||
|
- `docs/17-max-prep/M9_API_VALIDATION_EXAMPLES.md`
|
||||||
|
- `docs/17-max-prep/M9_IMPLEMENTATION_REVIEW_SCRIPT.md`
|
||||||
|
- `docs/17-max-prep/M9_REGRESSION_MAP.md`
|
||||||
|
- `docs/17-max-prep/M9_GAP_TO_TASK_CONVERSION.md`
|
||||||
|
- `docs/17-max-prep/M9_MODULE_DATAFLOW_CHECKLIST.md`
|
||||||
|
- `docs/17-max-prep/M9_FINAL_PRE_CODE_CHECKLIST.md`
|
||||||
|
- `docs/17-max-prep/M9_LONG_FORM_CODEX_PROMPT_VARIANTS.md`
|
||||||
|
- `docs/IMPLEMENTATION_GAP_REPORT.md`
|
||||||
|
- `RELEASE_NOTES/v0.9-m9-max-preparation.md`
|
||||||
|
- `CHANGELOG.md`
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
PYTHON_BIN := $(shell command -v python3 >/dev/null 2>&1 && echo python3 || echo python)
|
||||||
|
|
||||||
|
.PHONY: readiness docs fixtures preflight backend-install backend-test backend-dev frontend-install frontend-typecheck frontend-build m13 m14
|
||||||
|
|
||||||
|
readiness:
|
||||||
|
bash scripts/run_readiness_check.sh
|
||||||
|
|
||||||
|
backend-install:
|
||||||
|
cd backend && \
|
||||||
|
$(PYTHON_BIN) -m pip install -e .[dev]
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
cd backend && \
|
||||||
|
$(PYTHON_BIN) -m pytest
|
||||||
|
|
||||||
|
backend-dev:
|
||||||
|
cd backend && \
|
||||||
|
$(PYTHON_BIN) -m uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
frontend-install:
|
||||||
|
cd frontend && \
|
||||||
|
npm install
|
||||||
|
|
||||||
|
frontend-typecheck:
|
||||||
|
cd frontend && \
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
cd frontend && \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
docs:
|
||||||
|
$(PYTHON_BIN) scripts/smoke_docs.py
|
||||||
|
|
||||||
|
fixtures:
|
||||||
|
$(PYTHON_BIN) scripts/validate_fixtures.py
|
||||||
|
|
||||||
|
preflight:
|
||||||
|
bash scripts/codex_preflight.sh || true
|
||||||
|
$(PYTHON_BIN) scripts/preimplementation_audit.py
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: m13
|
||||||
|
m13:
|
||||||
|
$(PYTHON_BIN) scripts/validate_m13_codex_assets.py
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: m14
|
||||||
|
m14:
|
||||||
|
$(PYTHON_BIN) scripts/validate_m14_launch_assets.py
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# GeoIntel
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="frontend/public/geointel-icon-180.png" width="92" alt="GeoIntel logo">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><strong>Evidence-first GeoAI voor België en de Belgische Noordzee.</strong></p>
|
||||||
|
|
||||||
|
GeoIntel is een kaartgerichte GeoAI-workbench waarin officiële databronnen,
|
||||||
|
ruimtelijke analyse, PyTorch-computervisie, QA/QC en export samenkomen. Het
|
||||||
|
platform bewaart niet alleen een resultaat, maar ook de bron, ruimtelijke
|
||||||
|
context, modelversie en het bewijs waarmee dat resultaat gecontroleerd kan
|
||||||
|
worden.
|
||||||
|
|
||||||
|
De release ondersteunt heel België en de juridisch onderscheiden Belgische
|
||||||
|
Noordzeezones. Regionale bronnen blijven herkenbaar: GRB, PICC, UrbIS en
|
||||||
|
maritieme datasets worden niet voorgesteld alsof ze dezelfde semantiek,
|
||||||
|
resolutie of actualiteit hebben.
|
||||||
|
|
||||||
|
> **Data → ruimtelijke verwerking → AI-resultaat → kwaliteitsbewijs → export**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Waarom GeoIntel?
|
||||||
|
|
||||||
|
Veel geoportalen tonen lagen. GeoIntel ondersteunt een volledige,
|
||||||
|
controleerbare analyseketen:
|
||||||
|
|
||||||
|
- selecteer een officiële grens of teken een eigen Area of Interest;
|
||||||
|
- combineer vector-, raster- en historische bronnen met expliciete dekking;
|
||||||
|
- voer GIS- en CUDA-versnelde beeldanalyse uit;
|
||||||
|
- vergelijk kandidaatresultaten met referentiedata;
|
||||||
|
- inspecteer false positives, false negatives, overlap en provenance;
|
||||||
|
- exporteer alleen wat als reproduceerbaar resultaat is vastgelegd.
|
||||||
|
|
||||||
|
Mol en de Kempen blijven de gouden regressiegebieden. De productgrens is heel
|
||||||
|
België plus de juridisch benoemde Belgische maritieme zones.
|
||||||
|
|
||||||
|
## Product in beeld
|
||||||
|
|
||||||
|
De onderstaande screenshots tonen de huidige applicatie met publieke
|
||||||
|
demodata. De gastmodus toont een projectgebonden demowerkruimte
|
||||||
|
met dezelfde kaart-, bron-, model-, analyse-, QA- en exportflow als een
|
||||||
|
operator. Alleen beheer, instellingen, uploads, bronconfiguratie,
|
||||||
|
projectbeheer en evidence-review blijven afgeschermd.
|
||||||
|
|
||||||
|
### Interactieve projectketen
|
||||||
|
|
||||||
|
De landingspagina vertaalt de technische keten naar vier interactieve
|
||||||
|
schakels. De illustratie reageert op selectie, bronnen, analyse en bewijs en
|
||||||
|
respecteert automatisch `prefers-reduced-motion`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Kaart als werkruimte
|
||||||
|
|
||||||
|
De kaart blijft het primaire werkblad. Thema, broncontext, selectie en
|
||||||
|
resultaten zijn vanuit dezelfde ruimtelijke context bereikbaar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Op een breed scherm krijgt de kaart extra ruimte terwijl de themakolom en de
|
||||||
|
controleerbare analysestappen zichtbaar blijven.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Kwaliteit vóór resultaat
|
||||||
|
|
||||||
|
QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan
|
||||||
|
objectbewijs, kandidaat- en referentielagen en technische provenance.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Mobiele werkruimte
|
||||||
|
|
||||||
|
Dezelfde kaartgerichte workflow blijft bruikbaar op een smal scherm. Thema's,
|
||||||
|
selectieacties en de kaart worden gestapeld zonder de actieve werkcontext te
|
||||||
|
verbergen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Kernmogelijkheden
|
||||||
|
|
||||||
|
| Domein | Mogelijkheden |
|
||||||
|
| --- | --- |
|
||||||
|
| Werkgebieden | Officiële grenzen, vrije AOI-selectie, clipping en buffering |
|
||||||
|
| Bronnen | GeoJSON, vector, raster, orthofoto, historische en regionale catalogi |
|
||||||
|
| GIS | CRS-normalisatie, spatial joins, rasterstatistiek, tile- en selectieanalyse |
|
||||||
|
| GeoAI | PyTorch, Ultralytics YOLO en SAM-compatibele abstrahering |
|
||||||
|
| Tijd | Immutable DatasetVersions en vergelijking van ruimtelijke snapshots |
|
||||||
|
| QA/QC | Precision, recall, F1, IoU, false-positive/-negative evidence en reviews |
|
||||||
|
| Export | Reproduceerbare GeoJSON- en artefactexports met provenance |
|
||||||
|
| Runtime | DockerMan-native Unraid-container met PostGIS, backend en frontend |
|
||||||
|
|
||||||
|
## Architectuur
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
UI["React + MapLibre workbench"] --> API["FastAPI contracts"]
|
||||||
|
API --> GIS["GeoPandas / Shapely / Rasterio"]
|
||||||
|
API --> JOBS["Job-queue in PostgreSQL"]
|
||||||
|
JOBS --> AI["PyTorch / YOLO / SAM"]
|
||||||
|
GIS --> DB[("PostgreSQL + PostGIS")]
|
||||||
|
AI --> DB
|
||||||
|
DB --> QA["QA/QC + provenance"]
|
||||||
|
QA --> UI
|
||||||
|
QA --> EXPORT["GeoJSON / artifacts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
De publieke endpoints volgen [docs/API_CONTRACTS.md](docs/API_CONTRACTS.md).
|
||||||
|
PostGIS en de persistentieregels volgen
|
||||||
|
[docs/DATABASE_IMPLEMENTATION_PLAN.md](docs/DATABASE_IMPLEMENTATION_PLAN.md).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Frontend:** React 18, TypeScript, MapLibre GL, Lucide en Vite
|
||||||
|
- **Backend:** FastAPI en Python
|
||||||
|
- **Spatial:** GeoPandas, Shapely, Rasterio, PyProj en GDAL
|
||||||
|
- **AI:** PyTorch, Ultralytics YOLO en SAM-compatibele segmentatie
|
||||||
|
- **Data:** PostgreSQL/PostGIS, lokale artefactopslag en immutable versions
|
||||||
|
- **Jobs:** job-tabel in PostgreSQL met achtergrondworkers in het API-proces
|
||||||
|
- **Deployment:** één DockerMan-native Unraid-container
|
||||||
|
|
||||||
|
## Lokaal starten
|
||||||
|
|
||||||
|
Vereisten: Python 3.11+, Node 20.19+ of 22.12+ en PostgreSQL/PostGIS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -e backend/.[dev]
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Voor de volledige lokale stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open daarna `http://localhost:1202`.
|
||||||
|
|
||||||
|
### Gastdemo
|
||||||
|
|
||||||
|
Met `GEOINTEL_AUTH_ENABLED=true` en de expliciete opt-in
|
||||||
|
`GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een
|
||||||
|
kortlevende, projectgebonden demosessie. De gast heeft binnen het ingestelde
|
||||||
|
demoproject dezelfde kaart-, bronselectie-, modelselectie-, analyse-, QA- en
|
||||||
|
exportmogelijkheden als een operator. Alleen beheerfuncties zoals instellingen,
|
||||||
|
uploads, bronconfiguratie, projectbeheer en evidence-review blijven geblokkeerd;
|
||||||
|
dit is geen multi-user- of tenantisolatie. Gasttoegang staat standaard uit en
|
||||||
|
hoort alleen op een aparte demo-installatie met publieke data.
|
||||||
|
|
||||||
|
Een optionele Authentik OIDC-login kan naast de lokale operatorlogin worden
|
||||||
|
ingeschakeld. GeoIntel gebruikt daarbij PKCE, state, nonce, issuer-/audience-
|
||||||
|
controle en één expliciet toegelaten, geverifieerd e-mailadres. De lokale
|
||||||
|
operatorlogin blijft altijd het herstelpad.
|
||||||
|
|
||||||
|
## NVIDIA/CUDA
|
||||||
|
|
||||||
|
Productie-AI gebruikt de NVIDIA GPU van de server. De runtime faalt gesloten
|
||||||
|
wanneer CUDA vereist maar niet beschikbaar is; ontbrekende modelconfiguratie
|
||||||
|
wordt als `not_configured` getoond en nooit als geslaagde inferentie.
|
||||||
|
|
||||||
|
Corpus-, kalibratie-, test- en pure-background-gates blijven gescheiden: een
|
||||||
|
experimentele label- of modelvariant wordt pas actief nadat alle toepasselijke
|
||||||
|
kwaliteitscontroles slagen. Exacte runtime- en hardwaregegevens horen bij de
|
||||||
|
lokale deployment, niet bij de publieke broncode.
|
||||||
|
|
||||||
|
Controleer GPU-zichtbaarheid in de container met:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec geointel nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Training en labelkwaliteit
|
||||||
|
|
||||||
|
GeoIntel behandelt officiële gebouwgrondvlakken niet automatisch als perfecte
|
||||||
|
daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage,
|
||||||
|
bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd.
|
||||||
|
Een directe polygon-overlay maakt bovendien zichtbaar of de officiële geometrie
|
||||||
|
op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen. Voor
|
||||||
|
productresultaten blijft de taakgeschikte officiële bron doorslaggevend; een
|
||||||
|
AI-detectie is controleerbaar voorstelbewijs zolang een taakgebonden releasegate
|
||||||
|
niet aantoonbaar anders beslist.
|
||||||
|
|
||||||
|
Modelactivatie vereist naast het oudere diagnostische promotierapport ook een
|
||||||
|
geslaagd, governed Phase-4/5 release-gaterapport dat exact dezelfde candidate
|
||||||
|
key, model-SHA-256 en benchmarkmanifest-SHA-256 bindt. Zie
|
||||||
|
[Accuracy, modelgrenzen en bewijs](docs/ACCURACY.md). Er wordt geen
|
||||||
|
100%-accuratieclaim gemaakt; modeloutput blijft controleerbaar voorstelbewijs.
|
||||||
|
|
||||||
|
## Portfolio case study
|
||||||
|
|
||||||
|
De presentatieklare case study combineert productvraag, interface, PyTorch- en
|
||||||
|
NVIDIA-keten, architectuur en resultaten in één visueel document:
|
||||||
|
|
||||||
|
- [GeoIntel case study PDF](output/pdf/geointel-case-study.pdf)
|
||||||
|
- [Donkere cover in hoge resolutie](frontend/public/portfolio/geointel-dark-case-study-cover.png)
|
||||||
|
- [Architectuurvisual](docs/assets/portfolio/geointel-architecture.png)
|
||||||
|
|
||||||
|
De PDF kan reproduceerbaar opnieuw worden opgebouwd met:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/create_portfolio_case_study.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kwaliteitscontrole
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
python -m compileall backend/app
|
||||||
|
cd backend && python -m pytest
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm run typecheck
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Een wijziging is pas afgerond wanneer de toepasselijke criteria uit
|
||||||
|
[docs/DEFINITION_OF_DONE.md](docs/DEFINITION_OF_DONE.md) aantoonbaar zijn
|
||||||
|
afgevinkt.
|
||||||
|
|
||||||
|
## Unraid-deployment
|
||||||
|
|
||||||
|
De ondersteunde productieopstelling bundelt PostGIS, backend en frontend in één
|
||||||
|
Unraid-container. Kopieer de voorbeeldconfiguratie, vul de verplichte
|
||||||
|
authenticatie- en opslaginstellingen in en gebruik de releaseworkflow met
|
||||||
|
backup-, healthcheck- en rollbackcontrole.
|
||||||
|
|
||||||
|
Zie [deploy/unraid/README.md](deploy/unraid/README.md) en
|
||||||
|
[docs/RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) voor configuratie,
|
||||||
|
healthchecks en rollback.
|
||||||
|
|
||||||
|
## Documentatiekaart
|
||||||
|
|
||||||
|
- [Start hier](docs/00-start/START_HERE.md)
|
||||||
|
- [Belgium/North Sea scope freeze](docs/RC_SCOPE_FREEZE_BELGIUM_NORTH_SEA.md)
|
||||||
|
- [Actieve release-roadmap](docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md)
|
||||||
|
- [API-contracten](docs/API_CONTRACTS.md)
|
||||||
|
- [Database-implementatieplan](docs/DATABASE_IMPLEMENTATION_PLAN.md)
|
||||||
|
- [Bekende beperkingen](docs/KNOWN_LIMITATIONS.md)
|
||||||
|
- [Release-runbook](docs/RELEASE_RUNBOOK.md)
|
||||||
|
|
||||||
|
## Portfolio-assets
|
||||||
|
|
||||||
|
De map [`docs/assets/portfolio`](docs/assets/portfolio) bevat presentatieklare
|
||||||
|
screenshots, de geanimeerde projectketen, architectuur- en AI-visuals. De drie
|
||||||
|
originele campagnebeelden staan in
|
||||||
|
[`frontend/public/portfolio`](frontend/public/portfolio). Geoptimaliseerde
|
||||||
|
WebP-varianten worden door de applicatie gebruikt; de PNG-bronnen blijven
|
||||||
|
beschikbaar voor drukwerk en portfolio-opmaak.
|
||||||
|
|
||||||
|
Actuele rasterassets:
|
||||||
|
|
||||||
|
| Asset | Gebruik |
|
||||||
|
| --- | --- |
|
||||||
|
| `geointel-landing-hero.png` | Desktop hero en projectintroductie |
|
||||||
|
| `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering |
|
||||||
|
| `geointel-workbench-map.png` | Kaartgerichte gastwerkruimte |
|
||||||
|
| `geointel-workbench-wide.png` | Brede kaartwerkruimte voor desktopportfolio's |
|
||||||
|
| `geointel-workbench-quality.png` | QA/QC, metrics en objectbewijs |
|
||||||
|
| `geointel-landing-mobile.png` | Mobiele landing |
|
||||||
|
| `geointel-workbench-mobile.png` | Mobiele kaartworkflow |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Actieve mijlpaal: **v1.0.0 — Belgium and Belgian North Sea**.
|
||||||
|
|
||||||
|
GeoIntel is een project van Jens / ITWorx.tech.
|
||||||
|
|
||||||
|
## Licentie
|
||||||
|
|
||||||
|
GeoIntel is beschikbaar onder de [Apache License 2.0](LICENSE).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# M10 Ultra Preparation
|
||||||
|
|
||||||
|
M10 adds a stronger Codex autonomy layer:
|
||||||
|
|
||||||
|
- autonomous build charter;
|
||||||
|
- Codex start-here guide;
|
||||||
|
- pass sequence;
|
||||||
|
- master prompt;
|
||||||
|
- geometry contracts;
|
||||||
|
- CRS policy;
|
||||||
|
- security and secret handling;
|
||||||
|
- performance budgets;
|
||||||
|
- observability plan;
|
||||||
|
- connector guide;
|
||||||
|
- model adapter guide;
|
||||||
|
- QA/QC matching algorithm;
|
||||||
|
- frontend state machine;
|
||||||
|
- UI copy bank;
|
||||||
|
- repo hygiene rules;
|
||||||
|
- V1 release gate;
|
||||||
|
- implementation tickets;
|
||||||
|
- API example payloads;
|
||||||
|
- final pre-Codex checklist.
|
||||||
|
|
||||||
|
This milestone aims to make tomorrow's Codex build significantly more autonomous while preserving strict product boundaries.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Release Notes — v0.0 M2 Engineering Package
|
||||||
|
|
||||||
|
This is not an application release. It is a repository preparation milestone for autonomous Codex development.
|
||||||
|
|
||||||
|
## Main value
|
||||||
|
|
||||||
|
Codex now has fewer architecture choices to invent. The repo contains decision records, contracts, engineering rules, fixtures, and build prompts.
|
||||||
|
|
||||||
|
## Next recommended action
|
||||||
|
|
||||||
|
Run Codex Pass 01 using `prompts/codex/PASS_01_BACKEND_FOUNDATION.md`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Release Notes — v0.0 M3 Implementation Readiness
|
||||||
|
|
||||||
|
This is a documentation and repository preparation release.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- implementation epics
|
||||||
|
- build tickets
|
||||||
|
- migration plan
|
||||||
|
- seed data plan
|
||||||
|
- local dev runbook
|
||||||
|
- backend package map
|
||||||
|
- frontend route map
|
||||||
|
- module contracts
|
||||||
|
- job lifecycle
|
||||||
|
- Codex pass matrix
|
||||||
|
- additional Codex prompts
|
||||||
|
- known limitations
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Prepare the repository for Codex-driven implementation without requiring major architecture decisions during coding.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# v0.11 — M11 Architect Audit & Control Layer
|
||||||
|
|
||||||
|
This release turns the GeoIntel preparation repo into a stricter architecture-controlled implementation repo.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- One canonical `START_HERE` document.
|
||||||
|
- Constitution, forbidden decisions and architecture invariants.
|
||||||
|
- Canonical domain model definitions.
|
||||||
|
- GIS/raster standards.
|
||||||
|
- State machines and data lifecycle.
|
||||||
|
- Golden paths and build dependency graph.
|
||||||
|
- Error catalog and canonical performance budgets.
|
||||||
|
- M11 Codex architect master prompt.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The goal is to reduce Codex ambiguity before implementation starts. Older handoff documents remain available, but M11 defines the precedence and operating model.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# v0.12 — M12 Final Run Readiness Layer
|
||||||
|
|
||||||
|
This release turns the M11 architect audit repo into a directly executable Codex preparation package.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Root `CODEX_START.md` as the shortest canonical entry point.
|
||||||
|
- Final run-readiness docs under `docs/20-run-readiness/`.
|
||||||
|
- Final Day 1 Codex master prompt under `prompts/codex/final/`.
|
||||||
|
- Final pass prompts for Pass 00, Pass 01 and Pass 02.
|
||||||
|
- `scripts/preimplementation_audit.py`.
|
||||||
|
- `scripts/run_readiness_check.sh`.
|
||||||
|
- Root `Makefile` with `make readiness`.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- README now points to M12 and the final run path.
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Reduce manual work tomorrow by giving Codex one obvious entry point, one pass sequence, one first prompt, and a simple readiness command.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# v0.4 — M4 Autonomous Build Readiness
|
||||||
|
|
||||||
|
This release adds the documentation and fixtures required for longer autonomous Codex implementation passes.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
- Clear sprint board.
|
||||||
|
- Module build contracts.
|
||||||
|
- Acceptance tests.
|
||||||
|
- Service IO contracts.
|
||||||
|
- UI route/state contracts.
|
||||||
|
- Job lifecycle contract.
|
||||||
|
- Demo model registry seed.
|
||||||
|
- Geel demo fixtures.
|
||||||
|
- Codex prompts per pass.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
M5 should add concrete migration SQL, OpenAPI draft, component prop contracts and test skeletons.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# GeoIntel v0.5 — M5 Operational Readiness
|
||||||
|
|
||||||
|
## Toegevoegd
|
||||||
|
- Operational readiness documentatie.
|
||||||
|
- CI/CD-specificatie.
|
||||||
|
- Healthcheck-contracten.
|
||||||
|
- Observability plan.
|
||||||
|
- Troubleshooting runbook.
|
||||||
|
- Releaseproces.
|
||||||
|
- Rollback- en recoveryregels.
|
||||||
|
- Dependency lock plan.
|
||||||
|
- Security checklist.
|
||||||
|
- Data privacy en licensing notities.
|
||||||
|
- External services adaptercontracten.
|
||||||
|
- Geospatial validation rules.
|
||||||
|
- Build governance.
|
||||||
|
- Codex passdocumenten voor Pass 0 tot Pass 6.
|
||||||
|
- Long autonomous Codex build prompt.
|
||||||
|
- Smoke scripts voor repo/docs/contracts/backend import.
|
||||||
|
|
||||||
|
## Volgende logische stap
|
||||||
|
M6 kan zich richten op echte code-scaffolding: backend app, database migrations, API schemas, frontend shell en eerste project/dataset flows.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# GeoIntel v0.9 — M9 Max Preparation
|
||||||
|
|
||||||
|
This release adds a heavy preparation layer intended to maximize Codex autonomy before implementation.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- M9 max preparation pack.
|
||||||
|
- Day-one Codex master prompt.
|
||||||
|
- Autonomous build doctrine.
|
||||||
|
- Build pass scorecards.
|
||||||
|
- Build blocker and recovery guide.
|
||||||
|
- Real vs demo data policy.
|
||||||
|
- Detailed data contracts.
|
||||||
|
- Geospatial edge case catalog.
|
||||||
|
- UI state specification.
|
||||||
|
- API validation examples.
|
||||||
|
- Implementation review script.
|
||||||
|
- Regression map.
|
||||||
|
- Gap-to-task conversion rules.
|
||||||
|
- Module dataflow checklist.
|
||||||
|
- Final pre-code checklist.
|
||||||
|
- Long-form Codex prompt variants.
|
||||||
|
- Implementation gap report template.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Make the repository as ready as possible for a long autonomous Codex build session.
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported code
|
||||||
|
|
||||||
|
Security fixes target the current `main` release line. Historical research,
|
||||||
|
calibration and development branches are not supported release channels unless
|
||||||
|
explicitly documented otherwise.
|
||||||
|
|
||||||
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
|
Report suspected security issues privately to the repository owner. Do not put
|
||||||
|
credentials, access tokens, private infrastructure, precise sensitive
|
||||||
|
locations, proprietary imagery or datasets, model artifacts, production
|
||||||
|
database contents, personal data or exploit-sensitive evidence in a public
|
||||||
|
issue.
|
||||||
|
|
||||||
|
Include the affected commit and component, minimal reproduction conditions
|
||||||
|
using synthetic or public data where possible, expected and observed
|
||||||
|
behaviour, impact, and whether the issue affects ingestion, geospatial
|
||||||
|
processing, authentication or authorization, uploads, archive or path handling,
|
||||||
|
model inference, reports and exports, deployment, data retention or secrets.
|
||||||
|
|
||||||
|
## Repository and data boundary
|
||||||
|
|
||||||
|
Geospatial inputs and derived outputs can reveal people, assets and sensitive
|
||||||
|
locations. Treat screenshots, contact sheets, coordinates, source archives,
|
||||||
|
SQLite/WAL state, model output and exports as potentially sensitive. These
|
||||||
|
belong in controlled external storage, not the source repository.
|
||||||
|
|
||||||
|
Local Codex inputs, calibration databases, runtime data, generated reports and
|
||||||
|
cross-project scratch are not release source. Never commit live `.env` files,
|
||||||
|
private keys, production backups or databases, customer or operator data,
|
||||||
|
unpublished imagery, credentials or private datasets. Public examples and tests
|
||||||
|
must use synthetic or explicitly redistributable material.
|
||||||
|
|
||||||
|
## Disclosure
|
||||||
|
|
||||||
|
Coordinate remediation and disclosure with the repository owner before publishing details that would materially increase exploitation risk.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR-001 — Technology Stack
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
GeoIntel Kempen must demonstrate modern web development, geospatial processing, and GeoAI engineering. The stack must be realistic for a portfolio project while remaining close to professional workflows.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- Frontend: React + TypeScript.
|
||||||
|
- Map UI: MapLibre GL with Deck.gl where advanced overlays are useful.
|
||||||
|
- Backend: FastAPI.
|
||||||
|
- Database: PostgreSQL + PostGIS.
|
||||||
|
- Processing: GeoPandas, Shapely, Rasterio, PyProj, GDAL-compatible tools.
|
||||||
|
- AI: PyTorch with Ultralytics YOLO first; SAM/segmentation later.
|
||||||
|
- Jobs: Redis + RQ for V1.
|
||||||
|
- Storage: local filesystem with explicit storage abstraction.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
This stack keeps the first build achievable while matching the vacancy profile closely: Python, raster/vector processing, computer vision, AI pipelines, and GIS outputs.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
Do not introduce Django, Flask, MongoDB, Firebase, or a second frontend framework unless a future ADR explicitly replaces this decision.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ADR-002 — PostGIS as Spatial Source of Truth
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
GeoIntel stores areas, datasets, AI detections, segmentations, QA geometries, and exports. Spatial operations need to be queryable and persistent.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use PostgreSQL with PostGIS as the canonical database for:
|
||||||
|
|
||||||
|
- project areas,
|
||||||
|
- dataset spatial bounds,
|
||||||
|
- vector features,
|
||||||
|
- detection polygons/boxes,
|
||||||
|
- segmentation polygons,
|
||||||
|
- QA/QC geometries,
|
||||||
|
- spatial metadata,
|
||||||
|
- analysis outputs.
|
||||||
|
|
||||||
|
Raw rasters, tiles, masks, and large binary artifacts stay on disk/object storage. PostGIS stores metadata and vectorized results.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
The backend can do spatial filtering, intersections, bounding-box queries, and QA matching without reloading every file. The portfolio visibly demonstrates professional GIS database skills.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
Do not store full large rasters as database blobs in V1.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR-003 — GRB as Authoritative Reference Dataset
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1 research and implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The Basiskaart Vlaanderen / GRB is a professional Flemish geospatial reference dataset. GeoIntel is scoped to the Kempen, so Flemish official data is highly relevant.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Treat GRB as the primary QA/QC reference where available. Use it for building/reference geometry validation and later for roads, water, and other base-map objects.
|
||||||
|
|
||||||
|
V1 integration strategy:
|
||||||
|
|
||||||
|
1. Implement a GRB provider abstraction.
|
||||||
|
2. Start with WFS or downloaded sample/cache depending on practical availability.
|
||||||
|
3. Normalize GRB features into a common `reference_features` model.
|
||||||
|
4. Compare AI detections against GRB with IoU/overlap metrics.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
GeoIntel becomes more relevant to real Flemish GeoAI workflows than a generic OSM-only demo. GRB validation becomes a portfolio killer feature.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
Do not block the entire build on live GRB integration. Provide fixtures and provider interfaces first, then connect real GRB when endpoint details are tested.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ADR-004 — Storage Strategy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
GeoIntel stores multiple artifact types: uploaded rasters, vector uploads, generated tiles, model outputs, masks, exports, and reports.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use local filesystem storage for V1 with a strict directory convention:
|
||||||
|
|
||||||
|
- `storage/uploads/` for original user uploads,
|
||||||
|
- `storage/originals/` for normalized source copies,
|
||||||
|
- `storage/tiles/` for generated raster tiles,
|
||||||
|
- `storage/masks/` for segmentation masks,
|
||||||
|
- `storage/derived/` for processed artifacts,
|
||||||
|
- `storage/exports/` for GeoJSON/COCO/YOLO exports,
|
||||||
|
- `storage/reports/` for reports,
|
||||||
|
- `storage/models/` for model artifacts.
|
||||||
|
|
||||||
|
Database rows reference files by relative path and content hash.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Simple local development and predictable repo behavior. Future MinIO/S3 migration remains possible because storage calls must go through a service boundary.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
No direct random file writes from routes or frontend-specific paths.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR-005 — AI Model Strategy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The vacancy emphasizes PyTorch, object detection, segmentation, and GeoAI. A portfolio build should show a real inference pipeline, not only AI text generation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use Ultralytics YOLO as the first object detection runtime because it is practical, PyTorch-based, well documented, and fast to integrate. Add segmentation through YOLO-seg or SAM after the detection pipeline is reliable.
|
||||||
|
|
||||||
|
Model execution must be wrapped behind `ModelRegistryService` and `DetectionService` interfaces so the UI and API do not depend directly on Ultralytics internals.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
GeoIntel can demonstrate model inference, georeferencing, output conversion, confidence thresholds, and QA/QC against GRB.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
Do not train a custom model in V1. Fine-tuning becomes V2/V3 after annotation and dataset export exist.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR-006 — Job Processing
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Raster tiling, detection, segmentation, QA, and exports can take longer than a normal HTTP request.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use Redis + RQ for V1 background jobs. Every long-running operation creates an `analysis_run` or `job` record, updates status, stores outputs, and emits events.
|
||||||
|
|
||||||
|
Supported statuses:
|
||||||
|
|
||||||
|
- pending,
|
||||||
|
- queued,
|
||||||
|
- running,
|
||||||
|
- completed,
|
||||||
|
- failed,
|
||||||
|
- cancelled.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
The UI can show progress and status without blocking. RQ is easier than Celery for an initial solo/portfolio project.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
No Kubernetes-native queues, no Airflow, no full workflow engine in V1.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ADR-007 — API Design
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted for V1.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The frontend must be API-driven and Codex must not invent inconsistent response shapes.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use REST-style FastAPI endpoints with typed Pydantic schemas. Responses use stable envelopes for long-running jobs and direct resources for simple CRUD operations.
|
||||||
|
|
||||||
|
Errors use a common structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "DATASET_NOT_FOUND",
|
||||||
|
"message": "Dataset not found.",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Frontend API clients, tests, and docs stay consistent.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
No GraphQL in V1.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
geointel_backend.egg-info
|
||||||
|
storage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG GEOINTEL_INSTALL_AI=false
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
gdal-bin \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgdal-dev \
|
||||||
|
libgeos-dev \
|
||||||
|
libproj-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libsm6 \
|
||||||
|
libx11-6 \
|
||||||
|
libxcb1 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender1 \
|
||||||
|
proj-bin \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md /app/
|
||||||
|
COPY app /app/app
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip setuptools
|
||||||
|
RUN extras=".[gis]" \
|
||||||
|
&& if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then extras=".[gis,ai]"; fi \
|
||||||
|
&& pip install --no-cache-dir "$extras"
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
RUN python scripts/gis_import_smoke.py
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+2035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+psycopg://geointel:geointel@localhost:5432/geointel
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
class_ = logging.Formatter
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.base import Base
|
||||||
|
import app.models.entities # noqa: F401
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
${message}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
${imports}
|
||||||
|
|
||||||
|
revision = ${repr(revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Initial PostGIS schema for Sprint 1 foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
revision = "202601110001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS postgis_topology")
|
||||||
|
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"projects",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("region", sa.Text(), nullable=False, server_default="Kempen"),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"areas",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("MULTIPOLYGON", srid=4326), nullable=False),
|
||||||
|
sa.Column("original_crs", sa.Text(), nullable=True),
|
||||||
|
sa.Column("area_m2", sa.Float(), nullable=True),
|
||||||
|
sa.Column("bbox", Geometry("POLYGON", srid=4326), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"datasets",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("area_id", sa.UUID(as_uuid=True), sa.ForeignKey("areas.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("dataset_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("source", sa.Text(), nullable=False),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("derived_from_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("crs", sa.Text(), nullable=True),
|
||||||
|
sa.Column("bounds_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("resolution_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("bands_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="created"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"dataset_versions",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"analysis_runs",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("area_id", sa.UUID(as_uuid=True), sa.ForeignKey("areas.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False),
|
||||||
|
sa.Column("parameters_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"exports",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("export_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_areas_geometry", "areas", ["geometry"], postgresql_using="gist")
|
||||||
|
op.create_index("ix_areas_project_id", "areas", ["project_id"])
|
||||||
|
op.create_index("ix_datasets_project_id", "datasets", ["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_datasets_project_id", table_name="datasets")
|
||||||
|
op.drop_index("ix_areas_project_id", table_name="areas")
|
||||||
|
op.drop_index("ix_areas_geometry", table_name="areas", postgresql_using="gist")
|
||||||
|
op.drop_table("exports")
|
||||||
|
op.drop_table("analysis_runs")
|
||||||
|
op.drop_table("dataset_versions")
|
||||||
|
op.drop_table("datasets")
|
||||||
|
op.drop_table("areas")
|
||||||
|
op.drop_table("projects")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add dataset storage metadata columns."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202601120001"
|
||||||
|
down_revision = "202601110001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("datasets", sa.Column("original_filename", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("stored_filename", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("content_type", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("size_bytes", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("checksum_sha256", sa.Text(), nullable=True))
|
||||||
|
op.alter_column("datasets", "status", server_default="uploaded")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("datasets", "checksum_sha256")
|
||||||
|
op.drop_column("datasets", "size_bytes")
|
||||||
|
op.drop_column("datasets", "content_type")
|
||||||
|
op.drop_column("datasets", "stored_filename")
|
||||||
|
op.drop_column("datasets", "original_filename")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add lightweight job table for sprint-3 async architecture foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260611212435"
|
||||||
|
down_revision = "202601120001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"jobs",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("job_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="queued"),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("input_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("output_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("parameters_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("result_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_jobs_project_id", "jobs", ["project_id"])
|
||||||
|
op.create_index("ix_jobs_status", "jobs", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_jobs_status", table_name="jobs")
|
||||||
|
op.drop_index("ix_jobs_project_id", table_name="jobs")
|
||||||
|
op.drop_table("jobs")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add dataset reference and provenance metadata columns."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120001"
|
||||||
|
down_revision = "20260611212435"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("datasets", sa.Column("dataset_role", sa.Text(), nullable=False, server_default="source"))
|
||||||
|
op.add_column("datasets", sa.Column("source_name", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("reference_layer_name", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("source_metadata", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("provenance_metadata", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("imported_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("datasets", "imported_at")
|
||||||
|
op.drop_column("datasets", "provenance_metadata")
|
||||||
|
op.drop_column("datasets", "source_metadata")
|
||||||
|
op.drop_column("datasets", "reference_layer_name")
|
||||||
|
op.drop_column("datasets", "source_name")
|
||||||
|
op.drop_column("datasets", "dataset_role")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Add Sprint 7A vector feature and QA persistence foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120700"
|
||||||
|
down_revision = "202606120001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"vector_features",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("feature_class", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_feature_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("properties_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("geometry", Geometry("GEOMETRY", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_vector_features_dataset_id", "vector_features", ["dataset_id"])
|
||||||
|
op.create_index("ix_vector_features_geometry", "vector_features", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"quality_checks",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("candidate_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("reference_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("check_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False),
|
||||||
|
sa.Column("score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("parameters_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("findings_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_quality_checks_project_id", "quality_checks", ["project_id"])
|
||||||
|
op.create_index("ix_quality_checks_reference_dataset_id", "quality_checks", ["reference_dataset_id"])
|
||||||
|
op.create_index("ix_quality_checks_candidate_dataset_id", "quality_checks", ["candidate_dataset_id"])
|
||||||
|
op.create_index("ix_quality_checks_analysis_run_id", "quality_checks", ["analysis_run_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"metrics",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("quality_check_id", sa.UUID(as_uuid=True), sa.ForeignKey("quality_checks.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("metric_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metric_value", sa.Float(), nullable=True),
|
||||||
|
sa.Column("metric_unit", sa.Text(), nullable=True),
|
||||||
|
sa.Column("label", sa.Text(), nullable=True),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_metrics_quality_check_id", "metrics", ["quality_check_id"])
|
||||||
|
op.create_index("ix_metrics_analysis_run_id", "metrics", ["analysis_run_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_metrics_analysis_run_id", table_name="metrics")
|
||||||
|
op.drop_index("ix_metrics_quality_check_id", table_name="metrics")
|
||||||
|
op.drop_table("metrics")
|
||||||
|
op.drop_index("ix_quality_checks_analysis_run_id", table_name="quality_checks")
|
||||||
|
op.drop_index("ix_quality_checks_candidate_dataset_id", table_name="quality_checks")
|
||||||
|
op.drop_index("ix_quality_checks_reference_dataset_id", table_name="quality_checks")
|
||||||
|
op.drop_index("ix_quality_checks_project_id", table_name="quality_checks")
|
||||||
|
op.drop_table("quality_checks")
|
||||||
|
op.drop_index("ix_vector_features_geometry", table_name="vector_features", postgresql_using="gist")
|
||||||
|
op.drop_index("ix_vector_features_dataset_id", table_name="vector_features")
|
||||||
|
op.drop_table("vector_features")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Add Sprint 8 detection foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120800"
|
||||||
|
down_revision = "202606120700"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("analysis_runs", sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("model_name", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("model_version", sa.String(length=120), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("result_json", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"detections",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("model_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("model_version", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("class_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("confidence", sa.Float(), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("GEOMETRY", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("bbox_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("source_tile_path", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("properties_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_detections_project_id", "detections", ["project_id"])
|
||||||
|
op.create_index("ix_detections_dataset_id", "detections", ["dataset_id"])
|
||||||
|
op.create_index("ix_detections_analysis_run_id", "detections", ["analysis_run_id"])
|
||||||
|
op.create_index("ix_detections_class_name", "detections", ["class_name"])
|
||||||
|
op.create_index("ix_detections_geometry", "detections", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_detections_geometry", table_name="detections", postgresql_using="gist")
|
||||||
|
op.drop_index("ix_detections_class_name", table_name="detections")
|
||||||
|
op.drop_index("ix_detections_analysis_run_id", table_name="detections")
|
||||||
|
op.drop_index("ix_detections_dataset_id", table_name="detections")
|
||||||
|
op.drop_index("ix_detections_project_id", table_name="detections")
|
||||||
|
op.drop_table("detections")
|
||||||
|
|
||||||
|
op.drop_column("analysis_runs", "created_at")
|
||||||
|
op.drop_column("analysis_runs", "result_json")
|
||||||
|
op.drop_column("analysis_runs", "model_version")
|
||||||
|
op.drop_column("analysis_runs", "model_name")
|
||||||
|
op.drop_column("analysis_runs", "job_id")
|
||||||
|
op.drop_column("analysis_runs", "dataset_id")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Add Sprint 9 segmentation foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120900"
|
||||||
|
down_revision = "202606120800"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"segmentations",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("model_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("model_version", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("class_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("confidence", sa.Float(), nullable=True),
|
||||||
|
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("bbox_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("area_m2", sa.Float(), nullable=True),
|
||||||
|
sa.Column("mask_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_tile_path", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("tile_index", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("properties_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("provenance_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_segmentations_project_id", "segmentations", ["project_id"])
|
||||||
|
op.create_index("ix_segmentations_dataset_id", "segmentations", ["dataset_id"])
|
||||||
|
op.create_index("ix_segmentations_analysis_run_id", "segmentations", ["analysis_run_id"])
|
||||||
|
op.create_index("ix_segmentations_job_id", "segmentations", ["job_id"])
|
||||||
|
op.create_index("ix_segmentations_class_name", "segmentations", ["class_name"])
|
||||||
|
op.create_index("ix_segmentations_geometry", "segmentations", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_segmentations_geometry", table_name="segmentations", postgresql_using="gist")
|
||||||
|
op.drop_index("ix_segmentations_class_name", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_job_id", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_analysis_run_id", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_dataset_id", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_project_id", table_name="segmentations")
|
||||||
|
op.drop_table("segmentations")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Add temporal dataset metadata and durable dataset-version provenance."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607140001"
|
||||||
|
down_revision = "202606120900"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("datasets", sa.Column("temporal_series_key", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("temporal_granularity", sa.String(length=32), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("source_version", sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("dataset_versions", sa.Column("source_version", sa.String(length=120), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("checksum_sha256", sa.String(length=64), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("source_metadata", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("provenance_metadata", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasets_project_temporal_series_observed",
|
||||||
|
"datasets",
|
||||||
|
["project_id", "temporal_series_key", "observed_at"],
|
||||||
|
)
|
||||||
|
op.create_index("ix_dataset_versions_dataset_version", "dataset_versions", ["dataset_id", "version"], unique=True)
|
||||||
|
op.create_index(
|
||||||
|
"ix_vector_features_dataset_source_feature",
|
||||||
|
"vector_features",
|
||||||
|
["dataset_id", "source_feature_id"],
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_datasets_temporal_valid_range",
|
||||||
|
"datasets",
|
||||||
|
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_dataset_versions_temporal_valid_range",
|
||||||
|
"dataset_versions",
|
||||||
|
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_dataset_versions_temporal_valid_range", "dataset_versions", type_="check")
|
||||||
|
op.drop_constraint("ck_datasets_temporal_valid_range", "datasets", type_="check")
|
||||||
|
op.drop_index("ix_vector_features_dataset_source_feature", table_name="vector_features")
|
||||||
|
op.drop_index("ix_dataset_versions_dataset_version", table_name="dataset_versions")
|
||||||
|
op.drop_index("ix_datasets_project_temporal_series_observed", table_name="datasets")
|
||||||
|
|
||||||
|
op.drop_column("dataset_versions", "provenance_metadata")
|
||||||
|
op.drop_column("dataset_versions", "source_metadata")
|
||||||
|
op.drop_column("dataset_versions", "checksum_sha256")
|
||||||
|
op.drop_column("dataset_versions", "valid_to")
|
||||||
|
op.drop_column("dataset_versions", "valid_from")
|
||||||
|
op.drop_column("dataset_versions", "observed_at")
|
||||||
|
op.drop_column("dataset_versions", "source_version")
|
||||||
|
|
||||||
|
op.drop_column("datasets", "source_version")
|
||||||
|
op.drop_column("datasets", "temporal_granularity")
|
||||||
|
op.drop_column("datasets", "valid_to")
|
||||||
|
op.drop_column("datasets", "valid_from")
|
||||||
|
op.drop_column("datasets", "observed_at")
|
||||||
|
op.drop_column("datasets", "temporal_series_key")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Add durable operator review decisions for detection QA evidence."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607150001"
|
||||||
|
down_revision = "202607140001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"detection_reviews",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("quality_check_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("analysis_run_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("evidence_role", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("evidence_feature_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("detection_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("reference_feature_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("decision", sa.String(length=64), server_default="unreviewed", nullable=False),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reviewed_by", sa.String(length=120), server_default="operator", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"evidence_role IN ('false_positive', 'false_negative')",
|
||||||
|
name="ck_detection_reviews_evidence_role",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"decision IN ('confirmed_model_false_positive', 'confirmed_model_false_negative', "
|
||||||
|
"'reference_gap_or_change', 'qa_alignment_mismatch', "
|
||||||
|
"'imagery_obscured_or_uncertain', 'uncertain', 'unreviewed')",
|
||||||
|
name="ck_detection_reviews_decision",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["analysis_run_id"], ["analysis_runs.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["detection_id"], ["detections.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["quality_check_id"], ["quality_checks.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["reference_feature_id"], ["vector_features.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"quality_check_id",
|
||||||
|
"evidence_role",
|
||||||
|
"evidence_feature_id",
|
||||||
|
name="uq_detection_reviews_evidence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_detection_reviews_project_id", "detection_reviews", ["project_id"])
|
||||||
|
op.create_index("ix_detection_reviews_quality_check_id", "detection_reviews", ["quality_check_id"])
|
||||||
|
op.create_index("ix_detection_reviews_analysis_run_id", "detection_reviews", ["analysis_run_id"])
|
||||||
|
op.create_index("ix_detection_reviews_decision", "detection_reviews", ["decision"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_detection_reviews_decision", table_name="detection_reviews")
|
||||||
|
op.drop_index("ix_detection_reviews_analysis_run_id", table_name="detection_reviews")
|
||||||
|
op.drop_index("ix_detection_reviews_quality_check_id", table_name="detection_reviews")
|
||||||
|
op.drop_index("ix_detection_reviews_project_id", table_name="detection_reviews")
|
||||||
|
op.drop_table("detection_reviews")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Index partitioned vector features by dataset and municipality."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607160001"
|
||||||
|
down_revision = "202607150001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"ix_vector_features_dataset_municipality",
|
||||||
|
"vector_features",
|
||||||
|
["dataset_id", sa.text("(properties_json ->> 'municipality')")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_vector_features_dataset_municipality", table_name="vector_features")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Add resumable AOI parent and partition operations."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607260001"
|
||||||
|
down_revision = "202607160001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"aoi_operations",
|
||||||
|
sa.Column("id", sa.UUID(), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("area_id", sa.UUID(), sa.ForeignKey("areas.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("parent_job_id", sa.UUID(), sa.ForeignKey("jobs.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("operation_type", sa.String(128), nullable=False),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("request_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("plan_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("result_json", sa.JSON()),
|
||||||
|
sa.Column("error_message", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.CheckConstraint("status IN ('queued', 'running', 'partial', 'success', 'failed', 'cancelled')", name="ck_aoi_operations_status"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_aoi_operations_project_status", "aoi_operations", ["project_id", "status"])
|
||||||
|
op.create_index("ix_aoi_operations_geometry", "aoi_operations", ["geometry"], postgresql_using="gist")
|
||||||
|
op.create_table(
|
||||||
|
"aoi_operation_partitions",
|
||||||
|
sa.Column("id", sa.UUID(), primary_key=True),
|
||||||
|
sa.Column("operation_id", sa.UUID(), sa.ForeignKey("aoi_operations.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("child_job_id", sa.UUID(), sa.ForeignKey("jobs.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("partition_key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("provider_key", sa.String(120), nullable=False),
|
||||||
|
sa.Column("product_key", sa.String(120), nullable=False),
|
||||||
|
sa.Column("ordinal", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
|
||||||
|
sa.Column("checkpoint_json", sa.JSON()),
|
||||||
|
sa.Column("result_json", sa.JSON()),
|
||||||
|
sa.Column("error_message", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.CheckConstraint("status IN ('queued', 'running', 'success', 'failed', 'skipped')", name="ck_aoi_operation_partitions_status"),
|
||||||
|
sa.UniqueConstraint("operation_id", "partition_key", name="uq_aoi_operation_partition_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_aoi_operation_partitions_operation_status", "aoi_operation_partitions", ["operation_id", "status"])
|
||||||
|
op.create_index("ix_aoi_operation_partitions_geometry", "aoi_operation_partitions", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("aoi_operation_partitions")
|
||||||
|
op.drop_table("aoi_operations")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
"""Configure the immutable model source registry for governed snapshots.
|
||||||
|
|
||||||
|
The phase-2 seed intentionally registered model artifacts as unknown. Runtime
|
||||||
|
model provenance now records exact immutable snapshots, so the server-owned
|
||||||
|
registry must advertise that configured capability. The write guard is only
|
||||||
|
disabled for this narrowly-scoped, versioned migration and is restored in the
|
||||||
|
same transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202608230001"
|
||||||
|
down_revision = "202608010001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_status(*, freshness_status: str, ingest_status: str) -> None:
|
||||||
|
op.execute("ALTER TABLE source_registry DISABLE TRIGGER trg_source_registry_write_guard")
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE source_registry
|
||||||
|
SET freshness_status = '{freshness_status}',
|
||||||
|
ingest_status = '{ingest_status}',
|
||||||
|
registry_metadata_json = (
|
||||||
|
registry_metadata_json::jsonb ||
|
||||||
|
'{{"runtime_model_contract": {{"key": "geointel.model.pytorch", "version": "1.0.0"}}}}'::jsonb
|
||||||
|
)::json,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE source_key = 'model'
|
||||||
|
AND registry_metadata_json ->> 'registry_owner' = 'server'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE source_registry ENABLE TRIGGER trg_source_registry_write_guard")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_set_status(freshness_status="current", ingest_status="configured")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_set_status(freshness_status="unknown", ingest_status="registered")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.models.entities import AnalysisRun, Area, Dataset, Export, Project
|
||||||
|
|
||||||
|
__all__ = ["AnalysisRun", "Area", "Dataset", "Export", "Project"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
__all__ = [
|
||||||
|
"analysis",
|
||||||
|
"areas",
|
||||||
|
"assistant",
|
||||||
|
"auth",
|
||||||
|
"datasets",
|
||||||
|
"exports",
|
||||||
|
"external",
|
||||||
|
"health",
|
||||||
|
"jobs",
|
||||||
|
"projects",
|
||||||
|
"qa",
|
||||||
|
"source_registry",
|
||||||
|
"temporal",
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.guest_scope import assert_guest_project_scope
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Dataset
|
||||||
|
from app.schemas import Envelope, JobRead
|
||||||
|
from app.schemas.analysis import ChangeDetectionRequest
|
||||||
|
from app.services.change_detection_service import ChangeDetectionService
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-detection", response_model=Envelope[JobRead])
|
||||||
|
def run_change_detection(
|
||||||
|
payload: ChangeDetectionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
source_dataset = db.get(Dataset, payload.source_dataset_id)
|
||||||
|
if not source_dataset:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404)
|
||||||
|
assert_guest_project_scope(request, source_dataset.project_id)
|
||||||
|
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
|
||||||
|
ChangeDetectionService._get_project_vector_dataset(db, payload.target_dataset_id, source_dataset.project_id, "Target")
|
||||||
|
job = JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=source_dataset.project_id,
|
||||||
|
job_type="analysis.change-detection",
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
input_dataset_id=payload.source_dataset_id,
|
||||||
|
operation=lambda: ChangeDetectionService.compare_vector_datasets(
|
||||||
|
db=db,
|
||||||
|
project_id=source_dataset.project_id,
|
||||||
|
source_dataset_id=payload.source_dataset_id,
|
||||||
|
target_dataset_id=payload.target_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
modified_threshold=payload.modified_threshold,
|
||||||
|
include_unchanged=payload.include_unchanged,
|
||||||
|
bbox=payload.bbox.model_dump() if payload.bbox is not None else None,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
preview_limit=payload.preview_limit,
|
||||||
|
).model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
return envelope(job)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas.aoi_operation import AoiOperationCreate, AoiOperationList, AoiOperationRead, AoiPartitionCheckpoint, AoiPartitionComplete, AoiPartitionFail, AoiPartitionRead
|
||||||
|
from app.schemas.common import Envelope
|
||||||
|
from app.services.aoi_operation_service import AoiOperationService
|
||||||
|
from app.services.aoi_operation_executor import AoiOperationExecutor
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/aoi-operations", tags=["aoi-operations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201, response_model=Envelope[AoiOperationRead])
|
||||||
|
def create_operation(project_id: UUID, payload: AoiOperationCreate, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.create(db, project_id, payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Envelope[AoiOperationList])
|
||||||
|
def list_operations(project_id: UUID, limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.list(db, project_id, limit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{operation_id}", response_model=Envelope[AoiOperationRead])
|
||||||
|
def read_operation(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.read(db, project_id, operation_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/partitions/claim", response_model=Envelope[AoiPartitionRead | None])
|
||||||
|
def claim_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
partition = AoiOperationService.claim_next(db, project_id, operation_id)
|
||||||
|
return envelope(AoiPartitionRead.model_validate(partition).model_dump() if partition else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/execute-next", response_model=Envelope[AoiOperationRead])
|
||||||
|
def execute_next_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationExecutor.execute_next(db, project_id, operation_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{operation_id}/partitions/{partition_id}/checkpoint", response_model=Envelope[AoiPartitionRead])
|
||||||
|
def checkpoint_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionCheckpoint, db: Session = Depends(get_db)):
|
||||||
|
partition = AoiOperationService.checkpoint(db, project_id, operation_id, partition_id, payload.checkpoint_json)
|
||||||
|
return envelope(AoiPartitionRead.model_validate(partition).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/partitions/{partition_id}/complete", response_model=Envelope[AoiOperationRead])
|
||||||
|
def complete_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionComplete, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.complete(db, project_id, operation_id, partition_id, payload.result_json, payload.skipped))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/partitions/{partition_id}/fail", response_model=Envelope[AoiOperationRead])
|
||||||
|
def fail_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionFail, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.fail(db, project_id, operation_id, partition_id, payload.error_message, payload.retryable, payload.details))
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Area
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
|
||||||
|
from app.services.area_service import AreaService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Envelope[AreaList])
|
||||||
|
def list_areas(
|
||||||
|
project_id: UUID,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
|
||||||
|
return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201, response_model=Envelope[AreaRead])
|
||||||
|
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
|
||||||
|
area = AreaService.create_area(db, project_id, payload)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
|
||||||
|
def search_municipalities(
|
||||||
|
project_id: UUID,
|
||||||
|
query: str = Query(default="", max_length=120),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items, total = AreaService.search_municipalities(db, project_id, query, limit)
|
||||||
|
return envelope({"items": items, "total": total})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
|
||||||
|
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
|
||||||
|
area = AreaService.activate_municipality(db, project_id, niscode)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{area_id}", response_model=Envelope[AreaRead])
|
||||||
|
def get_area(
|
||||||
|
project_id: UUID,
|
||||||
|
area_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
area = AreaService.get_area(db, area_id)
|
||||||
|
if area.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Area not found")
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{area_id}", response_model=Envelope[AreaRead])
|
||||||
|
def update_area(
|
||||||
|
project_id: UUID,
|
||||||
|
area_id: UUID,
|
||||||
|
payload: AreaUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = db.get(Area, area_id)
|
||||||
|
if not existing or existing.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Area not found")
|
||||||
|
area = AreaService.update_area(db, area_id, payload)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.assistant import (
|
||||||
|
AssistantModelList,
|
||||||
|
AssistantQueryRequest,
|
||||||
|
AssistantQueryResponse,
|
||||||
|
AssistantStatus,
|
||||||
|
)
|
||||||
|
from app.services.geo_assistant_service import GeoAssistantService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["assistant"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assistant/status", response_model=Envelope[AssistantStatus])
|
||||||
|
def assistant_status() -> dict:
|
||||||
|
return envelope(GeoAssistantService().status().model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assistant/models", response_model=Envelope[AssistantModelList])
|
||||||
|
def assistant_models() -> dict:
|
||||||
|
service = GeoAssistantService()
|
||||||
|
models = service.list_models()
|
||||||
|
return envelope(
|
||||||
|
{
|
||||||
|
"items": [model.model_dump() for model in models],
|
||||||
|
"total": len(models),
|
||||||
|
"default_model": service.settings.ollama_default_model,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/assistant/query",
|
||||||
|
response_model=Envelope[AssistantQueryResponse],
|
||||||
|
)
|
||||||
|
def assistant_query(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: AssistantQueryRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(GeoAssistantService().query(db, project_id=project_id, payload=payload).model_dump())
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
|
||||||
|
from app.services.auth_service import AuthPrincipal, AuthService
|
||||||
|
from app.services.authentik_oidc_service import AuthentikOidcService
|
||||||
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
COOKIE_NAME = "geointel_session"
|
||||||
|
OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow"
|
||||||
|
logger = logging.getLogger("geointel.auth")
|
||||||
|
_TRUSTED_PROXY_NETWORKS = (
|
||||||
|
ip_network("127.0.0.0/8"),
|
||||||
|
ip_network("::1/128"),
|
||||||
|
ip_network("172.16.0.0/12"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_is_trusted_proxy(request: Request) -> bool:
|
||||||
|
if request.client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
peer_address = ip_address(request.client.host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(peer_address in network for network in _TRUSTED_PROXY_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_is_https(request: Request) -> bool:
|
||||||
|
if request.url.scheme == "https":
|
||||||
|
return True
|
||||||
|
if not _peer_is_trusted_proxy(request):
|
||||||
|
return False
|
||||||
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||||||
|
return forwarded_proto == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _client_host(request: Request) -> str:
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if not _peer_is_trusted_proxy(request):
|
||||||
|
return peer
|
||||||
|
forwarded = request.headers.get("x-real-ip", "").strip()
|
||||||
|
if not forwarded:
|
||||||
|
return peer
|
||||||
|
try:
|
||||||
|
return str(ip_address(forwarded))
|
||||||
|
except ValueError:
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
def _session_from_principal(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
*,
|
||||||
|
guest_access_enabled: bool,
|
||||||
|
authentik_enabled: bool,
|
||||||
|
) -> AuthSession:
|
||||||
|
return AuthSession(
|
||||||
|
authentication_required=True,
|
||||||
|
authenticated=True,
|
||||||
|
username=principal.username,
|
||||||
|
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
||||||
|
role=principal.role,
|
||||||
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
|
guest_project_id=principal.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_payload(request: Request) -> AuthSession:
|
||||||
|
settings = get_settings()
|
||||||
|
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
|
||||||
|
authentik_enabled = AuthentikOidcService(settings).enabled
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
return AuthSession(
|
||||||
|
authentication_required=False,
|
||||||
|
authenticated=True,
|
||||||
|
guest_access_enabled=False,
|
||||||
|
authentik_enabled=False,
|
||||||
|
)
|
||||||
|
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
|
||||||
|
if principal is None:
|
||||||
|
return AuthSession(
|
||||||
|
authentication_required=True,
|
||||||
|
authenticated=False,
|
||||||
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
|
)
|
||||||
|
return _session_from_principal(
|
||||||
|
principal,
|
||||||
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
token: str,
|
||||||
|
max_age: int,
|
||||||
|
) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
secure=_request_is_https(request),
|
||||||
|
samesite="strict",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=AuthSessionEnvelope)
|
||||||
|
def session(request: Request) -> AuthSessionEnvelope:
|
||||||
|
return AuthSessionEnvelope(data=_session_payload(request))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthSessionEnvelope)
|
||||||
|
def login(payload: AuthLoginRequest, request: Request, response: Response) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
raise AppError(
|
||||||
|
code="AUTHENTICATION_DISABLED",
|
||||||
|
message="Operator authentication is not enabled on this runtime",
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
if settings.auth_require_https and not _request_is_https(request):
|
||||||
|
raise AppError(
|
||||||
|
code="AUTH_HTTPS_REQUIRED",
|
||||||
|
message="Operator authentication requires HTTPS on this runtime",
|
||||||
|
status_code=status.HTTP_426_UPGRADE_REQUIRED,
|
||||||
|
)
|
||||||
|
client_host = _client_host(request)
|
||||||
|
throttle_key = f"{client_host}:{payload.username.casefold()}"
|
||||||
|
retry_after = AuthService.retry_after_seconds(throttle_key)
|
||||||
|
if retry_after:
|
||||||
|
raise AppError(
|
||||||
|
code="LOGIN_RATE_LIMITED",
|
||||||
|
message="Te veel mislukte aanmeldpogingen. Probeer later opnieuw.",
|
||||||
|
details={"retry_after_seconds": retry_after},
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
if not AuthService.credentials_match(payload.username, payload.password, settings):
|
||||||
|
AuthService.record_failure(throttle_key)
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_CREDENTIALS",
|
||||||
|
message="Gebruikersnaam of wachtwoord is onjuist.",
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
AuthService.clear_failures(throttle_key)
|
||||||
|
token = AuthService.create_session_token(payload.username, settings)
|
||||||
|
principal = AuthService.verify_session_token(token, settings)
|
||||||
|
if principal is None: # pragma: no cover - defensive invariant
|
||||||
|
raise AppError(
|
||||||
|
code="SESSION_CREATION_FAILED",
|
||||||
|
message="De beveiligde sessie kon niet worden aangemaakt.",
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.auth_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
return AuthSessionEnvelope(
|
||||||
|
data=_session_from_principal(
|
||||||
|
principal,
|
||||||
|
guest_access_enabled=settings.guest_access_enabled,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authentik/start")
|
||||||
|
def authentik_start(request: Request) -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
service = AuthentikOidcService(settings)
|
||||||
|
try:
|
||||||
|
location, flow = service.start()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Authentik authorization start failed: %s", type(exc).__name__)
|
||||||
|
raise AppError(
|
||||||
|
code="AUTHENTIK_UNAVAILABLE",
|
||||||
|
message="Authentik is momenteel niet beschikbaar.",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
) from exc
|
||||||
|
response = RedirectResponse(location, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
OIDC_FLOW_COOKIE_NAME,
|
||||||
|
flow,
|
||||||
|
max_age=600,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path=f"{settings.api_prefix}/auth/authentik",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authentik/callback")
|
||||||
|
def authentik_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = "",
|
||||||
|
state: str = "",
|
||||||
|
) -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
service = AuthentikOidcService(settings)
|
||||||
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
|
try:
|
||||||
|
service.finish(
|
||||||
|
code=code,
|
||||||
|
state=state,
|
||||||
|
flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""),
|
||||||
|
)
|
||||||
|
token = AuthService.create_session_token(
|
||||||
|
settings.auth_username or "operator",
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Authentik callback rejected: %s", type(exc).__name__)
|
||||||
|
response = RedirectResponse(
|
||||||
|
f"{base_url}/?authentik=error",
|
||||||
|
status_code=status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = RedirectResponse(
|
||||||
|
f"{base_url}/",
|
||||||
|
status_code=status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.auth_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
response.delete_cookie(
|
||||||
|
OIDC_FLOW_COOKIE_NAME,
|
||||||
|
path=f"{settings.api_prefix}/auth/authentik",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/guest", response_model=AuthSessionEnvelope)
|
||||||
|
def guest_login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled or not settings.guest_access_enabled:
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_ACCESS_DISABLED",
|
||||||
|
message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.",
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
client_host = _client_host(request)
|
||||||
|
retry_after = AuthService.consume_guest_request(
|
||||||
|
f"guest-login:{client_host}",
|
||||||
|
max_requests=settings.guest_login_requests_per_minute,
|
||||||
|
)
|
||||||
|
if retry_after:
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_LOGIN_RATE_LIMITED",
|
||||||
|
message="Too many guest sessions were requested. Try again later.",
|
||||||
|
details={"retry_after_seconds": retry_after},
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
demo = DemoWorkflowService.seed(db)
|
||||||
|
token = AuthService.create_session_token(
|
||||||
|
settings.guest_display_name,
|
||||||
|
settings,
|
||||||
|
role="guest",
|
||||||
|
project_id=demo.project_id,
|
||||||
|
ttl_seconds=settings.guest_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
principal = AuthService.verify_session_token(token, settings)
|
||||||
|
if principal is None: # pragma: no cover - defensive invariant
|
||||||
|
raise AppError(
|
||||||
|
code="SESSION_CREATION_FAILED",
|
||||||
|
message="De tijdelijke gastensessie kon niet worden aangemaakt.",
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.guest_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
return AuthSessionEnvelope(
|
||||||
|
data=_session_from_principal(
|
||||||
|
principal,
|
||||||
|
guest_access_enabled=True,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=AuthSessionEnvelope)
|
||||||
|
def logout(response: Response) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
|
||||||
|
return AuthSessionEnvelope(
|
||||||
|
data=AuthSession(
|
||||||
|
authentication_required=settings.auth_enabled,
|
||||||
|
authenticated=not settings.auth_enabled,
|
||||||
|
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.demo import DemoWorkflowResponse
|
||||||
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/demo", tags=["demo"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/workflow",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
response_model=Envelope[DemoWorkflowResponse],
|
||||||
|
)
|
||||||
|
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
|
||||||
|
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
|
||||||
|
return envelope(result.model_dump())
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
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,
|
||||||
|
DetectionListResponse,
|
||||||
|
DetectionModelsResponse,
|
||||||
|
DetectionComparisonRequest,
|
||||||
|
DetectionComparisonResponse,
|
||||||
|
DetectionQaRequest,
|
||||||
|
DetectionRead,
|
||||||
|
DetectionRunListResponse,
|
||||||
|
DetectionRunRead,
|
||||||
|
DetectionRunRequest,
|
||||||
|
DetectionRunResponse,
|
||||||
|
Envelope,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
JobRead,
|
||||||
|
ModelAssetListResponse,
|
||||||
|
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
|
||||||
|
from app.services.yolo_preflight_service import YoloPreflightService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/detection", tags=["detection"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=Envelope[DetectionModelsResponse])
|
||||||
|
def list_detection_models() -> dict:
|
||||||
|
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-assets", response_model=Envelope[ModelAssetListResponse])
|
||||||
|
def list_detection_model_assets() -> dict:
|
||||||
|
return envelope(ModelAssetCatalogService.list_assets().model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse])
|
||||||
|
def get_yolo_preflight(
|
||||||
|
tile_manifest_path: str | None = None,
|
||||||
|
check_model_load: bool = False,
|
||||||
|
model_asset_id: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
YoloPreflightService.run(
|
||||||
|
tile_manifest_path=tile_manifest_path,
|
||||||
|
check_model_load=check_model_load,
|
||||||
|
model_asset_id=model_asset_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_model=Envelope[DetectionRunResponse])
|
||||||
|
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,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
model_asset_id=payload.model_asset_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run-async", response_model=Envelope[JobRead])
|
||||||
|
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
|
||||||
|
inside the request and is only appropriate for a handful of tiles. Poll
|
||||||
|
``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,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
model_asset_id=payload.model_asset_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
|
||||||
|
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(
|
||||||
|
"/runs/{analysis_run_id}/detections",
|
||||||
|
response_model=Envelope[DetectionListResponse],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/detections",
|
||||||
|
response_model=Envelope[DetectionListResponse],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
|
||||||
|
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(
|
||||||
|
"/runs/{analysis_run_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
def get_detection_run_geojson(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/compare", response_model=Envelope[DetectionComparisonResponse])
|
||||||
|
def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Rank several runs against one reference on average precision.
|
||||||
|
|
||||||
|
The workbench ranks model variants by a stored F1 measured at each
|
||||||
|
variant's own confidence threshold, which orders the thresholds as much as
|
||||||
|
the models. Average precision describes the whole ranking a model produced.
|
||||||
|
Comparability is reported first: runs over different rasters, different
|
||||||
|
references or different inference coverage are not alternatives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return envelope(
|
||||||
|
DetectionComparisonService.compare_runs(
|
||||||
|
db,
|
||||||
|
analysis_run_ids=payload.analysis_run_ids,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/runs/{analysis_run_id}/qa/reference",
|
||||||
|
response_model=Envelope[AnalysisQaResponse],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
class_name=payload.class_name,
|
||||||
|
min_confidence=payload.min_confidence,
|
||||||
|
calibration_thresholds=payload.calibration_thresholds,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
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
|
||||||
|
from app.schemas.export import (
|
||||||
|
ExportContentResponse,
|
||||||
|
ExportCreateResponse,
|
||||||
|
ExportListResponse,
|
||||||
|
ExportRead,
|
||||||
|
GeoJsonExportRequest,
|
||||||
|
MapResultExportRequest,
|
||||||
|
MetadataExportRequest,
|
||||||
|
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,
|
||||||
|
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(
|
||||||
|
db,
|
||||||
|
payload.dataset_id,
|
||||||
|
payload.bbox.model_dump(),
|
||||||
|
area_id=payload.area_id,
|
||||||
|
limit=payload.limit,
|
||||||
|
name=payload.name,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
|
||||||
|
return envelope(
|
||||||
|
ExportService.export_detection_run_geojson(
|
||||||
|
db,
|
||||||
|
payload.analysis_run_id,
|
||||||
|
payload.name,
|
||||||
|
intended_use=payload.intended_use,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
if payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
|
||||||
|
return envelope(
|
||||||
|
ExportService.export_segmentation_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
if payload.dataset_id is not None:
|
||||||
|
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_EXPORT_REQUEST",
|
||||||
|
message="GeoJSON export request does not match any supported export target",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/exports",
|
||||||
|
response_model=Envelope[ExportListResponse],
|
||||||
|
)
|
||||||
|
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, 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, 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, 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"))
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Area, Project
|
||||||
|
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
|
||||||
|
from app.schemas import (
|
||||||
|
CoverageCatalogResponse,
|
||||||
|
CoverageResolveRequest,
|
||||||
|
CoverageResolveResponse,
|
||||||
|
Envelope,
|
||||||
|
ExternalFetchRequest,
|
||||||
|
ExternalFetchResponse,
|
||||||
|
ProviderCapabilitiesResponse,
|
||||||
|
ProviderCapabilityResponse,
|
||||||
|
ProviderImportRequest,
|
||||||
|
ProviderImportResponse,
|
||||||
|
ProviderLayersResponse,
|
||||||
|
ProviderStatusResponse,
|
||||||
|
)
|
||||||
|
from app.services.coverage_registry_service import CoverageRegistryService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/external", tags=["external"])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_area_in_project(db: Session, project_id, area_id: str | None) -> None:
|
||||||
|
if area_id is None:
|
||||||
|
return
|
||||||
|
area = db.get(Area, area_id)
|
||||||
|
if not area:
|
||||||
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||||
|
if area.project_id != project_id:
|
||||||
|
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_project_exists(db: Session, project_id):
|
||||||
|
project = db.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_guest_project_scope(request: Request, project_id) -> None:
|
||||||
|
principal = getattr(request.state, "auth_principal", None)
|
||||||
|
if (
|
||||||
|
getattr(principal, "role", None) == "guest"
|
||||||
|
and getattr(principal, "project_id", None) != project_id
|
||||||
|
):
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||||
|
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
|
||||||
|
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_payload(provider_name: str) -> dict:
|
||||||
|
return get_provider(provider_name).capability.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse])
|
||||||
|
def list_external_providers() -> dict:
|
||||||
|
return envelope({
|
||||||
|
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/coverage/catalog", response_model=Envelope[CoverageCatalogResponse])
|
||||||
|
def get_coverage_catalog() -> dict:
|
||||||
|
return envelope(CoverageRegistryService.catalog().model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
|
||||||
|
def resolve_project_coverage(
|
||||||
|
payload: CoverageResolveRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_assert_guest_project_scope(request, payload.project_id)
|
||||||
|
result = CoverageRegistryService.resolve(
|
||||||
|
db,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
bbox=payload.bbox,
|
||||||
|
themes=payload.themes,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/capabilities",
|
||||||
|
response_model=Envelope[ProviderCapabilitiesResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider_capabilities() -> dict:
|
||||||
|
return envelope({
|
||||||
|
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_name}",
|
||||||
|
response_model=Envelope[ProviderCapabilityResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider(provider_name: str) -> dict:
|
||||||
|
return envelope(_provider_payload(provider_name))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_name}/layers",
|
||||||
|
response_model=Envelope[ProviderLayersResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider_layers(provider_name: str) -> dict:
|
||||||
|
provider = get_provider(provider_name)
|
||||||
|
return envelope({
|
||||||
|
"provider_name": provider.provider_name,
|
||||||
|
"layers": provider.supported_layers,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_name}/status",
|
||||||
|
response_model=Envelope[ProviderStatusResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider_status(provider_name: str) -> dict:
|
||||||
|
provider = get_provider(provider_name)
|
||||||
|
return envelope({
|
||||||
|
"provider_name": provider.provider_name,
|
||||||
|
"configured": provider.is_configured,
|
||||||
|
"status": provider.capability.status,
|
||||||
|
"limitation_message": provider.limitation_message,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider_name}/import",
|
||||||
|
response_model=Envelope[ProviderImportResponse],
|
||||||
|
)
|
||||||
|
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
|
||||||
|
result = import_provider_dataset(
|
||||||
|
provider_name=provider_name,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
layers=_normalize_layer_input(payload.layers),
|
||||||
|
requested_dataset_role=payload.dataset_role,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFetchResponse:
|
||||||
|
area_id_str = str(payload.area_id) if payload.area_id else None
|
||||||
|
response = fetch_provider_data(
|
||||||
|
provider_name=provider_name,
|
||||||
|
project_id=str(payload.project_id),
|
||||||
|
area_id=area_id_str,
|
||||||
|
layers=_normalize_layer_input(payload.layers),
|
||||||
|
)
|
||||||
|
return ExternalFetchResponse(
|
||||||
|
provider=provider_name,
|
||||||
|
status=response.get("status", "not_configured"),
|
||||||
|
message=response.get("message", "Provider fetch executed."),
|
||||||
|
requested_layers=_normalize_layer_input(payload.layers),
|
||||||
|
project_id=payload.project_id,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/osm/fetch", response_model=Envelope[ExternalFetchResponse])
|
||||||
|
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
_assert_project_exists(db, payload.project_id)
|
||||||
|
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||||
|
return envelope(_run_fetch(payload, "osm").model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/grb/fetch", response_model=Envelope[ExternalFetchResponse])
|
||||||
|
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
_assert_project_exists(db, payload.project_id)
|
||||||
|
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||||
|
return envelope(_run_fetch(payload, "grb").model_dump())
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from fastapi import APIRouter, Response, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.session import get_engine
|
||||||
|
from app.providers.registry import list_provider_capabilities
|
||||||
|
from app.schemas.health import (
|
||||||
|
HealthResponse,
|
||||||
|
SystemCapabilities,
|
||||||
|
SystemCapabilitiesEnvelope,
|
||||||
|
)
|
||||||
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_enabled(module: str) -> bool:
|
||||||
|
try:
|
||||||
|
import_module(module)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_migration_heads() -> list[str]:
|
||||||
|
backend_root = Path(__file__).resolve().parents[3]
|
||||||
|
config = Config(str(backend_root / "alembic.ini"))
|
||||||
|
config.set_main_option("script_location", str(backend_root / "alembic"))
|
||||||
|
return list(ScriptDirectory.from_config(config).get_heads())
|
||||||
|
|
||||||
|
|
||||||
|
def _database_checks() -> dict[str, str]:
|
||||||
|
checks = {
|
||||||
|
"database": "degraded",
|
||||||
|
"postgis": "degraded",
|
||||||
|
"migration": "degraded",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with get_engine().connect() as connection:
|
||||||
|
connection.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT PostGIS_Version()")
|
||||||
|
).scalar_one()
|
||||||
|
checks["postgis"] = "ok"
|
||||||
|
database_head = connection.execute(
|
||||||
|
text("SELECT version_num FROM alembic_version")
|
||||||
|
).scalar_one()
|
||||||
|
expected_heads = _expected_migration_heads()
|
||||||
|
if len(expected_heads) == 1 and database_head == expected_heads[0]:
|
||||||
|
checks["migration"] = "ok"
|
||||||
|
else:
|
||||||
|
checks["migration"] = "degraded"
|
||||||
|
except Exception:
|
||||||
|
return checks
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_check(storage_root: str) -> str:
|
||||||
|
root = Path(storage_root).expanduser()
|
||||||
|
try:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
with NamedTemporaryFile(
|
||||||
|
prefix=".geointel-readiness-",
|
||||||
|
dir=root,
|
||||||
|
delete=True,
|
||||||
|
) as handle:
|
||||||
|
handle.write(b"ok")
|
||||||
|
handle.flush()
|
||||||
|
return "ok"
|
||||||
|
except OSError:
|
||||||
|
return "degraded"
|
||||||
|
|
||||||
|
|
||||||
|
def _readiness_payload() -> HealthResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
checks = _database_checks()
|
||||||
|
checks["storage"] = _storage_check(settings.storage_root)
|
||||||
|
ready = all(
|
||||||
|
value == "ok" or value.startswith("ok:")
|
||||||
|
for value in checks.values()
|
||||||
|
)
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok" if ready else "degraded",
|
||||||
|
service="geointel-backend",
|
||||||
|
version="public",
|
||||||
|
database=checks["database"],
|
||||||
|
postgis=checks["postgis"],
|
||||||
|
migration=checks["migration"],
|
||||||
|
storage=checks["storage"],
|
||||||
|
checks=checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/live", response_model=HealthResponse)
|
||||||
|
def liveness() -> HealthResponse:
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok",
|
||||||
|
service="geointel-backend",
|
||||||
|
version="public",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _readiness_response(response: Response) -> HealthResponse:
|
||||||
|
payload = _readiness_payload()
|
||||||
|
if payload.status != "ok":
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
def readiness(response: Response) -> HealthResponse:
|
||||||
|
return _readiness_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready", response_model=HealthResponse)
|
||||||
|
def readiness_explicit(response: Response) -> HealthResponse:
|
||||||
|
return _readiness_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/capabilities",
|
||||||
|
response_model=SystemCapabilitiesEnvelope,
|
||||||
|
)
|
||||||
|
def capabilities() -> SystemCapabilitiesEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
providers = [item.to_dict() for item in list_provider_capabilities()]
|
||||||
|
configured_yolo = ModelRegistryService.get_model_capability(
|
||||||
|
settings.yolo_model_id,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
yolo_configured = bool(configured_yolo and configured_yolo.configured)
|
||||||
|
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
|
||||||
|
configured_sam = ModelRegistryService.get_model_capability(
|
||||||
|
settings.sam_model_id,
|
||||||
|
settings=settings,
|
||||||
|
task_type="segmentation",
|
||||||
|
)
|
||||||
|
postgis_ready = _database_checks()["postgis"].startswith("ok:")
|
||||||
|
return SystemCapabilitiesEnvelope(
|
||||||
|
data=SystemCapabilities(
|
||||||
|
postgis=postgis_ready,
|
||||||
|
rasterio=_dependency_enabled("rasterio"),
|
||||||
|
geopandas=_dependency_enabled("geopandas"),
|
||||||
|
yolo=yolo_configured,
|
||||||
|
yolo_status=yolo_status,
|
||||||
|
sam=bool(configured_sam and configured_sam.configured),
|
||||||
|
grb="bounded",
|
||||||
|
sentinel="planned",
|
||||||
|
version=settings.app_version,
|
||||||
|
build_sha=settings.build_sha,
|
||||||
|
providers=providers,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope, JobCreate, JobList, JobRead, JobStatus
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs", status_code=201, response_model=Envelope[JobRead])
|
||||||
|
def create_job(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: JobCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if payload.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=400, detail="project_id mismatch")
|
||||||
|
return envelope(JobService.create_job(db, payload).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=Envelope[JobList])
|
||||||
|
def list_jobs(
|
||||||
|
project_id: UUID,
|
||||||
|
dataset_id: UUID | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items, total = JobService.list_jobs(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", response_model=Envelope[JobRead])
|
||||||
|
def read_job(
|
||||||
|
project_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
job = JobService.get_job(db, job_id)
|
||||||
|
if job.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return envelope(job.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/status", response_model=Envelope[JobStatus])
|
||||||
|
def read_job_status(
|
||||||
|
project_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
status_row = JobService.get_job_status(db, job_id)
|
||||||
|
if status_row["project_id"] != str(project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return envelope(JobStatus(**status_row).model_dump())
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
|
||||||
|
from app.services.project_service import ProjectService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Envelope[ProjectList])
|
||||||
|
def list_projects(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
name: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
principal = getattr(request.state, "auth_principal", None)
|
||||||
|
if principal is not None and principal.role == "guest":
|
||||||
|
project = ProjectService.get_project(db, principal.project_id)
|
||||||
|
status_matches = bool(
|
||||||
|
project is not None
|
||||||
|
and (project_status == "all" or project.status == project_status)
|
||||||
|
)
|
||||||
|
name_matches = bool(
|
||||||
|
project is not None
|
||||||
|
and (name is None or name.casefold() in project.name.casefold())
|
||||||
|
)
|
||||||
|
matches = project is not None and status_matches and name_matches
|
||||||
|
visible = [project] if matches and offset == 0 else []
|
||||||
|
return envelope(
|
||||||
|
{
|
||||||
|
"items": [ProjectRead.model_validate(item).model_dump() for item in visible[:limit]],
|
||||||
|
"total": 1 if matches else 0,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
projects, total = ProjectService.list_projects(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
name=name,
|
||||||
|
project_status=project_status,
|
||||||
|
)
|
||||||
|
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED, response_model=Envelope[ProjectRead])
|
||||||
|
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
|
||||||
|
project = ProjectService.create_project(db, payload)
|
||||||
|
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}", response_model=Envelope[ProjectRead])
|
||||||
|
def get_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
project = ProjectService.get_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{project_id}", response_model=Envelope[ProjectRead])
|
||||||
|
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
|
||||||
|
project = ProjectService.update_project(db, project_id, payload)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
response_model=Envelope[ProjectDeleteResult],
|
||||||
|
)
|
||||||
|
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
if not ProjectService.delete_project(db, project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return envelope({"deleted": True})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models import Dataset, Job
|
||||||
|
from app.schemas import Envelope, JobRead, QaProviderComparisonRequest
|
||||||
|
from app.services.qa_service import QaService
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.services.quality_service import QualityService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/qa", tags=["qa"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/detections-vs-reference", response_model=Envelope[JobRead])
|
||||||
|
def compare_candidate_with_reference(
|
||||||
|
payload: QaProviderComparisonRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
candidate_dataset = db.get(Dataset, payload.candidate_dataset_id)
|
||||||
|
if not candidate_dataset:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Candidate dataset not found", status_code=404)
|
||||||
|
job = JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=candidate_dataset.project_id,
|
||||||
|
job_type="qa.compare-candidate-with-reference",
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
input_dataset_id=candidate_dataset.id,
|
||||||
|
operation=lambda: QaService.compare_candidate_with_reference(
|
||||||
|
db=db,
|
||||||
|
project_id=candidate_dataset.project_id,
|
||||||
|
candidate_dataset_id=payload.candidate_dataset_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
).model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
result_json = job.get("result_json") if isinstance(job, dict) else None
|
||||||
|
if isinstance(result_json, dict) and job.get("status") == "success":
|
||||||
|
quality_check = QualityService.persist_quality_check(
|
||||||
|
db=db,
|
||||||
|
project_id=candidate_dataset.project_id,
|
||||||
|
job_id=uuid.UUID(str(job["id"])),
|
||||||
|
candidate_dataset_id=payload.candidate_dataset_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
check_type="candidate_vs_reference",
|
||||||
|
status=str(result_json.get("status", "ok")),
|
||||||
|
score=result_json.get("f1_score"),
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
findings={
|
||||||
|
"matches": result_json.get("matches"),
|
||||||
|
"false_positives": result_json.get("false_positives"),
|
||||||
|
"false_negatives": result_json.get("false_negatives"),
|
||||||
|
"warnings": result_json.get("warnings", []),
|
||||||
|
"unsupported_geometry": result_json.get("unsupported_geometry", False),
|
||||||
|
"unsupported_geometries": result_json.get("unsupported_geometries", []),
|
||||||
|
"match_evidence": result_json.get("match_evidence", []),
|
||||||
|
"false_positive_evidence": result_json.get("false_positive_evidence", []),
|
||||||
|
"false_negative_evidence": result_json.get("false_negative_evidence", []),
|
||||||
|
},
|
||||||
|
metrics={
|
||||||
|
"precision": result_json.get("precision"),
|
||||||
|
"recall": result_json.get("recall"),
|
||||||
|
"f1": result_json.get("f1_score"),
|
||||||
|
"mean_iou": result_json.get("mean_iou"),
|
||||||
|
"false_positive_count": result_json.get("false_positives"),
|
||||||
|
"false_negative_count": result_json.get("false_negatives"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result_json["quality_check_id"] = str(quality_check.id)
|
||||||
|
|
||||||
|
job_record = db.get(Job, uuid.UUID(str(job["id"])))
|
||||||
|
if job_record:
|
||||||
|
job_record.result_json = result_json
|
||||||
|
db.add(job_record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return envelope(job)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope, QualityEvidenceResponse
|
||||||
|
from app.schemas.detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewUpsert
|
||||||
|
from app.schemas.qa import QualityCheckList
|
||||||
|
from app.services.detection_review_service import DetectionReviewService
|
||||||
|
from app.services.quality_evidence_service import QualityEvidenceService
|
||||||
|
from app.services.quality_check_service import QualityCheckService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quality-checks", response_model=Envelope[QualityCheckList])
|
||||||
|
def list_quality_checks(
|
||||||
|
project_id: UUID,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
items, total = QualityCheckService.list_quality_checks(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/quality-checks/{quality_check_id}/evidence/geojson",
|
||||||
|
response_model=Envelope[QualityEvidenceResponse],
|
||||||
|
)
|
||||||
|
def get_quality_check_evidence_geojson(
|
||||||
|
project_id: UUID,
|
||||||
|
quality_check_id: UUID,
|
||||||
|
limit: int = Query(
|
||||||
|
default=QualityEvidenceService.DEFAULT_EVIDENCE_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=100_000,
|
||||||
|
description="Maximum evidence features to draw; 0 returns everything. Misses and false positives first.",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
QualityEvidenceService.evidence_geojson(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
quality_check_id=quality_check_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/quality-checks/{quality_check_id}/reviews",
|
||||||
|
response_model=Envelope[DetectionReviewList],
|
||||||
|
)
|
||||||
|
def list_detection_reviews(
|
||||||
|
project_id: UUID,
|
||||||
|
quality_check_id: UUID,
|
||||||
|
evidence_role: str | None = Query(default=None, pattern="^(false_positive|false_negative)$"),
|
||||||
|
decision: str | None = Query(default=None, max_length=64),
|
||||||
|
reviewed: bool | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
DetectionReviewService.list_reviews(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
quality_check_id=quality_check_id,
|
||||||
|
evidence_role=evidence_role,
|
||||||
|
decision=decision,
|
||||||
|
reviewed=reviewed,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/quality-checks/{quality_check_id}/reviews",
|
||||||
|
response_model=Envelope[DetectionReviewRead],
|
||||||
|
)
|
||||||
|
def upsert_detection_review(
|
||||||
|
project_id: UUID,
|
||||||
|
quality_check_id: UUID,
|
||||||
|
payload: DetectionReviewUpsert,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
DetectionReviewService.upsert_review(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
quality_check_id=quality_check_id,
|
||||||
|
payload=payload,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
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,
|
||||||
|
Envelope,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
JobRead,
|
||||||
|
SegmentationListResponse,
|
||||||
|
SegmentationModelsResponse,
|
||||||
|
SegmentationQaRequest,
|
||||||
|
SegmentationRead,
|
||||||
|
SegmentationRunListResponse,
|
||||||
|
SegmentationRunRead,
|
||||||
|
SegmentationRunRequest,
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=Envelope[SegmentationModelsResponse])
|
||||||
|
def list_segmentation_models() -> dict:
|
||||||
|
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
|
||||||
|
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,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run-async", response_model=Envelope[JobRead])
|
||||||
|
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,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
|
||||||
|
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(
|
||||||
|
"/runs/{analysis_run_id}/segmentations",
|
||||||
|
response_model=Envelope[SegmentationListResponse],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/segmentations",
|
||||||
|
response_model=Envelope[SegmentationListResponse],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
|
||||||
|
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(
|
||||||
|
"/runs/{analysis_run_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
def get_segmentation_run_geojson(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/runs/{analysis_run_id}/qa/reference",
|
||||||
|
response_model=Envelope[AnalysisQaResponse],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
class_name=payload.class_name,
|
||||||
|
min_confidence=payload.min_confidence,
|
||||||
|
calibration_thresholds=payload.calibration_thresholds,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Area, Dataset
|
||||||
|
from app.schemas.common import Envelope
|
||||||
|
from app.schemas.operations import VectorSelectionResponse
|
||||||
|
from app.schemas.selection_partitions import VectorPartitionSelectionRequest
|
||||||
|
from app.services.vector_feature_service import VectorFeatureService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["selection-partitions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _product_identity(dataset: Dataset) -> str:
|
||||||
|
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
|
||||||
|
return str(metadata.get("product_key") or dataset.reference_layer_name or "")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/datasets/vector/partitions/select",
|
||||||
|
response_model=Envelope[VectorSelectionResponse],
|
||||||
|
)
|
||||||
|
def select_vector_partitions(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: VectorPartitionSelectionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
datasets = db.query(Dataset).filter(Dataset.id.in_(payload.dataset_ids)).all()
|
||||||
|
by_id = {dataset.id: dataset for dataset in datasets}
|
||||||
|
ordered = [by_id.get(dataset_id) for dataset_id in payload.dataset_ids]
|
||||||
|
if any(dataset is None or dataset.project_id != project_id for dataset in ordered):
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="One or more selection partitions were not found", status_code=404)
|
||||||
|
typed_datasets = [dataset for dataset in ordered if dataset is not None]
|
||||||
|
if any(dataset.dataset_type not in {"vector", "geojson"} or dataset.status != "ready" for dataset in typed_datasets):
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_VECTOR_PARTITIONS",
|
||||||
|
message="Every selection partition must be a ready vector dataset",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
source_names = {dataset.source_name for dataset in typed_datasets}
|
||||||
|
product_keys = {_product_identity(dataset) for dataset in typed_datasets}
|
||||||
|
if len(source_names) != 1 or len(product_keys) != 1:
|
||||||
|
raise AppError(
|
||||||
|
code="VECTOR_PARTITION_SOURCE_MISMATCH",
|
||||||
|
message="Selection partitions must belong to one governed source product",
|
||||||
|
details={"source_names": sorted(str(value) for value in source_names), "product_keys": sorted(product_keys)},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
selection_geometry = None
|
||||||
|
selection_area_id = None
|
||||||
|
if payload.area_id is not None:
|
||||||
|
selection_area = db.get(Area, payload.area_id)
|
||||||
|
if selection_area is None or selection_area.project_id != project_id:
|
||||||
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||||
|
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
||||||
|
payload.bbox.model_dump(),
|
||||||
|
selection_area.geometry,
|
||||||
|
)
|
||||||
|
selection_area_id = selection_area.id
|
||||||
|
|
||||||
|
representative = typed_datasets[0]
|
||||||
|
dataset_ids = [dataset.id for dataset in typed_datasets]
|
||||||
|
result = VectorFeatureService.select_features_by_bbox(
|
||||||
|
db,
|
||||||
|
dataset_id=representative.id,
|
||||||
|
dataset_ids=dataset_ids,
|
||||||
|
bbox=payload.bbox.model_dump(),
|
||||||
|
limit=payload.limit,
|
||||||
|
dataset=representative,
|
||||||
|
selection_geometry=selection_geometry,
|
||||||
|
selection_area_id=selection_area_id,
|
||||||
|
deduplicate_source_features=True,
|
||||||
|
)
|
||||||
|
result.update(
|
||||||
|
partition_count=len(dataset_ids),
|
||||||
|
source_name=representative.source_name,
|
||||||
|
dataset_ids=dataset_ids,
|
||||||
|
)
|
||||||
|
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Dataset, DatasetLineageEdge, DatasetQuarantine, Project, SourceRegistry, SourceSnapshot
|
||||||
|
from app.schemas import (
|
||||||
|
DatasetLineageEdgeRead,
|
||||||
|
DatasetProvenanceRead,
|
||||||
|
DatasetQuarantineRead,
|
||||||
|
Envelope,
|
||||||
|
ItemList,
|
||||||
|
SourceRegistryDetailRead,
|
||||||
|
SourceRegistryRead,
|
||||||
|
SourceSnapshotRead,
|
||||||
|
)
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["source-registry"])
|
||||||
|
|
||||||
|
|
||||||
|
def _source_read(source: SourceRegistry, *, snapshot_count: int = 0) -> SourceRegistryRead:
|
||||||
|
return SourceRegistryRead.model_validate(source).model_copy(update={"snapshot_count": int(snapshot_count)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-registry", response_model=Envelope[ItemList[SourceRegistryRead]])
|
||||||
|
def list_source_registry(
|
||||||
|
classification: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
query = (
|
||||||
|
db.query(SourceRegistry, func.count(SourceSnapshot.id).label("snapshot_count"))
|
||||||
|
.outerjoin(SourceSnapshot, SourceSnapshot.source_registry_id == SourceRegistry.id)
|
||||||
|
)
|
||||||
|
if classification:
|
||||||
|
query = query.filter(SourceRegistry.classification == classification.strip().lower())
|
||||||
|
rows = (
|
||||||
|
query.group_by(SourceRegistry.id)
|
||||||
|
.order_by(SourceRegistry.classification.asc(), SourceRegistry.display_name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
items = [_source_read(source, snapshot_count=count) for source, count in rows]
|
||||||
|
return envelope({"items": items, "total": len(items)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-registry/{source_key}", response_model=Envelope[SourceRegistryDetailRead])
|
||||||
|
def get_source_registry_entry(source_key: str, db: Session = Depends(get_db)) -> dict:
|
||||||
|
normalized_key = source_key.strip().lower()
|
||||||
|
source = db.query(SourceRegistry).filter(SourceRegistry.source_key == normalized_key).one_or_none()
|
||||||
|
if source is None:
|
||||||
|
raise AppError(code="SOURCE_REGISTRY_ENTRY_NOT_FOUND", message="Source registry entry was not found", status_code=404)
|
||||||
|
snapshots = (
|
||||||
|
db.query(SourceSnapshot)
|
||||||
|
.filter(SourceSnapshot.source_registry_id == source.id)
|
||||||
|
.order_by(SourceSnapshot.fetched_at.desc(), SourceSnapshot.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
detail = SourceRegistryDetailRead(
|
||||||
|
source=_source_read(source, snapshot_count=len(snapshots)),
|
||||||
|
snapshots=[SourceSnapshotRead.model_validate(snapshot) for snapshot in snapshots],
|
||||||
|
)
|
||||||
|
return envelope(detail)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/datasets/{dataset_id}/provenance",
|
||||||
|
response_model=Envelope[DatasetProvenanceRead],
|
||||||
|
)
|
||||||
|
def get_dataset_provenance(
|
||||||
|
project_id: UUID,
|
||||||
|
dataset_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if db.get(Project, project_id) is None:
|
||||||
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||||
|
dataset = db.get(Dataset, dataset_id)
|
||||||
|
if dataset is None or dataset.project_id != project_id:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||||
|
|
||||||
|
source = db.get(SourceRegistry, dataset.source_registry_id) if dataset.source_registry_id else None
|
||||||
|
snapshot = db.get(SourceSnapshot, dataset.source_snapshot_id) if dataset.source_snapshot_id else None
|
||||||
|
lineage = (
|
||||||
|
db.query(DatasetLineageEdge)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
DatasetLineageEdge.parent_dataset_id == dataset.id,
|
||||||
|
DatasetLineageEdge.child_dataset_id == dataset.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(DatasetLineageEdge.created_at.asc(), DatasetLineageEdge.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
quarantines = (
|
||||||
|
db.query(DatasetQuarantine)
|
||||||
|
.filter(DatasetQuarantine.dataset_id == dataset.id)
|
||||||
|
.order_by(DatasetQuarantine.created_at.desc(), DatasetQuarantine.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result = DatasetProvenanceRead(
|
||||||
|
dataset_id=dataset.id,
|
||||||
|
source=_source_read(source) if source else None,
|
||||||
|
snapshot=SourceSnapshotRead.model_validate(snapshot) if snapshot else None,
|
||||||
|
data_contract_key=dataset.data_contract_key,
|
||||||
|
data_contract_version=dataset.data_contract_version,
|
||||||
|
validation_status=dataset.validation_status,
|
||||||
|
validation_report_json=dataset.validation_report_json,
|
||||||
|
provenance_status=dataset.provenance_status,
|
||||||
|
lineage_status=dataset.lineage_status,
|
||||||
|
quarantine_status=dataset.quarantine_status,
|
||||||
|
lineage=[DatasetLineageEdgeRead.model_validate(item) for item in lineage],
|
||||||
|
quarantines=[DatasetQuarantineRead.model_validate(item) for item in quarantines],
|
||||||
|
)
|
||||||
|
return envelope(result)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope, ItemList
|
||||||
|
from app.schemas.temporal import (
|
||||||
|
TemporalComparisonRequest,
|
||||||
|
TemporalComparisonResponse,
|
||||||
|
TemporalSeriesRead,
|
||||||
|
)
|
||||||
|
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/temporal", tags=["temporal"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/series", response_model=Envelope[ItemList[TemporalSeriesRead]])
|
||||||
|
def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
series = TemporalAnalysisService.list_series(db, project_id)
|
||||||
|
return envelope({"items": [item.model_dump() for item in series], "total": len(series)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compare", response_model=Envelope[TemporalComparisonResponse])
|
||||||
|
def compare_temporal_snapshots(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: TemporalComparisonRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
return envelope(TemporalAnalysisService.compare(db, project_id=project_id, payload=payload).model_dump())
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_env: str = Field(default="development", validation_alias="GEOINTEL_ENV")
|
||||||
|
app_version: str = Field(
|
||||||
|
default="1.0.0",
|
||||||
|
validation_alias="GEOINTEL_APP_VERSION",
|
||||||
|
)
|
||||||
|
build_sha: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_SHA")
|
||||||
|
build_time: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_TIME")
|
||||||
|
api_prefix: str = Field(default="/api/v1", validation_alias="GEOINTEL_API_PREFIX")
|
||||||
|
auth_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_ENABLED")
|
||||||
|
auth_require_https: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_REQUIRE_HTTPS")
|
||||||
|
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
|
||||||
|
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
|
||||||
|
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
|
||||||
|
authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER")
|
||||||
|
authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID")
|
||||||
|
authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET")
|
||||||
|
authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL")
|
||||||
|
public_base_url: str = Field(
|
||||||
|
default="http://localhost:1202",
|
||||||
|
validation_alias="GEOINTEL_PUBLIC_BASE_URL",
|
||||||
|
)
|
||||||
|
auth_session_ttl_seconds: int = Field(
|
||||||
|
default=43_200,
|
||||||
|
ge=900,
|
||||||
|
le=604_800,
|
||||||
|
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
guest_access_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="GEOINTEL_GUEST_ACCESS_ENABLED",
|
||||||
|
)
|
||||||
|
guest_display_name: str = Field(
|
||||||
|
default="Gast",
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
validation_alias="GEOINTEL_GUEST_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
guest_session_ttl_seconds: int = Field(
|
||||||
|
default=7_200,
|
||||||
|
ge=900,
|
||||||
|
le=86_400,
|
||||||
|
validation_alias="GEOINTEL_GUEST_SESSION_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
guest_login_requests_per_minute: int = Field(
|
||||||
|
default=3,
|
||||||
|
ge=1,
|
||||||
|
le=60,
|
||||||
|
validation_alias="GEOINTEL_GUEST_LOGIN_REQUESTS_PER_MINUTE",
|
||||||
|
)
|
||||||
|
guest_compute_requests_per_minute: int = Field(
|
||||||
|
default=4,
|
||||||
|
ge=1,
|
||||||
|
le=120,
|
||||||
|
validation_alias="GEOINTEL_GUEST_COMPUTE_REQUESTS_PER_MINUTE",
|
||||||
|
)
|
||||||
|
guest_compute_max_concurrency: int = Field(
|
||||||
|
default=2,
|
||||||
|
ge=1,
|
||||||
|
le=16,
|
||||||
|
validation_alias="GEOINTEL_GUEST_COMPUTE_MAX_CONCURRENCY",
|
||||||
|
)
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
|
||||||
|
validation_alias="DATABASE_URL",
|
||||||
|
)
|
||||||
|
storage_root: str = Field(default="./storage", validation_alias="STORAGE_ROOT")
|
||||||
|
# Analysis consumes only artifacts under storage_root. Provisioning
|
||||||
|
# workflows that stage tiles elsewhere before ingest can opt out.
|
||||||
|
allow_external_artifact_paths: bool = Field(
|
||||||
|
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
|
||||||
|
)
|
||||||
|
max_upload_mb: int = Field(
|
||||||
|
default=500,
|
||||||
|
ge=1,
|
||||||
|
le=2_048,
|
||||||
|
validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"),
|
||||||
|
)
|
||||||
|
max_in_memory_vector_mb: int = Field(
|
||||||
|
default=64,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
|
||||||
|
)
|
||||||
|
max_raster_pixels: int = Field(
|
||||||
|
default=40_000_000,
|
||||||
|
ge=1,
|
||||||
|
le=500_000_000,
|
||||||
|
validation_alias="GEOINTEL_MAX_RASTER_PIXELS",
|
||||||
|
)
|
||||||
|
max_raster_bands: int = Field(
|
||||||
|
default=16,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="GEOINTEL_MAX_RASTER_BANDS",
|
||||||
|
)
|
||||||
|
max_decoded_raster_mb: int = Field(
|
||||||
|
default=1024,
|
||||||
|
ge=16,
|
||||||
|
le=8192,
|
||||||
|
validation_alias="GEOINTEL_MAX_DECODED_RASTER_MB",
|
||||||
|
)
|
||||||
|
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
||||||
|
orthophoto_wms_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
||||||
|
validation_alias="ORTHOPHOTO_WMS_URL",
|
||||||
|
)
|
||||||
|
orthophoto_wms_layer: str = Field(default="Ortho", validation_alias="ORTHOPHOTO_WMS_LAYER")
|
||||||
|
spw_orthophoto_wms_url: str = Field(
|
||||||
|
default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer",
|
||||||
|
validation_alias="SPW_ORTHOPHOTO_WMS_URL",
|
||||||
|
)
|
||||||
|
brussels_orthophoto_wms_url: str = Field(
|
||||||
|
default="https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows",
|
||||||
|
validation_alias="BRUSSELS_ORTHOPHOTO_WMS_URL",
|
||||||
|
)
|
||||||
|
orthophoto_resolution_m: float = Field(default=1.0, gt=0, validation_alias="ORTHOPHOTO_RESOLUTION_M")
|
||||||
|
orthophoto_min_side_m: float = Field(default=128.0, gt=0, validation_alias="ORTHOPHOTO_MIN_SIDE_M")
|
||||||
|
orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M")
|
||||||
|
orthophoto_timeout_seconds: int = Field(default=120, ge=1, validation_alias="ORTHOPHOTO_TIMEOUT_SECONDS")
|
||||||
|
orthophoto_max_response_mb: int = Field(default=32, ge=1, validation_alias="ORTHOPHOTO_MAX_RESPONSE_MB")
|
||||||
|
orthophoto_cache_ttl_hours: int = Field(default=24, ge=0, validation_alias="ORTHOPHOTO_CACHE_TTL_HOURS")
|
||||||
|
source_catalog_probe_enabled: bool = Field(default=True, validation_alias="SOURCE_CATALOG_PROBE_ENABLED")
|
||||||
|
source_catalog_grb_wfs_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/GRB/wfs",
|
||||||
|
validation_alias="SOURCE_CATALOG_GRB_WFS_URL",
|
||||||
|
)
|
||||||
|
source_catalog_alz_release_url: str = Field(
|
||||||
|
default="https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen",
|
||||||
|
validation_alias="SOURCE_CATALOG_ALZ_RELEASE_URL",
|
||||||
|
)
|
||||||
|
source_catalog_statbel_dcat_url: str = Field(
|
||||||
|
default="https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl",
|
||||||
|
validation_alias="SOURCE_CATALOG_STATBEL_DCAT_URL",
|
||||||
|
)
|
||||||
|
source_catalog_statbel_max_response_mb: int = Field(
|
||||||
|
default=5,
|
||||||
|
ge=1,
|
||||||
|
le=10,
|
||||||
|
validation_alias="SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
source_catalog_probe_timeout_seconds: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
le=60,
|
||||||
|
validation_alias="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
source_catalog_probe_max_response_mb: int = Field(
|
||||||
|
default=2,
|
||||||
|
ge=1,
|
||||||
|
le=10,
|
||||||
|
validation_alias="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
source_catalog_probe_cache_ttl_seconds: int = Field(
|
||||||
|
default=900,
|
||||||
|
ge=0,
|
||||||
|
le=86_400,
|
||||||
|
validation_alias="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
grb_enabled: bool = Field(default=True, validation_alias="GRB_ENABLED")
|
||||||
|
grb_ogc_api_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/GRB/ogc/features/v1",
|
||||||
|
validation_alias="GRB_OGC_API_URL",
|
||||||
|
)
|
||||||
|
grb_min_side_m: float = Field(default=10.0, gt=0, validation_alias="GRB_MIN_SIDE_M")
|
||||||
|
grb_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="GRB_MAX_SIDE_M")
|
||||||
|
grb_page_size: int = Field(default=1000, ge=1, le=1000, validation_alias="GRB_PAGE_SIZE")
|
||||||
|
grb_max_pages: int = Field(default=200, ge=1, le=1000, validation_alias="GRB_MAX_PAGES")
|
||||||
|
grb_max_features: int = Field(default=150_000, ge=1, validation_alias="GRB_MAX_FEATURES")
|
||||||
|
grb_timeout_seconds: int = Field(default=180, ge=1, le=600, validation_alias="GRB_TIMEOUT_SECONDS")
|
||||||
|
grb_max_response_mb: int = Field(default=20, ge=1, le=100, validation_alias="GRB_MAX_RESPONSE_MB")
|
||||||
|
grb_max_total_response_mb: int = Field(
|
||||||
|
default=256,
|
||||||
|
ge=1,
|
||||||
|
le=2048,
|
||||||
|
validation_alias="GRB_MAX_TOTAL_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
grb_cache_ttl_hours: int = Field(default=24, ge=0, le=8760, validation_alias="GRB_CACHE_TTL_HOURS")
|
||||||
|
official_vector_enabled: bool = Field(default=True, validation_alias="OFFICIAL_VECTOR_ENABLED")
|
||||||
|
bwk_wfs_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/BWK/wfs",
|
||||||
|
validation_alias="BWK_WFS_URL",
|
||||||
|
)
|
||||||
|
dov_soil_wfs_url: str = Field(
|
||||||
|
default="https://www.dov.vlaanderen.be/geoserver/wfs",
|
||||||
|
validation_alias="DOV_SOIL_WFS_URL",
|
||||||
|
)
|
||||||
|
official_vector_min_side_m: float = Field(
|
||||||
|
default=10.0,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MIN_SIDE_M",
|
||||||
|
)
|
||||||
|
official_vector_max_side_m: float = Field(
|
||||||
|
default=20_000.0,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_SIDE_M",
|
||||||
|
)
|
||||||
|
official_vector_page_size: int = Field(
|
||||||
|
default=1000,
|
||||||
|
ge=1,
|
||||||
|
le=2000,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_PAGE_SIZE",
|
||||||
|
)
|
||||||
|
official_vector_max_pages: int = Field(
|
||||||
|
default=200,
|
||||||
|
ge=1,
|
||||||
|
le=1000,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_PAGES",
|
||||||
|
)
|
||||||
|
official_vector_max_features: int = Field(
|
||||||
|
default=100_000,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_FEATURES",
|
||||||
|
)
|
||||||
|
official_vector_timeout_seconds: int = Field(
|
||||||
|
default=180,
|
||||||
|
ge=1,
|
||||||
|
le=600,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
official_vector_max_response_mb: int = Field(
|
||||||
|
default=20,
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
official_vector_max_total_response_mb: int = Field(
|
||||||
|
default=256,
|
||||||
|
ge=1,
|
||||||
|
le=2048,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
official_vector_cache_ttl_hours: int = Field(
|
||||||
|
default=24,
|
||||||
|
ge=0,
|
||||||
|
le=8760,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_CACHE_TTL_HOURS",
|
||||||
|
)
|
||||||
|
spw_picc_enabled: bool = Field(default=True, validation_alias="SPW_PICC_ENABLED")
|
||||||
|
spw_picc_mapserver_url: str = Field(
|
||||||
|
default=(
|
||||||
|
"https://geoservices.wallonie.be/arcgis/rest/services/"
|
||||||
|
"TOPOGRAPHIE/PICC_VDIFF/MapServer"
|
||||||
|
),
|
||||||
|
validation_alias="SPW_PICC_MAPSERVER_URL",
|
||||||
|
)
|
||||||
|
spw_flood_hazard_enabled: bool = Field(default=True, validation_alias="SPW_FLOOD_HAZARD_ENABLED")
|
||||||
|
spw_flood_hazard_mapserver_url: str = Field(
|
||||||
|
default=(
|
||||||
|
"https://geoservices.wallonie.be/arcgis/rest/services/"
|
||||||
|
"EAU/ALEA_INOND/MapServer"
|
||||||
|
),
|
||||||
|
validation_alias="SPW_FLOOD_HAZARD_MAPSERVER_URL",
|
||||||
|
)
|
||||||
|
urbis_enabled: bool = Field(default=True, validation_alias="URBIS_ENABLED")
|
||||||
|
urbis_wfs_url: str = Field(
|
||||||
|
default="https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows",
|
||||||
|
validation_alias="URBIS_WFS_URL",
|
||||||
|
)
|
||||||
|
dhmv_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED")
|
||||||
|
dhmv_wcs_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/DHMV/wcs",
|
||||||
|
validation_alias="DHMV_WCS_URL",
|
||||||
|
)
|
||||||
|
dhmv_resolution_m: float = Field(default=5.0, ge=1.0, le=10.0, validation_alias="DHMV_RESOLUTION_M")
|
||||||
|
dhmv_min_side_m: float = Field(default=10.0, gt=0, validation_alias="DHMV_MIN_SIDE_M")
|
||||||
|
dhmv_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="DHMV_MAX_SIDE_M")
|
||||||
|
dhmv_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="DHMV_MAX_PIXELS")
|
||||||
|
dhmv_timeout_seconds: int = Field(default=300, ge=1, validation_alias="DHMV_TIMEOUT_SECONDS")
|
||||||
|
dhmv_max_response_mb: int = Field(default=160, ge=1, validation_alias="DHMV_MAX_RESPONSE_MB")
|
||||||
|
flood_hazard_enabled: bool = Field(default=True, validation_alias="FLOOD_HAZARD_ENABLED")
|
||||||
|
flood_hazard_wcs_url: str = Field(
|
||||||
|
default="https://geoservice.waterinfo.be/OGRK/wcs",
|
||||||
|
validation_alias="FLOOD_HAZARD_WCS_URL",
|
||||||
|
)
|
||||||
|
flood_hazard_resolution_m: float = Field(default=5.0, ge=2.0, le=20.0, validation_alias="FLOOD_HAZARD_RESOLUTION_M")
|
||||||
|
flood_hazard_min_side_m: float = Field(default=10.0, gt=0, validation_alias="FLOOD_HAZARD_MIN_SIDE_M")
|
||||||
|
flood_hazard_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="FLOOD_HAZARD_MAX_SIDE_M")
|
||||||
|
flood_hazard_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="FLOOD_HAZARD_MAX_PIXELS")
|
||||||
|
flood_hazard_timeout_seconds: int = Field(default=300, ge=1, validation_alias="FLOOD_HAZARD_TIMEOUT_SECONDS")
|
||||||
|
flood_hazard_max_response_mb: int = Field(default=160, ge=1, validation_alias="FLOOD_HAZARD_MAX_RESPONSE_MB")
|
||||||
|
bathymetry_profiles_enabled: bool = Field(default=True, validation_alias="BATHYMETRY_PROFILES_ENABLED")
|
||||||
|
bathymetry_profiles_layer_url: str = Field(
|
||||||
|
default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0",
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_LAYER_URL",
|
||||||
|
)
|
||||||
|
bathymetry_watercourse_layer_url: str = Field(
|
||||||
|
default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1",
|
||||||
|
validation_alias="BATHYMETRY_WATERCOURSE_LAYER_URL",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_page_size: int = Field(
|
||||||
|
default=1000,
|
||||||
|
ge=1,
|
||||||
|
le=2000,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_PAGE_SIZE",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_max_features: int = Field(
|
||||||
|
default=50_000,
|
||||||
|
ge=1,
|
||||||
|
le=250_000,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_MAX_FEATURES",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_max_pages: int = Field(
|
||||||
|
default=200,
|
||||||
|
ge=1,
|
||||||
|
le=5_000,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_MAX_PAGES",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_timeout_seconds: int = Field(
|
||||||
|
default=120,
|
||||||
|
ge=1,
|
||||||
|
le=600,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_max_response_mb: int = Field(
|
||||||
|
default=32,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
bathymetry_raster_max_pixels: int = Field(
|
||||||
|
default=30_000_000,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="BATHYMETRY_RASTER_MAX_PIXELS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_probe_enabled: bool = Field(default=True, validation_alias="MDK_BATHYMETRY_PROBE_ENABLED")
|
||||||
|
mdk_bathymetry_wcs_url: str = Field(
|
||||||
|
default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
|
||||||
|
validation_alias="MDK_BATHYMETRY_WCS_URL",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_probe_timeout_seconds: int = Field(
|
||||||
|
default=20,
|
||||||
|
ge=1,
|
||||||
|
le=120,
|
||||||
|
validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_probe_max_response_mb: int = Field(
|
||||||
|
default=4,
|
||||||
|
ge=1,
|
||||||
|
le=16,
|
||||||
|
validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
thematic_raster_enabled: bool = Field(default=True, validation_alias="THEMATIC_RASTER_ENABLED")
|
||||||
|
thematic_raster_wcs_url: str = Field(
|
||||||
|
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
|
||||||
|
validation_alias="THEMATIC_RASTER_WCS_URL",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_coverage_id: str | None = Field(default=None, validation_alias="MDK_BATHYMETRY_COVERAGE_ID")
|
||||||
|
mdk_bathymetry_request_crs: str = Field(default="EPSG:4326", validation_alias="MDK_BATHYMETRY_REQUEST_CRS")
|
||||||
|
mdk_bathymetry_max_bbox_deg2: float = Field(
|
||||||
|
default=0.25,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_timeout_seconds: int = Field(
|
||||||
|
default=120,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_max_response_mb: int = Field(
|
||||||
|
default=160,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M")
|
||||||
|
thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M")
|
||||||
|
thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS")
|
||||||
|
thematic_raster_timeout_seconds: int = Field(default=300, ge=1, validation_alias="THEMATIC_RASTER_TIMEOUT_SECONDS")
|
||||||
|
thematic_raster_max_response_mb: int = Field(default=160, ge=1, validation_alias="THEMATIC_RASTER_MAX_RESPONSE_MB")
|
||||||
|
walous_enabled: bool = Field(default=True, validation_alias="WALOUS_ENABLED")
|
||||||
|
walous_source_dir: str = Field(
|
||||||
|
default="/app/storage/source-cache/walous",
|
||||||
|
validation_alias="WALOUS_SOURCE_DIR",
|
||||||
|
)
|
||||||
|
walous_analysis_resolution_m: float = Field(
|
||||||
|
default=10.0,
|
||||||
|
ge=1.0,
|
||||||
|
le=100.0,
|
||||||
|
validation_alias="WALOUS_ANALYSIS_RESOLUTION_M",
|
||||||
|
)
|
||||||
|
walous_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="WALOUS_MAX_SIDE_M")
|
||||||
|
walous_max_pixels: int = Field(default=36_000_000, ge=1, validation_alias="WALOUS_MAX_PIXELS")
|
||||||
|
spw_terrain_enabled: bool = Field(default=True, validation_alias="SPW_TERRAIN_ENABLED")
|
||||||
|
spw_terrain_source_dir: str = Field(
|
||||||
|
default="/app/storage/source-cache/spw-terrain",
|
||||||
|
validation_alias="SPW_TERRAIN_SOURCE_DIR",
|
||||||
|
)
|
||||||
|
spw_terrain_analysis_resolution_m: float = Field(
|
||||||
|
default=5.0,
|
||||||
|
ge=1.0,
|
||||||
|
le=10.0,
|
||||||
|
validation_alias="SPW_TERRAIN_ANALYSIS_RESOLUTION_M",
|
||||||
|
)
|
||||||
|
spw_terrain_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="SPW_TERRAIN_MAX_SIDE_M")
|
||||||
|
spw_terrain_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="SPW_TERRAIN_MAX_PIXELS")
|
||||||
|
redis_url: str | None = Field(default=None, validation_alias="REDIS_URL")
|
||||||
|
log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL")
|
||||||
|
sql_log_level: str = Field(default="WARNING", validation_alias="GEOINTEL_SQL_LOG_LEVEL")
|
||||||
|
reconcile_interrupted_runs_on_startup: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP",
|
||||||
|
)
|
||||||
|
aoi_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AOI_WORKER_ENABLED")
|
||||||
|
aoi_worker_poll_seconds: float = Field(default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_AOI_WORKER_POLL_SECONDS")
|
||||||
|
# Executes queued detection.run / segmentation.run jobs so tiled GPU
|
||||||
|
# inference never blocks an HTTP request.
|
||||||
|
analysis_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_ANALYSIS_WORKER_ENABLED")
|
||||||
|
analysis_worker_poll_seconds: float = Field(
|
||||||
|
default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS"
|
||||||
|
)
|
||||||
|
database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS")
|
||||||
|
yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED")
|
||||||
|
yolo_models_dir: str = Field(default="/app/models", validation_alias="YOLO_MODELS_DIR")
|
||||||
|
yolo_model_path: str | None = Field(default=None, validation_alias="YOLO_MODEL_PATH")
|
||||||
|
yolo_model_id: str = Field(default="yolo-configured", validation_alias="YOLO_MODEL_ID")
|
||||||
|
yolo_model_display_name: str = Field(default="Configured YOLO detector", validation_alias="YOLO_MODEL_DISPLAY_NAME")
|
||||||
|
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
|
||||||
|
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
|
||||||
|
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
|
||||||
|
yolo_validation_scope_manifest_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH",
|
||||||
|
)
|
||||||
|
yolo_validation_scope_manifest_sha256: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256",
|
||||||
|
)
|
||||||
|
# Deprecated compatibility field. Mutable Area names are never an
|
||||||
|
# inference authorization boundary; deployments must use the immutable
|
||||||
|
# checksum-bound scope manifest above.
|
||||||
|
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
|
||||||
|
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
|
||||||
|
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
|
||||||
|
yolo_image_size: int = Field(default=640, validation_alias="YOLO_IMAGE_SIZE")
|
||||||
|
yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES")
|
||||||
|
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
|
||||||
|
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
|
||||||
|
yolo_suppress_tile_edge_detections: bool = Field(
|
||||||
|
default=True, validation_alias="YOLO_SUPPRESS_TILE_EDGE_DETECTIONS"
|
||||||
|
)
|
||||||
|
# Intersection over the smaller box. The candidate evaluation freezes this
|
||||||
|
# during calibration; serving a promoted model at a different value means
|
||||||
|
# the runtime suppresses detections the gate counted.
|
||||||
|
yolo_containment_nms_threshold: float = Field(
|
||||||
|
default=0.85, ge=0.0, le=1.0, validation_alias="YOLO_CONTAINMENT_NMS_THRESHOLD"
|
||||||
|
)
|
||||||
|
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
|
||||||
|
yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED")
|
||||||
|
yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH")
|
||||||
|
yolo_seg_model_id: str = Field(default="yolo-seg-configured", validation_alias="YOLO_SEG_MODEL_ID")
|
||||||
|
yolo_seg_model_display_name: str = Field(
|
||||||
|
default="Configured YOLO segmentation",
|
||||||
|
validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
yolo_seg_model_version: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_VERSION")
|
||||||
|
sam_enabled: bool = Field(default=False, validation_alias="SAM_ENABLED")
|
||||||
|
sam_model_path: str | None = Field(default=None, validation_alias="SAM_MODEL_PATH")
|
||||||
|
sam_model_id: str = Field(default="sam-configured", validation_alias="SAM_MODEL_ID")
|
||||||
|
sam_model_display_name: str = Field(
|
||||||
|
default="Configured SAM segmentation",
|
||||||
|
validation_alias="SAM_MODEL_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION")
|
||||||
|
segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE")
|
||||||
|
# Masks and boxes overlap differently, so segmentation carries its own
|
||||||
|
# containment value rather than borrowing the detector's.
|
||||||
|
segmentation_containment_nms_threshold: float = Field(
|
||||||
|
default=0.85,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
validation_alias="SEGMENTATION_CONTAINMENT_NMS_THRESHOLD",
|
||||||
|
)
|
||||||
|
segmentation_duplicate_iou_threshold: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
|
||||||
|
)
|
||||||
|
ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED")
|
||||||
|
ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL")
|
||||||
|
ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL")
|
||||||
|
ollama_timeout_seconds: int = Field(default=120, ge=5, le=600, validation_alias="OLLAMA_TIMEOUT_SECONDS")
|
||||||
|
ollama_max_output_tokens: int = Field(default=1_200, ge=100, le=4_000, validation_alias="OLLAMA_MAX_OUTPUT_TOKENS")
|
||||||
|
ollama_context_tokens: int = Field(default=16_384, ge=4_096, le=131_072, validation_alias="OLLAMA_CONTEXT_TOKENS")
|
||||||
|
cors_origins: list[str] | str = Field(
|
||||||
|
default=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
validation_alias="CORS_ORIGINS",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("cors_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_cors_origins(cls, value: object) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
@field_validator("ollama_base_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_ollama_base_url(cls, value: str) -> str:
|
||||||
|
normalized = value.strip().rstrip("/")
|
||||||
|
if not normalized.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("OLLAMA_BASE_URL must use http or https")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_operator_auth(self) -> "Settings":
|
||||||
|
self.guest_display_name = self.guest_display_name.strip()
|
||||||
|
if not self.guest_display_name:
|
||||||
|
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
|
||||||
|
for field_name in (
|
||||||
|
"authentik_issuer",
|
||||||
|
"authentik_client_id",
|
||||||
|
"authentik_client_secret",
|
||||||
|
"authentik_allowed_email",
|
||||||
|
):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
setattr(self, field_name, value.strip() if value else None)
|
||||||
|
self.public_base_url = self.public_base_url.strip().rstrip("/")
|
||||||
|
authentik_values = (
|
||||||
|
self.authentik_issuer,
|
||||||
|
self.authentik_client_id,
|
||||||
|
self.authentik_client_secret,
|
||||||
|
self.authentik_allowed_email,
|
||||||
|
)
|
||||||
|
if any(authentik_values) and not all(authentik_values):
|
||||||
|
raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together")
|
||||||
|
if all(authentik_values):
|
||||||
|
if not self.auth_enabled:
|
||||||
|
raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured")
|
||||||
|
for label, value in (
|
||||||
|
("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer),
|
||||||
|
("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url),
|
||||||
|
):
|
||||||
|
parsed = urlsplit(str(value))
|
||||||
|
if (
|
||||||
|
parsed.scheme != "https"
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment")
|
||||||
|
public_url = urlsplit(self.public_base_url)
|
||||||
|
if public_url.path not in ("", "/"):
|
||||||
|
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path")
|
||||||
|
if "@" not in str(self.authentik_allowed_email) or any(
|
||||||
|
character.isspace() for character in str(self.authentik_allowed_email)
|
||||||
|
):
|
||||||
|
raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address")
|
||||||
|
if not self.auth_enabled:
|
||||||
|
return self
|
||||||
|
if not (self.auth_username or "").strip():
|
||||||
|
raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled")
|
||||||
|
if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"):
|
||||||
|
raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash")
|
||||||
|
if len(self.auth_session_secret or "") < 32:
|
||||||
|
raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class AppError(Exception):
|
||||||
|
"""Domain error used by services to return canonical API errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
details: dict | list | None = None,
|
||||||
|
status_code: int = 400,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
self.status_code = status_code
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO", sql_level: str = "WARNING") -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
for name in ["uvicorn", "uvicorn.error", "uvicorn.access"]:
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(sql_level)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
# Stable server-owned identity: a public session must never attach itself to an
|
||||||
|
# operator project merely because the display names happen to match.
|
||||||
|
PUBLIC_DEMO_PROJECT_ID = UUID("6f7e6f12-9b62-4a3f-a5a0-4b3bb6b2c901")
|
||||||
|
PUBLIC_DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
|
||||||
|
PUBLIC_DEMO_PROJECT_MARKER = "geointel:public-demo:v1"
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_demo_project(project_id: UUID) -> bool:
|
||||||
|
return project_id == PUBLIC_DEMO_PROJECT_ID
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
|
||||||
|
|
||||||
|
_request_id: ContextVar[str] = ContextVar("geointel_request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> str:
|
||||||
|
return _request_id.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_id(value: str) -> Token:
|
||||||
|
return _request_id.set(value)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_request_id(token: Token) -> None:
|
||||||
|
_request_id.reset(token)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .base import Base
|
||||||
|
from .session import get_db, get_engine
|
||||||
|
|
||||||
|
__all__ = ["Base", "get_db", "get_engine"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
return engine
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user