chore(repo): retire duplicate nested source mirror
This commit is contained in:
@@ -4,6 +4,8 @@ __pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -35,6 +37,9 @@ build/
|
||||
!/artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/**
|
||||
!/artifacts/evidence/accuracy/P2/
|
||||
!/artifacts/evidence/accuracy/P2/**
|
||||
!/artifacts/evidence/accuracy/model-training/
|
||||
/artifacts/evidence/accuracy/model-training/*
|
||||
!/artifacts/evidence/accuracy/model-training/20260830-independent-ai-visual-review.json
|
||||
/.cache/
|
||||
/datasets/raw/*
|
||||
/datasets/processed/*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "verify_repository_layout.py"
|
||||
SPEC = importlib.util.spec_from_file_location("verify_repository_layout", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def test_nested_repository_mirror_is_absent() -> None:
|
||||
assert MODULE.nested_mirror_markers(ROOT) == []
|
||||
|
||||
|
||||
def test_nested_repository_mirror_is_detected(tmp_path: Path) -> None:
|
||||
for marker in MODULE.CANONICAL_MARKERS:
|
||||
path = tmp_path / "geointel" / marker
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("fixture", encoding="utf-8")
|
||||
|
||||
assert MODULE.nested_mirror_markers(tmp_path) == list(MODULE.CANONICAL_MARKERS)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Retirement of the tracked `geointel/` mirror
|
||||
|
||||
## Decision
|
||||
|
||||
The repository root is the only canonical GeoIntel source tree. The stale,
|
||||
tracked copy below `geointel/` was removed during the full-platform remediation
|
||||
because it duplicated backend, frontend, scripts, tests and documentation and
|
||||
could make local tools inspect or execute the wrong implementation.
|
||||
|
||||
## Recovery evidence
|
||||
|
||||
- Source commit: `d39816a4ca1a23a7b90bcf01e5f2b01fd5fb57c4`.
|
||||
- Source tree object: `b66d5987f7e2b1f8180acd3b4c768586993c7d5a`.
|
||||
- Tracked files: `1,153`.
|
||||
- The two mirror-only PNG assets remain recoverable from that immutable Git
|
||||
commit; the canonical frontend already contains the current SVG/PNG/WebP
|
||||
icon and wordmark assets used by the application.
|
||||
- Recovery command, if historical inspection is required:
|
||||
`git restore --source d39816a4ca1a23a7b90bcf01e5f2b01fd5fb57c4 -- geointel`.
|
||||
|
||||
The migration deletes no production data, model weights, source datasets or
|
||||
runtime storage. After the tracked mirror was retired, 207 untracked runtime
|
||||
files (about 0.04 GiB) remained below that directory. They were moved intact,
|
||||
without content rewriting, to the ignored recovery directory
|
||||
`.codex-artifacts/retired-nested-runtime-20260830/`. Nothing from that recovery
|
||||
directory is packaged or deployed. Docker already excluded the former mirror,
|
||||
so production runtime paths remain unchanged.
|
||||
|
||||
## Prevention gate
|
||||
|
||||
`python scripts/verify_repository_layout.py` fails when canonical source
|
||||
markers appear under a new nested `geointel/` directory. The release-readiness
|
||||
workflow runs this check so repository ambiguity cannot silently return.
|
||||
@@ -1,23 +0,0 @@
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/*.tsbuildinfo
|
||||
backend/.pytest_cache
|
||||
backend/**/*.pyc
|
||||
backend/**/__pycache__
|
||||
|
||||
storage
|
||||
postgres-data
|
||||
datasets/raw
|
||||
datasets/processed
|
||||
datasets/cache
|
||||
exports
|
||||
models
|
||||
|
||||
.env
|
||||
@@ -1,173 +0,0 @@
|
||||
# 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
|
||||
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 requires this gate and
|
||||
# should be enabled only on a dedicated demo-safe instance.
|
||||
GEOINTEL_AUTH_ENABLED=false
|
||||
GEOINTEL_AUTH_USERNAME=
|
||||
GEOINTEL_AUTH_PASSWORD_HASH=
|
||||
GEOINTEL_AUTH_SESSION_SECRET=
|
||||
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
|
||||
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_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
|
||||
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
|
||||
YOLO_BATCH_SIZE=1
|
||||
|
||||
# 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=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
|
||||
@@ -1,13 +0,0 @@
|
||||
*.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
|
||||
@@ -1,141 +0,0 @@
|
||||
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-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Compile, test, contracts and builds
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
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@v4
|
||||
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@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
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@v4
|
||||
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: GIS image, SBOM and container scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build non-AI release image
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker build \
|
||||
-f deploy/unraid/Dockerfile.all-in-one \
|
||||
--build-arg GEOINTEL_INSTALL_AI=false \
|
||||
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
|
||||
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
|
||||
-t "geointel-ci:$RELEASE_SHA-gis" \
|
||||
.
|
||||
docker image inspect "geointel-ci:$RELEASE_SHA-gis" > artifacts/image-inspect.json
|
||||
- name: Generate SPDX SBOM
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/generate_container_sbom.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
- name: Enforce container vulnerability policy
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/scan_container_image.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
- name: Publish container evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: container-evidence
|
||||
path: |
|
||||
artifacts/image-inspect.json
|
||||
artifacts/geointel-sbom.spdx.json
|
||||
artifacts/geointel-container-vulnerabilities.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# 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
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
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@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
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@v4
|
||||
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@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
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@v4
|
||||
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: GIS image, SBOM and container scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build non-AI release image
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker build \
|
||||
-f deploy/unraid/Dockerfile.all-in-one \
|
||||
--build-arg GEOINTEL_INSTALL_AI=false \
|
||||
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
|
||||
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
|
||||
-t "geointel-ci:$RELEASE_SHA-gis" \
|
||||
.
|
||||
docker image inspect "geointel-ci:$RELEASE_SHA-gis" > artifacts/image-inspect.json
|
||||
- name: Generate SPDX SBOM
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: bash scripts/generate_container_sbom.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
- name: Enforce container vulnerability policy
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: bash scripts/scan_container_image.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
- name: Publish container evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: container-evidence
|
||||
path: |
|
||||
artifacts/image-inspect.json
|
||||
artifacts/geointel-sbom.spdx.json
|
||||
artifacts/geointel-container-vulnerabilities.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
@@ -1,56 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Large local data
|
||||
/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/
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
@@ -1,23 +0,0 @@
|
||||
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.
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
@@ -1,313 +0,0 @@
|
||||
# GeoIntel Belgium and the Belgian North Sea
|
||||
|
||||
GeoIntel is a map-first GeoAI Workbench for Belgium and the Belgian North Sea.
|
||||
It combines governed official-source coverage, raster/vector processing,
|
||||
historical comparison, computer vision, QA/QC and geospatial exports.
|
||||
|
||||
Mol and the Kempen remain deep regression and model-validation references. The
|
||||
release scope is all of Belgium plus legally labelled Belgian maritime zones;
|
||||
source coverage remains explicit per theme and jurisdiction.
|
||||
|
||||
GeoIntel is not a generic dashboard or chatbot. The core product is:
|
||||
|
||||
> data → processing → geospatial output → QA/QC → export
|
||||
|
||||
## Current milestone
|
||||
|
||||
**v1.0.0 - Belgium/North Sea release**
|
||||
|
||||
The canonical release controls are:
|
||||
|
||||
- `docs/00-start/START_HERE.md`
|
||||
- `docs/RC_SCOPE_FREEZE_BELGIUM_NORTH_SEA.md`
|
||||
- `docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md`
|
||||
- `docs/RELEASE_RUNBOOK.md`
|
||||
- `docs/KNOWN_LIMITATIONS.md`
|
||||
- `docs/DEFINITION_OF_DONE.md`
|
||||
|
||||
Older milestone and sprint handoff files remain historical evidence. They do
|
||||
not override the active national/maritime scope freeze or RC roadmap.
|
||||
|
||||
## Core V1 vertical slice
|
||||
|
||||
The first implementation target is:
|
||||
|
||||
1. Project + Area creation.
|
||||
2. Dataset registration/upload and metadata extraction.
|
||||
3. Reference building layer loading.
|
||||
4. Predicted detection layer loading/import.
|
||||
5. QA/QC matching against reference polygons.
|
||||
6. Metrics and false positive/false negative outputs.
|
||||
7. GeoJSON export.
|
||||
8. Minimal map/workbench UI.
|
||||
|
||||
## Primary stack
|
||||
|
||||
- Frontend: React, TypeScript, MapLibre GL, Deck.gl, Tailwind.
|
||||
- Backend: FastAPI, Python.
|
||||
- Database: PostgreSQL + PostGIS.
|
||||
- GIS processing: GeoPandas, Shapely, Rasterio, PyProj, GDAL.
|
||||
- AI: PyTorch, Ultralytics YOLO, SAM-compatible architecture.
|
||||
- Jobs: Redis + RQ.
|
||||
- Storage: local filesystem first, MinIO-compatible later.
|
||||
|
||||
## Codex instructions
|
||||
|
||||
Codex must start with:
|
||||
|
||||
1. `docs/00-start/START_HERE.md`
|
||||
2. `prompts/codex/M11_ARCHITECT_MASTER_PROMPT.md`
|
||||
|
||||
Then follow the build order in:
|
||||
|
||||
- `docs/build/BUILD_ORDER_DEPENDENCY_GRAPH.md`
|
||||
- `docs/build/CODEX_OPERATING_SYSTEM.md`
|
||||
|
||||
Before every implementation pass, run available preflight/smoke scripts where applicable.
|
||||
|
||||
## Repo principle
|
||||
|
||||
This is a documentation-driven engineering repo. The documentation is not decorative; it is the control system for autonomous implementation.
|
||||
|
||||
## Fastest Day 1 command path
|
||||
|
||||
```bash
|
||||
make readiness
|
||||
```
|
||||
|
||||
## Unraid / Tower deployment
|
||||
|
||||
GeoIntel runs on Unraid as an all-in-one DockerMan-native container. The container embeds PostGIS, runs the FastAPI backend internally, and serves the frontend through nginx on one editable web port.
|
||||
|
||||
Unraid template assets live in:
|
||||
|
||||
- `deploy/unraid/geointel.env.example`
|
||||
- `deploy/unraid/geointel-unraid-template.xml`
|
||||
- `deploy/unraid/geointel-icon.svg`
|
||||
- `deploy/unraid/geointel-icon.png`
|
||||
- `docker-compose.unraid.yml`
|
||||
|
||||
Copy the Unraid env template to `.env` in the checkout and edit ports/paths there:
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/geointel
|
||||
cp deploy/unraid/geointel.env.example .env
|
||||
nano .env
|
||||
docker build -f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest .
|
||||
bash deploy/unraid/run-dockerman-container.sh
|
||||
```
|
||||
|
||||
Common editable values:
|
||||
|
||||
```env
|
||||
GEOINTEL_FRONTEND_PORT=1202
|
||||
GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
|
||||
GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
|
||||
```
|
||||
|
||||
### Optional guest demonstration access
|
||||
|
||||
Guest access is disabled by default. On a dedicated demonstration installation,
|
||||
it can be enabled alongside the operator gate:
|
||||
|
||||
```env
|
||||
GEOINTEL_AUTH_ENABLED=true
|
||||
GEOINTEL_AUTH_USERNAME=operator
|
||||
GEOINTEL_AUTH_PASSWORD_HASH=pbkdf2_sha256$...
|
||||
GEOINTEL_AUTH_SESSION_SECRET=<independent-random-secret-of-at-least-32-characters>
|
||||
GEOINTEL_GUEST_ACCESS_ENABLED=true
|
||||
GEOINTEL_GUEST_DISPLAY_NAME=Gast
|
||||
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
|
||||
```
|
||||
|
||||
The login page then offers **Als gast verkennen**. A guest receives a
|
||||
short-lived, read-only session scoped to the seeded demo project and sees only
|
||||
the map and existing quality evidence. This is not multi-user authorization or
|
||||
tenant isolation. Do not enable it on an installation containing private or
|
||||
operational datasets; use a separate demo instance instead.
|
||||
|
||||
The backend and PostGIS ports are intentionally not exposed to the LAN in the all-in-one runtime. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes.
|
||||
|
||||
On Tower/Unraid, `scripts/deploy_tower.ps1` and `scripts/deploy_tower.sh` validate the Compose reference but build with plain `docker build`, then automatically install the editable DockerMan template as `/boot/config/plugins/dockerMan/templates-user/my-geointel.xml`, install the PNG icon as `/boot/config/plugins/dockerMan/images/geointel-icon.png`, remove any old Compose-owned `geointel` container and start the final container with DockerMan labels.
|
||||
|
||||
## Sprint 2 quick start
|
||||
|
||||
- Update dependencies:
|
||||
|
||||
```bash
|
||||
python -m pip install -e backend/.[dev]
|
||||
cd frontend && npm install
|
||||
```
|
||||
|
||||
- Run full readiness checks (with no scope expansion):
|
||||
|
||||
```bash
|
||||
python -m compileall backend/app
|
||||
cd backend && python -m pytest
|
||||
cd ../frontend && npm run typecheck && npm run build
|
||||
bash scripts/run_readiness_check.sh
|
||||
```
|
||||
|
||||
- Raster workflow validation command (backend only):
|
||||
|
||||
```bash
|
||||
bash scripts/smoke_backend_import.sh
|
||||
cd backend && python -c "from app.main import app; print(app.title)"
|
||||
```
|
||||
|
||||
If `rasterio` is not installed, raster metadata endpoints return `RASTER_PROCESSING_UNAVAILABLE` and the frontend displays the
|
||||
state as failed until the dependency is added.
|
||||
|
||||
## Sprint 4 raster foundation
|
||||
|
||||
- Raster operations now support:
|
||||
- raster metadata extraction,
|
||||
- raster preview generation,
|
||||
- raster clip by area (with provenance on derived datasets),
|
||||
- raster tile generation with manifest output.
|
||||
- Raster services are dependency-aware:
|
||||
- if `rasterio` is unavailable, endpoints return `RASTER_PROCESSING_UNAVAILABLE`.
|
||||
- if preview dependencies (`numpy`, `pillow`) are unavailable, preview generation is unavailable with a clear error.
|
||||
- Enable raster stack explicitly when needed:
|
||||
|
||||
```bash
|
||||
cd backend && python -m pip install -e .[dev,raster]
|
||||
```
|
||||
|
||||
## Sprint 5 raster analytics hardening
|
||||
|
||||
- Added raster band statistics (min/max/mean/std, nodata ratio/count, valid pixel count, dtype, optional histograms).
|
||||
- Added raster reproject workflow with CRS validation and provenance persistence.
|
||||
- Extended tile manifest expectations (`tile_set_id`, `tile_size`, `overlap`, `bounds`, `source_raster_id`, `tile_paths`, `tile_server`).
|
||||
- Clarified raster operation availability in frontend/backend docs (`RASTER_PROCESSING_UNAVAILABLE` and invalid-CRS cases).
|
||||
|
||||
- Raster workflow command set (where available):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m pip install -e .[dev,raster]
|
||||
python -m pytest
|
||||
cd ../frontend
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then give Codex the prompt in:
|
||||
|
||||
- `prompts/codex/final/DAY_1_MASTER_PROMPT.md`
|
||||
|
||||
|
||||
## M13 Codex optimization
|
||||
|
||||
For the first serious Codex build run, use:
|
||||
|
||||
- `prompts/codex/m13/DAY_1_OPTIMIZED_MASTER_PROMPT.md`
|
||||
|
||||
Codex should also use the relevant reusable skill under `skills/` for each implementation pass. Validate the optimization assets with:
|
||||
|
||||
```bash
|
||||
make m13
|
||||
```
|
||||
|
||||
The full readiness path remains:
|
||||
|
||||
```bash
|
||||
make readiness
|
||||
```
|
||||
|
||||
|
||||
## M14 Build Launch
|
||||
|
||||
For the first serious implementation run, use:
|
||||
|
||||
- `docs/40-build-launch/SPRINT_1_SCOPE_FREEZE.md`
|
||||
- `docs/40-build-launch/BUILD_SUCCESS_DEFINITION.md`
|
||||
- `docs/40-build-launch/CODEX_STOP_RULES.md`
|
||||
- `prompts/codex/m14/CODEX_FIRST_DAY_MASTER_PROMPT.md`
|
||||
|
||||
Validate launch assets with:
|
||||
|
||||
```bash
|
||||
make m14
|
||||
```
|
||||
|
||||
Full readiness remains:
|
||||
|
||||
```bash
|
||||
make readiness
|
||||
```
|
||||
|
||||
## Sprint 1 execution (Sprint 1 only)
|
||||
|
||||
From a clean machine:
|
||||
|
||||
```bash
|
||||
cd backend && python -m pip install -e .[dev]
|
||||
cd ..
|
||||
make backend-install
|
||||
make frontend-install
|
||||
make readiness
|
||||
```
|
||||
|
||||
Copy `.env.example` to `.env` only when you want local overrides. Docker Compose has safe defaults for the local PostGIS/backend/frontend stack and does not require a root `.env` file to exist.
|
||||
|
||||
With Docker Compose, open the workbench at `http://localhost:1202`.
|
||||
|
||||
The Docker frontend is served by nginx and proxies `/api` and `/health` to the backend container, so browser clients should use the frontend URL only, for example `http://192.168.10.150:1202` on a LAN host.
|
||||
|
||||
Runtime containers include healthchecks for PostGIS, backend and frontend. After
|
||||
startup, inspect them with:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Verify the browser-facing API proxy after rebuilding Docker images:
|
||||
|
||||
```bash
|
||||
bash scripts/verify_browser_runtime.sh http://localhost:1202 http://localhost:8000/health
|
||||
```
|
||||
|
||||
Verify the Docker GIS runtime after rebuilding the backend image:
|
||||
|
||||
```bash
|
||||
bash scripts/verify_gis_runtime.sh http://localhost:1202
|
||||
```
|
||||
|
||||
On the LAN host use the published browser URL, for example:
|
||||
|
||||
```bash
|
||||
bash scripts/verify_gis_runtime.sh http://192.168.10.150:1202
|
||||
```
|
||||
|
||||
Load the explicit offline demo workflow:
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.10.150:1202/api/v1/demo/workflow
|
||||
```
|
||||
|
||||
If `/api/v1/projects` returns frontend HTML instead of a JSON envelope, rebuild
|
||||
and restart the frontend container.
|
||||
|
||||
Useful direct verification commands:
|
||||
|
||||
```bash
|
||||
python -m compileall backend/app
|
||||
cd backend && python -c "from app.main import app; print(app.title)"
|
||||
python -m pytest
|
||||
cd ../frontend && npm run typecheck
|
||||
cd ../frontend && npm run build
|
||||
docker compose config
|
||||
bash scripts/run_readiness_check.sh
|
||||
```
|
||||
|
||||
If `make` or `docker` are unavailable in your shell, run the equivalent script entrypoints directly:
|
||||
|
||||
```bash
|
||||
bash scripts/backend_install.sh
|
||||
bash scripts/backend_test.sh
|
||||
bash scripts/frontend_install.sh
|
||||
bash scripts/frontend_typecheck.sh
|
||||
bash scripts/frontend_build.sh
|
||||
bash scripts/run_readiness_check.sh
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@@ -1 +0,0 @@
|
||||
1.0.0
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,10 +0,0 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
geointel_backend.egg-info
|
||||
storage
|
||||
dist
|
||||
node_modules
|
||||
.env
|
||||
@@ -1,36 +0,0 @@
|
||||
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"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
[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
|
||||
@@ -1,48 +0,0 @@
|
||||
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()
|
||||
@@ -1,20 +0,0 @@
|
||||
"""
|
||||
${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"}
|
||||
@@ -1,108 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,38 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,76 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,51 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,72 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,22 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,65 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,3 +0,0 @@
|
||||
from app.models.entities import AnalysisRun, Area, Dataset, Export, Project
|
||||
|
||||
__all__ = ["AnalysisRun", "Area", "Dataset", "Export", "Project"]
|
||||
@@ -1 +0,0 @@
|
||||
__all__ = ["analysis", "areas", "assistant", "auth", "datasets", "health", "projects", "exports", "jobs", "external", "qa", "temporal"]
|
||||
@@ -1,42 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 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,
|
||||
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)
|
||||
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
|
||||
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,
|
||||
include_unchanged=payload.include_unchanged,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
return envelope(job)
|
||||
@@ -1,58 +0,0 @@
|
||||
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))
|
||||
@@ -1,76 +0,0 @@
|
||||
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))
|
||||
@@ -1,50 +0,0 @@
|
||||
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())
|
||||
@@ -1,180 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
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.demo_workflow_service import DemoWorkflowService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
COOKIE_NAME = "geointel_session"
|
||||
|
||||
|
||||
def _session_from_principal(principal: AuthPrincipal, *, guest_access_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,
|
||||
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
|
||||
if not settings.auth_enabled:
|
||||
return AuthSession(
|
||||
authentication_required=False,
|
||||
authenticated=True,
|
||||
guest_access_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,
|
||||
)
|
||||
return _session_from_principal(
|
||||
principal,
|
||||
guest_access_enabled=guest_access_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _set_session_cookie(
|
||||
*,
|
||||
request: Request,
|
||||
response: Response,
|
||||
token: str,
|
||||
max_age: int,
|
||||
) -> None:
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
secure=forwarded_proto == "https" or request.url.scheme == "https",
|
||||
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,
|
||||
)
|
||||
client_host = request.client.host if request.client else "unknown"
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
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())
|
||||
@@ -1,197 +0,0 @@
|
||||
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 (
|
||||
AnalysisQaResponse,
|
||||
DetectionListResponse,
|
||||
DetectionModelsResponse,
|
||||
DetectionQaRequest,
|
||||
DetectionRead,
|
||||
DetectionRunListResponse,
|
||||
DetectionRunRead,
|
||||
DetectionRunRequest,
|
||||
DetectionRunResponse,
|
||||
Envelope,
|
||||
GeoJsonFeatureCollection,
|
||||
ModelAssetListResponse,
|
||||
YoloPreflightResponse,
|
||||
)
|
||||
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,
|
||||
) -> dict:
|
||||
return envelope(
|
||||
YoloPreflightService.run(
|
||||
tile_manifest_path=tile_manifest_path,
|
||||
check_model_load=check_model_load,
|
||||
model_asset_id=model_asset_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_model=Envelope[DetectionRunResponse])
|
||||
def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
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.get("/runs", response_model=Envelope[DetectionRunListResponse])
|
||||
def list_detection_runs(
|
||||
project_id: UUID | None = None,
|
||||
dataset_id: UUID | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(DetectionService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
|
||||
def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(DetectionService.get_run(db, analysis_run_id).model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/detections",
|
||||
response_model=Envelope[DetectionListResponse],
|
||||
)
|
||||
def list_detection_run_detections(
|
||||
analysis_run_id: UUID,
|
||||
dataset_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
DetectionService.list_detections(
|
||||
db,
|
||||
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}/detections",
|
||||
response_model=Envelope[DetectionListResponse],
|
||||
)
|
||||
def list_dataset_detections(
|
||||
dataset_id: UUID,
|
||||
analysis_run_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
DetectionService.list_detections(
|
||||
db,
|
||||
analysis_run_id=analysis_run_id,
|
||||
dataset_id=dataset_id,
|
||||
class_name=class_name,
|
||||
min_confidence=min_confidence,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
|
||||
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(DetectionService.get_detection(db, detection_id).model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/geojson",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def get_detection_run_geojson(
|
||||
analysis_run_id: UUID,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
DetectionService.detections_to_geojson(
|
||||
db,
|
||||
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,
|
||||
analysis_run_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
DetectionService.detections_to_geojson(
|
||||
db,
|
||||
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_detection_run_with_reference(
|
||||
analysis_run_id: UUID,
|
||||
payload: DetectionQaRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.utils.response import envelope
|
||||
|
||||
router = APIRouter(prefix="/exports", tags=["exports"])
|
||||
|
||||
|
||||
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
|
||||
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
|
||||
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).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, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/report", response_model=Envelope[ExportCreateResponse])
|
||||
def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
|
||||
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
|
||||
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,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{export_id}", response_model=Envelope[ExportRead])
|
||||
def get_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{export_id}/download")
|
||||
def download_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||
path = ExportService.get_export_download_path(db, export_id)
|
||||
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
|
||||
return FileResponse(path, filename=path.name, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
|
||||
def get_export_content(export_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
|
||||
@@ -1,185 +0,0 @@
|
||||
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())
|
||||
@@ -1,170 +0,0 @@
|
||||
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"
|
||||
postgis_version = connection.execute(
|
||||
text("SELECT PostGIS_Version()")
|
||||
).scalar_one()
|
||||
checks["postgis"] = f"ok:{postgis_version}"
|
||||
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"] = f"ok:{database_head}"
|
||||
else:
|
||||
checks["migration"] = (
|
||||
f"degraded:database={database_head};"
|
||||
f"expected={','.join(expected_heads) or 'none'}"
|
||||
)
|
||||
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=settings.app_version,
|
||||
build_sha=settings.build_sha,
|
||||
build_time=settings.build_time,
|
||||
database=checks["database"],
|
||||
postgis=checks["postgis"],
|
||||
migration=checks["migration"],
|
||||
storage=checks["storage"],
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/live", response_model=HealthResponse)
|
||||
def liveness() -> HealthResponse:
|
||||
settings = get_settings()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service="geointel-backend",
|
||||
version=settings.app_version,
|
||||
build_sha=settings.build_sha,
|
||||
build_time=settings.build_time,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -1,67 +0,0 @@
|
||||
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())
|
||||
@@ -1,88 +0,0 @@
|
||||
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})
|
||||
@@ -1,83 +0,0 @@
|
||||
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)
|
||||
@@ -1,93 +0,0 @@
|
||||
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,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(QualityEvidenceService.evidence_geojson(db, project_id=project_id, quality_check_id=quality_check_id))
|
||||
|
||||
|
||||
@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()
|
||||
)
|
||||
@@ -1,172 +0,0 @@
|
||||
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 (
|
||||
AnalysisQaResponse,
|
||||
Envelope,
|
||||
GeoJsonFeatureCollection,
|
||||
SegmentationListResponse,
|
||||
SegmentationModelsResponse,
|
||||
SegmentationQaRequest,
|
||||
SegmentationRead,
|
||||
SegmentationRunListResponse,
|
||||
SegmentationRunRead,
|
||||
SegmentationRunRequest,
|
||||
SegmentationRunResponse,
|
||||
)
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
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, db: Session = Depends(get_db)) -> dict:
|
||||
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.get("/runs", response_model=Envelope[SegmentationRunListResponse])
|
||||
def list_segmentation_runs(
|
||||
project_id: UUID | None = None,
|
||||
dataset_id: UUID | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(SegmentationService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
|
||||
def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/segmentations",
|
||||
response_model=Envelope[SegmentationListResponse],
|
||||
)
|
||||
def list_segmentation_run_outputs(
|
||||
analysis_run_id: UUID,
|
||||
dataset_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
SegmentationService.list_segmentations(
|
||||
db,
|
||||
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,
|
||||
analysis_run_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
SegmentationService.list_segmentations(
|
||||
db,
|
||||
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, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/geojson",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def get_segmentation_run_geojson(
|
||||
analysis_run_id: UUID,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
SegmentationService.segmentations_to_geojson(
|
||||
db,
|
||||
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,
|
||||
analysis_run_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
SegmentationService.segmentations_to_geojson(
|
||||
db,
|
||||
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,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -1,87 +0,0 @@
|
||||
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))
|
||||
@@ -1,34 +0,0 @@
|
||||
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())
|
||||
@@ -1,442 +0,0 @@
|
||||
from pydantic import 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_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")
|
||||
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",
|
||||
)
|
||||
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")
|
||||
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_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_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")
|
||||
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_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_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")
|
||||
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")
|
||||
if self.guest_access_enabled and not self.auth_enabled:
|
||||
raise ValueError("GEOINTEL_GUEST_ACCESS_ENABLED requires GEOINTEL_AUTH_ENABLED=true")
|
||||
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()
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
@@ -1,4 +0,0 @@
|
||||
from .base import Base
|
||||
from .session import get_db, get_engine
|
||||
|
||||
__all__ = ["Base", "get_db", "get_engine"]
|
||||
@@ -1,5 +0,0 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,357 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.routes import analysis, aoi_operations, areas, assistant, auth, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.logging import configure_logging
|
||||
from app.core.request_context import reset_request_id, set_request_id
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.runtime_reconciliation_service import RuntimeReconciliationService
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.aoi_operation_worker import AoiOperationWorker
|
||||
|
||||
|
||||
logger = logging.getLogger("geointel")
|
||||
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _to_error_payload(
|
||||
code: str,
|
||||
message: str,
|
||||
details: dict | list | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"error": code,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level, settings.sql_log_level)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
worker_stop = asyncio.Event()
|
||||
worker_task = None
|
||||
if settings.reconcile_interrupted_runs_on_startup:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = RuntimeReconciliationService.reconcile(db)
|
||||
logger.info(
|
||||
"Runtime reconciliation completed: jobs=%s analysis_runs=%s resumed_aoi_partitions=%s exhausted_aoi_partitions=%s",
|
||||
result.interrupted_jobs,
|
||||
result.interrupted_analysis_runs,
|
||||
result.resumed_aoi_partitions,
|
||||
result.exhausted_aoi_partitions,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Runtime reconciliation failed")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
if settings.aoi_worker_enabled:
|
||||
worker_task = asyncio.create_task(AoiOperationWorker.run(worker_stop, settings.aoi_worker_poll_seconds))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
worker_stop.set()
|
||||
if worker_task is not None:
|
||||
await worker_task
|
||||
|
||||
app = FastAPI(
|
||||
title="GeoIntel",
|
||||
version=settings.app_version,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(analysis.router, prefix=settings.api_prefix)
|
||||
app.include_router(aoi_operations.router, prefix=settings.api_prefix)
|
||||
app.include_router(projects.router, prefix=settings.api_prefix)
|
||||
app.include_router(areas.router, prefix=settings.api_prefix)
|
||||
app.include_router(datasets.router, prefix=settings.api_prefix)
|
||||
app.include_router(jobs.router, prefix=settings.api_prefix)
|
||||
app.include_router(quality_checks.router, prefix=settings.api_prefix)
|
||||
app.include_router(exports.router, prefix=settings.api_prefix)
|
||||
app.include_router(external.router, prefix=settings.api_prefix)
|
||||
app.include_router(demo.router, prefix=settings.api_prefix)
|
||||
app.include_router(qa.router, prefix=settings.api_prefix)
|
||||
app.include_router(detection.router, prefix=settings.api_prefix)
|
||||
app.include_router(segmentation.router, prefix=settings.api_prefix)
|
||||
app.include_router(selection_partitions.router, prefix=settings.api_prefix)
|
||||
app.include_router(temporal.router, prefix=settings.api_prefix)
|
||||
app.include_router(assistant.router, prefix=settings.api_prefix)
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_identity(request: Request, call_next):
|
||||
supplied_request_id = request.headers.get("x-request-id", "")
|
||||
request_id = supplied_request_id if SAFE_REQUEST_ID.fullmatch(supplied_request_id) else str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
token = set_request_id(request_id)
|
||||
started_at = time.perf_counter()
|
||||
raw_path = str(request.scope.get("path") or "")
|
||||
try:
|
||||
host = request.headers.get("host", "")
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if not raw_path.startswith("/") or not host or UNSAFE_HOST.search(host):
|
||||
response = JSONResponse(
|
||||
status_code=400,
|
||||
content=_to_error_payload(
|
||||
"INVALID_REQUEST_TARGET",
|
||||
"The request target or Host header is invalid",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
if content_type == "application/x-www-form-urlencoded":
|
||||
response = JSONResponse(
|
||||
status_code=415,
|
||||
content=_to_error_payload(
|
||||
"UNSUPPORTED_CONTENT_TYPE",
|
||||
"URL-encoded form bodies are not supported",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
public_auth_paths = {
|
||||
f"{settings.api_prefix}/auth/session",
|
||||
f"{settings.api_prefix}/auth/login",
|
||||
f"{settings.api_prefix}/auth/guest",
|
||||
f"{settings.api_prefix}/auth/logout",
|
||||
}
|
||||
direct_loopback_request = (
|
||||
request.client is not None
|
||||
and request.client.host in {"127.0.0.1", "::1"}
|
||||
and not request.headers.get("x-real-ip")
|
||||
and not request.headers.get("x-forwarded-for")
|
||||
)
|
||||
if (
|
||||
settings.auth_enabled
|
||||
and raw_path.startswith(f"{settings.api_prefix}/")
|
||||
and raw_path not in public_auth_paths
|
||||
and not direct_loopback_request
|
||||
):
|
||||
principal = AuthService.verify_session_token(
|
||||
request.cookies.get(auth.COOKIE_NAME),
|
||||
settings,
|
||||
)
|
||||
if principal is None:
|
||||
response = JSONResponse(
|
||||
status_code=401,
|
||||
content=_to_error_payload(
|
||||
"AUTHENTICATION_REQUIRED",
|
||||
"Meld u aan om de GeoIntel API te gebruiken.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
request.state.auth_principal = principal
|
||||
if principal.role == "guest":
|
||||
project_path_prefix = f"{settings.api_prefix}/projects/"
|
||||
guest_project_root = f"{project_path_prefix}{principal.project_id}"
|
||||
if raw_path.startswith(project_path_prefix):
|
||||
scoped_path = raw_path[len(project_path_prefix):]
|
||||
requested_project_id = scoped_path.split("/", 1)[0]
|
||||
if str(principal.project_id) != requested_project_id:
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
query_project_id = request.query_params.get("project_id")
|
||||
if query_project_id and query_project_id != str(principal.project_id):
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
guest_safe_read_paths = {
|
||||
f"{settings.api_prefix}/projects",
|
||||
f"{settings.api_prefix}/external/providers",
|
||||
}
|
||||
normalized_path = raw_path.rstrip("/") or "/"
|
||||
guest_project_read = (
|
||||
normalized_path == guest_project_root
|
||||
or normalized_path.startswith(f"{guest_project_root}/")
|
||||
)
|
||||
is_read_request = request.method in {"GET", "HEAD", "OPTIONS"}
|
||||
if is_read_request:
|
||||
if normalized_path not in guest_safe_read_paths and not guest_project_read:
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_ROUTE_NOT_AVAILABLE",
|
||||
"Deze API-route maakt geen deel uit van de afgeschermde GeoIntel-demo.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
else:
|
||||
guest_safe_post_paths = {
|
||||
f"{settings.api_prefix}/demo/workflow",
|
||||
f"{settings.api_prefix}/external/coverage/resolve",
|
||||
}
|
||||
guest_safe_post_suffixes = (
|
||||
"/vector/select",
|
||||
"/raster/bathymetry/select",
|
||||
"/raster/terrain/select",
|
||||
"/raster/flood-hazard/select",
|
||||
"/raster/thematic/select",
|
||||
"/raster/walous/select",
|
||||
"/temporal/compare",
|
||||
"/datasets/vector/partitions/select",
|
||||
"/datasets/bathymetry/profiles/partitions/select",
|
||||
)
|
||||
is_guest_safe_post = request.method == "POST" and (
|
||||
raw_path in guest_safe_post_paths
|
||||
or (
|
||||
raw_path.startswith(project_path_prefix)
|
||||
and raw_path.endswith(guest_safe_post_suffixes)
|
||||
)
|
||||
)
|
||||
if not is_guest_safe_post:
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_READ_ONLY",
|
||||
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request_id
|
||||
logger.info(
|
||||
"request_complete request_id=%s method=%s path=%s status=%s duration_ms=%.1f",
|
||||
request_id,
|
||||
request.method,
|
||||
raw_path,
|
||||
response.status_code,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
reset_request_id(token)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error(request: Request, exc: AppError): # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=_to_error_payload(
|
||||
exc.code,
|
||||
exc.message,
|
||||
exc.details,
|
||||
request_id=request.state.request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(request: Request, exc: HTTPException): # noqa: ARG001
|
||||
code = "HTTP_ERROR"
|
||||
message = str(exc.detail)
|
||||
details = {}
|
||||
if isinstance(exc.detail, dict):
|
||||
code = str(exc.detail.get("error") or exc.detail.get("code") or code)
|
||||
message = str(exc.detail.get("message") or message)
|
||||
raw_details = exc.detail.get("details")
|
||||
details = raw_details if isinstance(raw_details, (dict, list)) else {}
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=_to_error_payload(
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
request_id=request.state.request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(request: Request, exc: RequestValidationError): # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=_to_error_payload(
|
||||
"VALIDATION_ERROR",
|
||||
"Validation failed",
|
||||
exc.errors(),
|
||||
request_id=request.state.request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unexpected_error(request: Request, exc: Exception):
|
||||
logger.exception(
|
||||
"Unhandled request error request_id=%s method=%s path=%s",
|
||||
request.state.request_id,
|
||||
request.method,
|
||||
str(request.scope.get("path") or ""),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=_to_error_payload(
|
||||
"INTERNAL_ERROR",
|
||||
"Unexpected server error",
|
||||
{"type": exc.__class__.__name__},
|
||||
request_id=request.state.request_id,
|
||||
),
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.app_env == "development",
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
from app.models import *
|
||||
@@ -1,19 +0,0 @@
|
||||
from .entities import AoiOperation, AoiOperationPartition, AnalysisRun, Area, Dataset, DatasetVersion, Detection, DetectionReview, Export, Job, Metric, Project, QualityCheck, Segmentation, VectorFeature
|
||||
|
||||
__all__ = [
|
||||
"AnalysisRun",
|
||||
"AoiOperation",
|
||||
"AoiOperationPartition",
|
||||
"Area",
|
||||
"Dataset",
|
||||
"DatasetVersion",
|
||||
"Detection",
|
||||
"DetectionReview",
|
||||
"Export",
|
||||
"Job",
|
||||
"Metric",
|
||||
"Project",
|
||||
"QualityCheck",
|
||||
"Segmentation",
|
||||
"VectorFeature",
|
||||
]
|
||||
@@ -1,423 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Float, Index, JSON, String, Text, UniqueConstraint, func, text
|
||||
from sqlalchemy.sql.sqltypes import Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
region: Mapped[str] = mapped_column(String(120), default="Belgium and Belgian North Sea")
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
areas: Mapped[list["Area"]] = relationship("Area", back_populates="project", cascade="all, delete-orphan")
|
||||
datasets: Mapped[list["Dataset"]] = relationship("Dataset", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Area(Base):
|
||||
__tablename__ = "areas"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326), nullable=False)
|
||||
original_crs: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
area_m2: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bbox: Mapped[str | None] = mapped_column(Geometry("Polygon", srid=4326), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
project: Mapped[Project] = relationship("Project", back_populates="areas")
|
||||
|
||||
|
||||
class Dataset(Base):
|
||||
__tablename__ = "datasets"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||
name="ck_datasets_temporal_valid_range",
|
||||
),
|
||||
Index(
|
||||
"ix_datasets_project_temporal_series_observed",
|
||||
"project_id",
|
||||
"temporal_series_key",
|
||||
"observed_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
area_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("areas.id", ondelete="SET NULL"), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
dataset_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
original_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
stored_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
content_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
derived_from_dataset_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("datasets.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
crs: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
bounds_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
resolution_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
bands_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
dataset_role: Mapped[str] = mapped_column(String(32), nullable=False, default="source", server_default="source")
|
||||
source_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
reference_layer_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
source_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
provenance_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
imported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
temporal_series_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
temporal_granularity: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
source_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="uploaded")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
project: Mapped[Project] = relationship("Project", back_populates="datasets")
|
||||
versions: Mapped[list["DatasetVersion"]] = relationship(
|
||||
"DatasetVersion",
|
||||
back_populates="dataset",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
vector_features: Mapped[list["VectorFeature"]] = relationship(
|
||||
"VectorFeature",
|
||||
back_populates="dataset",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class DatasetVersion(Base):
|
||||
__tablename__ = "dataset_versions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||
name="ck_dataset_versions_temporal_valid_range",
|
||||
),
|
||||
Index("ix_dataset_versions_dataset_version", "dataset_id", "version", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
source_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
provenance_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
dataset: Mapped[Dataset] = relationship("Dataset", back_populates="versions")
|
||||
|
||||
|
||||
class VectorFeature(Base):
|
||||
__tablename__ = "vector_features"
|
||||
__table_args__ = (
|
||||
Index("ix_vector_features_dataset_id", "dataset_id"),
|
||||
Index("ix_vector_features_geometry", "geometry", postgresql_using="gist"),
|
||||
Index("ix_vector_features_dataset_source_feature", "dataset_id", "source_feature_id"),
|
||||
Index(
|
||||
"ix_vector_features_dataset_municipality",
|
||||
"dataset_id",
|
||||
text("(properties_json ->> 'municipality')"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False)
|
||||
feature_class: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
source_feature_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
properties_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
geometry: Mapped[str] = mapped_column(Geometry("Geometry", srid=4326, spatial_index=False), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
dataset: Mapped[Dataset] = relationship("Dataset", back_populates="vector_features")
|
||||
|
||||
|
||||
class AnalysisRun(Base):
|
||||
__tablename__ = "analysis_runs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
area_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("areas.id", ondelete="SET NULL"), nullable=True)
|
||||
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
analysis_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
model_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
model_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
parameters_json: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Detection(Base):
|
||||
__tablename__ = "detections"
|
||||
__table_args__ = (
|
||||
Index("ix_detections_project_id", "project_id"),
|
||||
Index("ix_detections_dataset_id", "dataset_id"),
|
||||
Index("ix_detections_analysis_run_id", "analysis_run_id"),
|
||||
Index("ix_detections_class_name", "class_name"),
|
||||
Index("ix_detections_geometry", "geometry", postgresql_using="gist"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
model_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
class_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
geometry: Mapped[str] = mapped_column(Geometry("Geometry", srid=4326, spatial_index=False), nullable=False)
|
||||
bbox_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
source_tile_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
properties_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Segmentation(Base):
|
||||
__tablename__ = "segmentations"
|
||||
__table_args__ = (
|
||||
Index("ix_segmentations_project_id", "project_id"),
|
||||
Index("ix_segmentations_dataset_id", "dataset_id"),
|
||||
Index("ix_segmentations_analysis_run_id", "analysis_run_id"),
|
||||
Index("ix_segmentations_job_id", "job_id"),
|
||||
Index("ix_segmentations_class_name", "class_name"),
|
||||
Index("ix_segmentations_geometry", "geometry", postgresql_using="gist"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
model_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
class_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False)
|
||||
bbox_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
area_m2: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
mask_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_tile_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
tile_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
properties_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
provenance_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class QualityCheck(Base):
|
||||
__tablename__ = "quality_checks"
|
||||
__table_args__ = (
|
||||
Index("ix_quality_checks_project_id", "project_id"),
|
||||
Index("ix_quality_checks_reference_dataset_id", "reference_dataset_id"),
|
||||
Index("ix_quality_checks_candidate_dataset_id", "candidate_dataset_id"),
|
||||
Index("ix_quality_checks_analysis_run_id", "analysis_run_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||
candidate_dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
reference_dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False)
|
||||
check_type: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
parameters_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
findings_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Metric(Base):
|
||||
__tablename__ = "metrics"
|
||||
__table_args__ = (
|
||||
Index("ix_metrics_quality_check_id", "quality_check_id"),
|
||||
Index("ix_metrics_analysis_run_id", "analysis_run_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
quality_check_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("quality_checks.id", ondelete="CASCADE"), nullable=True)
|
||||
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||
metric_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
metric_value: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
metric_unit: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
label: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DetectionReview(Base):
|
||||
__tablename__ = "detection_reviews"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"evidence_role IN ('false_positive', 'false_negative')",
|
||||
name="ck_detection_reviews_evidence_role",
|
||||
),
|
||||
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",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"quality_check_id",
|
||||
"evidence_role",
|
||||
"evidence_feature_id",
|
||||
name="uq_detection_reviews_evidence",
|
||||
),
|
||||
Index("ix_detection_reviews_project_id", "project_id"),
|
||||
Index("ix_detection_reviews_quality_check_id", "quality_check_id"),
|
||||
Index("ix_detection_reviews_analysis_run_id", "analysis_run_id"),
|
||||
Index("ix_detection_reviews_decision", "decision"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
quality_check_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("quality_checks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("analysis_runs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
evidence_role: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
evidence_feature_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
detection_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("detections.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
reference_feature_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("vector_features.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
decision: Mapped[str] = mapped_column(String(64), nullable=False, default="unreviewed", server_default="unreviewed")
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reviewed_by: Mapped[str] = mapped_column(String(120), nullable=False, default="operator", server_default="operator")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Export(Base):
|
||||
__tablename__ = "exports"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||
export_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
job_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
input_dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
output_dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||
parameters_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AoiOperation(Base):
|
||||
__tablename__ = "aoi_operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('queued', 'running', 'partial', 'success', 'failed', 'cancelled')",
|
||||
name="ck_aoi_operations_status",
|
||||
),
|
||||
Index("ix_aoi_operations_project_status", "project_id", "status"),
|
||||
Index("ix_aoi_operations_geometry", "geometry", postgresql_using="gist"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
area_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("areas.id", ondelete="SET NULL"), nullable=True)
|
||||
parent_job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
operation_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False)
|
||||
request_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
plan_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AoiOperationPartition(Base):
|
||||
__tablename__ = "aoi_operation_partitions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('queued', 'running', 'success', 'failed', 'skipped')",
|
||||
name="ck_aoi_operation_partitions_status",
|
||||
),
|
||||
UniqueConstraint("operation_id", "partition_key", name="uq_aoi_operation_partition_key"),
|
||||
Index("ix_aoi_operation_partitions_operation_status", "operation_id", "status"),
|
||||
Index("ix_aoi_operation_partitions_geometry", "geometry", postgresql_using="gist"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
operation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("aoi_operations.id", ondelete="CASCADE"), nullable=False)
|
||||
child_job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
partition_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
product_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
checkpoint_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user