Publish curated GeoIntel source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:16:56 +02:00
commit 0aae654d6e
764 files changed
+169154

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
# Build context exclusions for deploy/unraid/Dockerfile.all-in-one.
# Everything listed here is NOT sent to the Docker daemon.
# Keep this aggressive: the all-in-one image only needs
# backend/, frontend/, fixtures/, scripts/, deploy/ and VERSION.
.git
.gitea
.github
.venv
venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
# Node dependencies at every level (root test harness + frontend).
node_modules
**/node_modules
frontend/node_modules
frontend/dist
frontend/*.tsbuildinfo
**/*.tsbuildinfo
frontend-src.tar.gz
backend/.pytest_cache
backend/**/*.pyc
backend/**/__pycache__
# Accidental self-copy of the repository into itself.
# Without this the build context is duplicated and the build stalls.
/geointel
# Local work products, never needed inside the image.
.codex-input
test-results
playwright-report
artifacts
RELEASE_NOTES
adr
docs
checklists
*.log
# Runtime data and secrets: mounted at runtime, never baked in.
storage
postgres-data
backups
datasets/raw
datasets/processed
datasets/cache
exports
models
.env
.env.*
!.env.example
+209
View File
@@ -0,0 +1,209 @@
# Backend
GEOINTEL_ENV=development
GEOINTEL_API_PREFIX=/api/v1
DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1
STORAGE_ROOT=./storage
MAX_UPLOAD_MB=500
GEOINTEL_MAX_IN_MEMORY_VECTOR_MB=64
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
# Optional single-operator access gate. Store only a PBKDF2-SHA256 hash and
# a unique 32+ character signing secret. Guest access is opt-in and should be
# enabled only on a dedicated demo installation with bounded public data.
GEOINTEL_AUTH_ENABLED=false
GEOINTEL_AUTH_REQUIRE_HTTPS=false
GEOINTEL_AUTH_USERNAME=
GEOINTEL_AUTH_PASSWORD_HASH=
GEOINTEL_AUTH_SESSION_SECRET=
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
GEOINTEL_PUBLIC_BASE_URL=http://localhost:1202
GEOINTEL_AUTHENTIK_ISSUER=
GEOINTEL_AUTHENTIK_CLIENT_ID=
GEOINTEL_AUTHENTIK_CLIENT_SECRET=
GEOINTEL_AUTHENTIK_ALLOWED_EMAIL=
GEOINTEL_GUEST_ACCESS_ENABLED=false
GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer
BRUSSELS_ORTHOPHOTO_WMS_URL=https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows
ORTHOPHOTO_WMS_LAYER=Ortho
ORTHOPHOTO_RESOLUTION_M=1.0
ORTHOPHOTO_MIN_SIDE_M=128
ORTHOPHOTO_MAX_SIDE_M=1024
ORTHOPHOTO_CACHE_TTL_HOURS=24
SOURCE_CATALOG_PROBE_ENABLED=true
SOURCE_CATALOG_GRB_WFS_URL=https://geo.api.vlaanderen.be/GRB/wfs
GRB_ENABLED=true
GRB_OGC_API_URL=https://geo.api.vlaanderen.be/GRB/ogc/features/v1
GRB_MIN_SIDE_M=10
GRB_MAX_SIDE_M=20000
GRB_PAGE_SIZE=1000
GRB_MAX_PAGES=200
GRB_MAX_FEATURES=150000
GRB_TIMEOUT_SECONDS=180
GRB_MAX_RESPONSE_MB=20
GRB_MAX_TOTAL_RESPONSE_MB=256
GRB_CACHE_TTL_HOURS=24
OFFICIAL_VECTOR_ENABLED=true
BWK_WFS_URL=https://geo.api.vlaanderen.be/BWK/wfs
DOV_SOIL_WFS_URL=https://www.dov.vlaanderen.be/geoserver/wfs
SPW_PICC_ENABLED=true
SPW_PICC_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/TOPOGRAPHIE/PICC_VDIFF/MapServer
SPW_FLOOD_HAZARD_ENABLED=true
SPW_FLOOD_HAZARD_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/EAU/ALEA_INOND/MapServer
URBIS_ENABLED=true
URBIS_WFS_URL=https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows
OFFICIAL_VECTOR_MIN_SIDE_M=10
OFFICIAL_VECTOR_MAX_SIDE_M=20000
OFFICIAL_VECTOR_PAGE_SIZE=1000
OFFICIAL_VECTOR_MAX_PAGES=200
OFFICIAL_VECTOR_MAX_FEATURES=100000
OFFICIAL_VECTOR_TIMEOUT_SECONDS=180
OFFICIAL_VECTOR_MAX_RESPONSE_MB=20
OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB=256
OFFICIAL_VECTOR_CACHE_TTL_HOURS=24
SOURCE_CATALOG_STATBEL_DCAT_URL=https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl
SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5
SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen
SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2
SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900
DHMV_ENABLED=true
DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs
DHMV_RESOLUTION_M=5.0
DHMV_MIN_SIDE_M=10
DHMV_MAX_SIDE_M=20000
DHMV_MAX_PIXELS=12000000
DHMV_TIMEOUT_SECONDS=300
DHMV_MAX_RESPONSE_MB=160
FLOOD_HAZARD_ENABLED=true
FLOOD_HAZARD_WCS_URL=https://geoservice.waterinfo.be/OGRK/wcs
FLOOD_HAZARD_RESOLUTION_M=5.0
FLOOD_HAZARD_MIN_SIDE_M=10
FLOOD_HAZARD_MAX_SIDE_M=20000
FLOOD_HAZARD_MAX_PIXELS=12000000
FLOOD_HAZARD_TIMEOUT_SECONDS=300
FLOOD_HAZARD_MAX_RESPONSE_MB=160
BATHYMETRY_PROFILES_ENABLED=true
BATHYMETRY_PROFILES_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0
BATHYMETRY_WATERCOURSE_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1
BATHYMETRY_PROFILES_PAGE_SIZE=1000
BATHYMETRY_PROFILES_MAX_PAGES=200
BATHYMETRY_PROFILES_MAX_FEATURES=50000
BATHYMETRY_PROFILES_TIMEOUT_SECONDS=120
BATHYMETRY_PROFILES_MAX_RESPONSE_MB=32
MDK_BATHYMETRY_PROBE_ENABLED=true
MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
# Bounded MDK acquisition stays fail-closed until the readiness probe reports
# "reachable" and an advertised coverage id is configured explicitly.
MDK_BATHYMETRY_ACQUISITION_ENABLED=false
MDK_BATHYMETRY_COVERAGE_ID=
MDK_BATHYMETRY_REQUEST_CRS=EPSG:4326
MDK_BATHYMETRY_MAX_BBOX_DEG2=0.25
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS=120
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB=160
THEMATIC_RASTER_ENABLED=true
THEMATIC_RASTER_WCS_URL=https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs
THEMATIC_RASTER_MIN_SIDE_M=100
THEMATIC_RASTER_MAX_SIDE_M=60000
THEMATIC_RASTER_MAX_PIXELS=30000000
THEMATIC_RASTER_TIMEOUT_SECONDS=300
THEMATIC_RASTER_MAX_RESPONSE_MB=160
WALOUS_ENABLED=true
WALOUS_SOURCE_DIR=/app/storage/source-cache/walous
WALOUS_ANALYSIS_RESOLUTION_M=10
WALOUS_MAX_SIDE_M=60000
WALOUS_MAX_PIXELS=36000000
YOLO_ENABLED=false
YOLO_MODELS_DIR=/app/models
YOLO_MODEL_PATH=
YOLO_MODEL_ID=yolo-configured
YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
YOLO_MODEL_VERSION=
YOLO_MODEL_CLASSES=building
YOLO_ENFORCE_VALIDATION_SCOPE=false
# Required when scope enforcement is enabled. The manifest is bound to exact
# model bytes and contains the allowed EPSG:4326 validation geometry.
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
# Deprecated display metadata; never used as an inference authorization gate.
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
YOLO_CONFIG_DIR=./storage/ultralytics
YOLO_DEVICE=cpu
YOLO_REQUIRE_CUDA=false
YOLO_IMAGE_SIZE=640
YOLO_MAX_TILES=100
YOLO_MAX_DETECTIONS=1000
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
# Tiles per model.predict call. 1 leaves most of a modern GPU idle on a run of
# a hundred tiles; raise it until VRAM is the limit.
YOLO_BATCH_SIZE=1
# Drop boxes truncated by an interior tile edge. Because tiles overlap, the
# neighbouring tile saw the same object whole, so the truncated half is a
# duplicate and a shape error at once. Boxes on the outer raster edge are kept.
YOLO_SUPPRESS_TILE_EDGE_DETECTIONS=true
# Intersection over the smaller box. scripts/evaluate_belgium_building_candidate.py
# freezes this during calibration (--containment-nms) before the protected test.
# Serving a promoted model at a different value means the runtime suppresses
# detections its gate counted, so set this to the value the candidate was gated at.
YOLO_CONTAINMENT_NMS_THRESHOLD=0.85
# Segmentation carries its own value: masks and boxes overlap differently,
# so one threshold need not fit both.
SEGMENTATION_CONTAINMENT_NMS_THRESHOLD=0.85
# Local segmentation models. GeoIntel never downloads model weights
# automatically; point these to existing local files to enable inference.
YOLO_SEG_ENABLED=false
YOLO_SEG_MODEL_PATH=
YOLO_SEG_MODEL_ID=yolo-seg-configured
YOLO_SEG_MODEL_DISPLAY_NAME=Configured YOLO segmentation
YOLO_SEG_MODEL_VERSION=
SAM_ENABLED=false
SAM_MODEL_PATH=
SAM_MODEL_ID=sam-configured
SAM_MODEL_DISPLAY_NAME=Configured SAM segmentation
SAM_MODEL_VERSION=
SEGMENTATION_MAX_MASKS_PER_TILE=300
SEGMENTATION_DUPLICATE_IOU_THRESHOLD=0.5
ENABLE_GRB_WFS=false
GRB_WFS_URL=
OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter
# Install backend raster dependencies when needed:
# python -m pip install rasterio
# Frontend
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=http://localhost:8000
# Leave empty to use the local/demo OpenStreetMap fallback with visible attribution.
# Set this to a managed MapLibre style URL for production or heavier tile traffic.
VITE_MAP_STYLE_URL=
# Docker Compose / Unraid
GEOINTEL_FRONTEND_PORT=1202
GEOINTEL_BACKEND_PORT=8000
GEOINTEL_INSTALL_AI=false
GEOINTEL_STORAGE_PATH=./storage
GEOINTEL_BACKUPS_PATH=./backups
GEOINTEL_MODELS_PATH=./models
GEOINTEL_POSTGIS_DATA_PATH=./postgres-data
GEOINTEL_POSTGRES_DB=geointel
GEOINTEL_POSTGRES_USER=geointel
GEOINTEL_POSTGRES_PASSWORD=geointel
GEOINTEL_CORS_ORIGINS=https://geointel.example.com,http://localhost:1202,http://127.0.0.1:1202
GEOINTEL_MAX_UPLOAD_MB=500
GEOINTEL_AOI_WORKER_ENABLED=false
GEOINTEL_AOI_WORKER_POLL_SECONDS=2
# Executes queued detection.run / segmentation.run jobs from POST
# /detection/run-async, so tiled GPU inference never blocks an HTTP request.
GEOINTEL_ANALYSIS_WORKER_ENABLED=false
GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS=2
# Analysis consumes only artifacts under STORAGE_ROOT: a tile manifest path
# arrives in the request and a manifest entry may name an absolute tile
# path, so without this an API field is an unbounded filesystem reference.
# Provisioning workflows that stage tiles elsewhere before ingest can opt out.
GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS=false
+17
View File
@@ -0,0 +1,17 @@
*.sh text eol=lf
deploy/unraid/gosu-setpriv text eol=lf
*.py text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.ini text eol=lf
Dockerfile text eol=lf
*.md text eol=lf
*.tsx text eol=lf
*.ts text eol=lf
*.css text eol=lf
*.json text eol=lf
# Retained audit exports preserve source bytes, including line endings and
# tool-emitted whitespace.
artifacts/evidence/accuracy/** -text -whitespace
+68
View File
@@ -0,0 +1,68 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.env
.env.*
!.env.example
*.egg-info/
.pytest_cache/
.ruff_cache/
# Node
node_modules/
dist/
build/
*.tsbuildinfo
# Local source-transfer archives; preserve them outside version control.
/frontend-src.tar.gz
# Generated evidence, model output and operational snapshots stay outside Git.
/artifacts/
/.cache/
/datasets/raw/*
/datasets/processed/*
/datasets/cache/*
/storage/uploads/*
/storage/tiles/*
/storage/masks/*
/storage/reports/*
/storage/exports/*
/storage/rasters/*
/storage/models/*
/storage/operator-data/*
/storage/operator-evidence/*
/storage/release-evidence/*
/storage/previews/*
/storage/training/*
/storage/ultralytics/*
/exports/*
/models/*
/backend/storage/uploads/*
/backend/storage/tiles/*
/backend/storage/masks/*
/backend/storage/reports/*
/backend/storage/exports/*
/backups/*
/postgres-data/*
# Keep folder placeholders
!**/.gitkeep
!**/README.md
# Runtime-generated operator documentation is not repository documentation.
/storage/operator-data/README.md
# OS/editor
.DS_Store
.vscode/
.idea/
# Local investigation scratch is never repository input.
/.codex-input/
/.codex-artifacts/
/.playwright-mcp/
/.mcp.json
/data/
+13
View File
@@ -0,0 +1,13 @@
[extend]
useDefault = true
[[allowlists]]
description = "Public registry identifiers that resemble generic API keys"
regexTarget = "match"
regexes = [
'''key="population_density_2019"''',
'''source_key="inbo_bwk_natura2000"''',
'''product_key": "population_density_2019"''',
'''key="spw_flood_hazard_2021"''',
'''key="phase2-mutation"''',
]
+12
View File
@@ -0,0 +1,12 @@
# Contributing
Keep GeoIntel changes reproducible, privacy-preserving and free from generated workspace state.
- Branch from the protected default branch.
- Use synthetic or explicitly redistributable imagery, coordinates and datasets in tests and documentation.
- Do not commit `.codex-input` contents, SQLite/WAL/SHM files, archives, contact sheets, model outputs, private maps, credentials or local deployment data.
- Document the origin, license and intended use of every external dataset, model and media asset.
- Add tests for behavioural changes and run the documented backend, frontend, geospatial and managed validation gates.
- Explain data-model, coordinate-system, model, privacy and deployment impact in the pull request.
Report security issues according to `SECURITY.md`.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Jens / ITWorx.tech
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+50
View File
@@ -0,0 +1,50 @@
PYTHON_BIN := $(shell command -v python3 >/dev/null 2>&1 && echo python3 || echo python)
.PHONY: readiness docs fixtures preflight backend-install backend-test backend-dev frontend-install frontend-typecheck frontend-build m13 m14
readiness:
bash scripts/run_readiness_check.sh
backend-install:
cd backend && \
$(PYTHON_BIN) -m pip install -e .[dev]
backend-test:
cd backend && \
$(PYTHON_BIN) -m pytest
backend-dev:
cd backend && \
$(PYTHON_BIN) -m uvicorn app.main:app --reload
frontend-install:
cd frontend && \
npm install
frontend-typecheck:
cd frontend && \
npm run typecheck
frontend-build:
cd frontend && \
npm run build
docs:
$(PYTHON_BIN) scripts/smoke_docs.py
fixtures:
$(PYTHON_BIN) scripts/validate_fixtures.py
preflight:
bash scripts/codex_preflight.sh || true
$(PYTHON_BIN) scripts/preimplementation_audit.py
.PHONY: m13
m13:
$(PYTHON_BIN) scripts/validate_m13_codex_assets.py
.PHONY: m14
m14:
$(PYTHON_BIN) scripts/validate_m14_launch_assets.py
+3
View File
@@ -0,0 +1,3 @@
# Public source export
This source snapshot was generated from the private canonical repository at revision `ac977bcd4b7b08bcce38edac2bcb9b05da0555ed`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
# GeoIntel · public source
GeoIntel is a GeoAI workbench for Belgium and the Belgian North Sea. This repository is the curated public source snapshot. The private canonical repository contains development history and operational material; public updates are generated from reviewed source revisions.
The source includes a FastAPI backend, a React frontend, API contracts, tests, and synthetic geospatial fixtures. See `docker-compose.yml` and `.env.example` for local setup. AI models and external geospatial data providers must be configured separately.
Licensed under [LICENSE](LICENSE). Report vulnerabilities using [SECURITY.md](SECURITY.md).
+38
View File
@@ -0,0 +1,38 @@
# Security Policy
## Supported code
Security fixes target the current `main` release line. Historical research,
calibration and development branches are not supported release channels unless
explicitly documented otherwise.
## Reporting vulnerabilities
Report suspected security issues privately to `security@itworx.tech`. Do not put
credentials, access tokens, private infrastructure, precise sensitive
locations, proprietary imagery or datasets, model artifacts, production
database contents, personal data or exploit-sensitive evidence in a public
issue.
Include the affected commit and component, minimal reproduction conditions
using synthetic or public data where possible, expected and observed
behaviour, impact, and whether the issue affects ingestion, geospatial
processing, authentication or authorization, uploads, archive or path handling,
model inference, reports and exports, deployment, data retention or secrets.
## Repository and data boundary
Geospatial inputs and derived outputs can reveal people, assets and sensitive
locations. Treat screenshots, contact sheets, coordinates, source archives,
SQLite/WAL state, model output and exports as potentially sensitive. These
belong in controlled external storage, not the source repository.
Local Codex inputs, calibration databases, runtime data, generated reports and
cross-project scratch are not release source. Never commit live `.env` files,
private keys, production backups or databases, customer or operator data,
unpublished imagery, credentials or private datasets. Public examples and tests
must use synthetic or explicitly redistributable material.
## Disclosure
Coordinate remediation and disclosure with the repository owner before publishing details that would materially increase exploitation risk.
+1
View File
@@ -0,0 +1 @@
1.0.0
+12
View File
@@ -0,0 +1,12 @@
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
geointel_backend.egg-info
storage
dist
node_modules
.env
.env.*
!.env.example
View File
Whitespace-only changes.
+36
View File
@@ -0,0 +1,36 @@
FROM python:3.12-slim
WORKDIR /app
ARG GEOINTEL_INSTALL_AI=false
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
gdal-bin \
libgl1 \
libglib2.0-0 \
libgdal-dev \
libgeos-dev \
libproj-dev \
libpq-dev \
libsm6 \
libx11-6 \
libxcb1 \
libxext6 \
libxrender1 \
proj-bin \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md /app/
COPY app /app/app
RUN pip install --no-cache-dir --upgrade pip setuptools
RUN extras=".[gis]" \
&& if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then extras=".[gis,ai]"; fi \
&& pip install --no-cache-dir "$extras"
COPY . /app
RUN python scripts/gis_import_smoke.py
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+2035
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://geointel:geointel@localhost:5432/geointel
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = INFO
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
class_ = logging.Formatter
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
from app.core.config import get_settings
from app.db.base import Base
import app.models.entities # noqa: F401
settings = get_settings()
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+20
View File
@@ -0,0 +1,20 @@
"""
${message}
"""
from alembic import op
import sqlalchemy as sa
${imports}
revision = ${repr(revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,108 @@
"""Initial PostGIS schema for Sprint 1 foundation."""
from alembic import op
import sqlalchemy as sa
from geoalchemy2 import Geometry
revision = "202601110001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
op.execute("CREATE EXTENSION IF NOT EXISTS postgis_topology")
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
op.create_table(
"projects",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("region", sa.Text(), nullable=False, server_default="Kempen"),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_table(
"areas",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("geometry", Geometry("MULTIPOLYGON", srid=4326), nullable=False),
sa.Column("original_crs", sa.Text(), nullable=True),
sa.Column("area_m2", sa.Float(), nullable=True),
sa.Column("bbox", Geometry("POLYGON", srid=4326), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_table(
"datasets",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("area_id", sa.UUID(as_uuid=True), sa.ForeignKey("areas.id", ondelete="SET NULL"), nullable=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("dataset_type", sa.Text(), nullable=False),
sa.Column("source", sa.Text(), nullable=False),
sa.Column("storage_path", sa.Text(), nullable=True),
sa.Column("derived_from_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("crs", sa.Text(), nullable=True),
sa.Column("bounds_json", sa.JSON(), nullable=True),
sa.Column("resolution_json", sa.JSON(), nullable=True),
sa.Column("bands_json", sa.JSON(), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="created"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_table(
"dataset_versions",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("storage_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_table(
"analysis_runs",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("area_id", sa.UUID(as_uuid=True), sa.ForeignKey("areas.id", ondelete="SET NULL"), nullable=True),
sa.Column("analysis_type", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("parameters_json", sa.JSON(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
)
op.create_table(
"exports",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
sa.Column("export_type", sa.Text(), nullable=False),
sa.Column("storage_path", sa.Text(), nullable=False),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_index("ix_areas_geometry", "areas", ["geometry"], postgresql_using="gist")
op.create_index("ix_areas_project_id", "areas", ["project_id"])
op.create_index("ix_datasets_project_id", "datasets", ["project_id"])
def downgrade() -> None:
op.drop_index("ix_datasets_project_id", table_name="datasets")
op.drop_index("ix_areas_project_id", table_name="areas")
op.drop_index("ix_areas_geometry", table_name="areas", postgresql_using="gist")
op.drop_table("exports")
op.drop_table("analysis_runs")
op.drop_table("dataset_versions")
op.drop_table("datasets")
op.drop_table("areas")
op.drop_table("projects")
@@ -0,0 +1,27 @@
"""Add dataset storage metadata columns."""
from alembic import op
import sqlalchemy as sa
revision = "202601120001"
down_revision = "202601110001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("datasets", sa.Column("original_filename", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("stored_filename", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("content_type", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("size_bytes", sa.Integer(), nullable=True))
op.add_column("datasets", sa.Column("checksum_sha256", sa.Text(), nullable=True))
op.alter_column("datasets", "status", server_default="uploaded")
def downgrade() -> None:
op.drop_column("datasets", "checksum_sha256")
op.drop_column("datasets", "size_bytes")
op.drop_column("datasets", "content_type")
op.drop_column("datasets", "stored_filename")
op.drop_column("datasets", "original_filename")
@@ -0,0 +1,38 @@
"""Add lightweight job table for sprint-3 async architecture foundation."""
from alembic import op
import sqlalchemy as sa
revision = "20260611212435"
down_revision = "202601120001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"jobs",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("job_type", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="queued"),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("input_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("output_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("parameters_json", sa.JSON(), nullable=False),
sa.Column("result_json", sa.JSON(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_jobs_project_id", "jobs", ["project_id"])
op.create_index("ix_jobs_status", "jobs", ["status"])
def downgrade() -> None:
op.drop_index("ix_jobs_status", table_name="jobs")
op.drop_index("ix_jobs_project_id", table_name="jobs")
op.drop_table("jobs")
@@ -0,0 +1,28 @@
"""Add dataset reference and provenance metadata columns."""
from alembic import op
import sqlalchemy as sa
revision = "202606120001"
down_revision = "20260611212435"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("datasets", sa.Column("dataset_role", sa.Text(), nullable=False, server_default="source"))
op.add_column("datasets", sa.Column("source_name", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("reference_layer_name", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("source_metadata", sa.JSON(), nullable=True))
op.add_column("datasets", sa.Column("provenance_metadata", sa.JSON(), nullable=True))
op.add_column("datasets", sa.Column("imported_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False))
def downgrade() -> None:
op.drop_column("datasets", "imported_at")
op.drop_column("datasets", "provenance_metadata")
op.drop_column("datasets", "source_metadata")
op.drop_column("datasets", "reference_layer_name")
op.drop_column("datasets", "source_name")
op.drop_column("datasets", "dataset_role")
@@ -0,0 +1,76 @@
"""Add Sprint 7A vector feature and QA persistence foundation."""
from alembic import op
import sqlalchemy as sa
from geoalchemy2 import Geometry
revision = "202606120700"
down_revision = "202606120001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"vector_features",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("feature_class", sa.Text(), nullable=True),
sa.Column("source_feature_id", sa.Text(), nullable=True),
sa.Column("properties_json", sa.JSON(), nullable=True),
sa.Column("geometry", Geometry("GEOMETRY", srid=4326, spatial_index=False), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_index("ix_vector_features_dataset_id", "vector_features", ["dataset_id"])
op.create_index("ix_vector_features_geometry", "vector_features", ["geometry"], postgresql_using="gist")
op.create_table(
"quality_checks",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
sa.Column("candidate_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("reference_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("check_type", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("score", sa.Float(), nullable=True),
sa.Column("parameters_json", sa.JSON(), nullable=True),
sa.Column("findings_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_quality_checks_project_id", "quality_checks", ["project_id"])
op.create_index("ix_quality_checks_reference_dataset_id", "quality_checks", ["reference_dataset_id"])
op.create_index("ix_quality_checks_candidate_dataset_id", "quality_checks", ["candidate_dataset_id"])
op.create_index("ix_quality_checks_analysis_run_id", "quality_checks", ["analysis_run_id"])
op.create_table(
"metrics",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("quality_check_id", sa.UUID(as_uuid=True), sa.ForeignKey("quality_checks.id", ondelete="CASCADE"), nullable=True),
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
sa.Column("metric_key", sa.Text(), nullable=False),
sa.Column("metric_value", sa.Float(), nullable=True),
sa.Column("metric_unit", sa.Text(), nullable=True),
sa.Column("label", sa.Text(), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
)
op.create_index("ix_metrics_quality_check_id", "metrics", ["quality_check_id"])
op.create_index("ix_metrics_analysis_run_id", "metrics", ["analysis_run_id"])
def downgrade() -> None:
op.drop_index("ix_metrics_analysis_run_id", table_name="metrics")
op.drop_index("ix_metrics_quality_check_id", table_name="metrics")
op.drop_table("metrics")
op.drop_index("ix_quality_checks_analysis_run_id", table_name="quality_checks")
op.drop_index("ix_quality_checks_candidate_dataset_id", table_name="quality_checks")
op.drop_index("ix_quality_checks_reference_dataset_id", table_name="quality_checks")
op.drop_index("ix_quality_checks_project_id", table_name="quality_checks")
op.drop_table("quality_checks")
op.drop_index("ix_vector_features_geometry", table_name="vector_features", postgresql_using="gist")
op.drop_index("ix_vector_features_dataset_id", table_name="vector_features")
op.drop_table("vector_features")
@@ -0,0 +1,59 @@
"""Add Sprint 8 detection foundation."""
from alembic import op
import sqlalchemy as sa
from geoalchemy2 import Geometry
revision = "202606120800"
down_revision = "202606120700"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("analysis_runs", sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True))
op.add_column("analysis_runs", sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True))
op.add_column("analysis_runs", sa.Column("model_name", sa.String(length=255), nullable=True))
op.add_column("analysis_runs", sa.Column("model_version", sa.String(length=120), nullable=True))
op.add_column("analysis_runs", sa.Column("result_json", sa.JSON(), nullable=True))
op.add_column("analysis_runs", sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False))
op.create_table(
"detections",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
sa.Column("model_name", sa.String(length=255), nullable=False),
sa.Column("model_version", sa.String(length=120), nullable=True),
sa.Column("class_name", sa.String(length=120), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("geometry", Geometry("GEOMETRY", srid=4326, spatial_index=False), nullable=False),
sa.Column("bbox_json", sa.JSON(), nullable=True),
sa.Column("source_tile_path", sa.String(length=500), nullable=True),
sa.Column("properties_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
op.create_index("ix_detections_project_id", "detections", ["project_id"])
op.create_index("ix_detections_dataset_id", "detections", ["dataset_id"])
op.create_index("ix_detections_analysis_run_id", "detections", ["analysis_run_id"])
op.create_index("ix_detections_class_name", "detections", ["class_name"])
op.create_index("ix_detections_geometry", "detections", ["geometry"], postgresql_using="gist")
def downgrade() -> None:
op.drop_index("ix_detections_geometry", table_name="detections", postgresql_using="gist")
op.drop_index("ix_detections_class_name", table_name="detections")
op.drop_index("ix_detections_analysis_run_id", table_name="detections")
op.drop_index("ix_detections_dataset_id", table_name="detections")
op.drop_index("ix_detections_project_id", table_name="detections")
op.drop_table("detections")
op.drop_column("analysis_runs", "created_at")
op.drop_column("analysis_runs", "result_json")
op.drop_column("analysis_runs", "model_version")
op.drop_column("analysis_runs", "model_name")
op.drop_column("analysis_runs", "job_id")
op.drop_column("analysis_runs", "dataset_id")
@@ -0,0 +1,51 @@
"""Add Sprint 9 segmentation foundation."""
from alembic import op
import sqlalchemy as sa
from geoalchemy2 import Geometry
revision = "202606120900"
down_revision = "202606120800"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"segmentations",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
sa.Column("model_name", sa.String(length=255), nullable=False),
sa.Column("model_version", sa.String(length=120), nullable=True),
sa.Column("class_name", sa.String(length=120), nullable=False),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
sa.Column("bbox_json", sa.JSON(), nullable=True),
sa.Column("area_m2", sa.Float(), nullable=True),
sa.Column("mask_path", sa.Text(), nullable=True),
sa.Column("source_tile_path", sa.String(length=500), nullable=True),
sa.Column("tile_index", sa.Integer(), nullable=True),
sa.Column("properties_json", sa.JSON(), nullable=True),
sa.Column("provenance_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
)
op.create_index("ix_segmentations_project_id", "segmentations", ["project_id"])
op.create_index("ix_segmentations_dataset_id", "segmentations", ["dataset_id"])
op.create_index("ix_segmentations_analysis_run_id", "segmentations", ["analysis_run_id"])
op.create_index("ix_segmentations_job_id", "segmentations", ["job_id"])
op.create_index("ix_segmentations_class_name", "segmentations", ["class_name"])
op.create_index("ix_segmentations_geometry", "segmentations", ["geometry"], postgresql_using="gist")
def downgrade() -> None:
op.drop_index("ix_segmentations_geometry", table_name="segmentations", postgresql_using="gist")
op.drop_index("ix_segmentations_class_name", table_name="segmentations")
op.drop_index("ix_segmentations_job_id", table_name="segmentations")
op.drop_index("ix_segmentations_analysis_run_id", table_name="segmentations")
op.drop_index("ix_segmentations_dataset_id", table_name="segmentations")
op.drop_index("ix_segmentations_project_id", table_name="segmentations")
op.drop_table("segmentations")
@@ -0,0 +1,72 @@
"""Add temporal dataset metadata and durable dataset-version provenance."""
from alembic import op
import sqlalchemy as sa
revision = "202607140001"
down_revision = "202606120900"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("datasets", sa.Column("temporal_series_key", sa.String(length=255), nullable=True))
op.add_column("datasets", sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("datasets", sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True))
op.add_column("datasets", sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True))
op.add_column("datasets", sa.Column("temporal_granularity", sa.String(length=32), nullable=True))
op.add_column("datasets", sa.Column("source_version", sa.String(length=120), nullable=True))
op.add_column("dataset_versions", sa.Column("source_version", sa.String(length=120), nullable=True))
op.add_column("dataset_versions", sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("dataset_versions", sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True))
op.add_column("dataset_versions", sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True))
op.add_column("dataset_versions", sa.Column("checksum_sha256", sa.String(length=64), nullable=True))
op.add_column("dataset_versions", sa.Column("source_metadata", sa.JSON(), nullable=True))
op.add_column("dataset_versions", sa.Column("provenance_metadata", sa.JSON(), nullable=True))
op.create_index(
"ix_datasets_project_temporal_series_observed",
"datasets",
["project_id", "temporal_series_key", "observed_at"],
)
op.create_index("ix_dataset_versions_dataset_version", "dataset_versions", ["dataset_id", "version"], unique=True)
op.create_index(
"ix_vector_features_dataset_source_feature",
"vector_features",
["dataset_id", "source_feature_id"],
)
op.create_check_constraint(
"ck_datasets_temporal_valid_range",
"datasets",
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
)
op.create_check_constraint(
"ck_dataset_versions_temporal_valid_range",
"dataset_versions",
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
)
def downgrade() -> None:
op.drop_constraint("ck_dataset_versions_temporal_valid_range", "dataset_versions", type_="check")
op.drop_constraint("ck_datasets_temporal_valid_range", "datasets", type_="check")
op.drop_index("ix_vector_features_dataset_source_feature", table_name="vector_features")
op.drop_index("ix_dataset_versions_dataset_version", table_name="dataset_versions")
op.drop_index("ix_datasets_project_temporal_series_observed", table_name="datasets")
op.drop_column("dataset_versions", "provenance_metadata")
op.drop_column("dataset_versions", "source_metadata")
op.drop_column("dataset_versions", "checksum_sha256")
op.drop_column("dataset_versions", "valid_to")
op.drop_column("dataset_versions", "valid_from")
op.drop_column("dataset_versions", "observed_at")
op.drop_column("dataset_versions", "source_version")
op.drop_column("datasets", "source_version")
op.drop_column("datasets", "temporal_granularity")
op.drop_column("datasets", "valid_to")
op.drop_column("datasets", "valid_from")
op.drop_column("datasets", "observed_at")
op.drop_column("datasets", "temporal_series_key")
@@ -0,0 +1,64 @@
"""Add durable operator review decisions for detection QA evidence."""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "202607150001"
down_revision = "202607140001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"detection_reviews",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("quality_check_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("analysis_run_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("evidence_role", sa.String(length=32), nullable=False),
sa.Column("evidence_feature_id", sa.String(length=255), nullable=False),
sa.Column("detection_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("reference_feature_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("decision", sa.String(length=64), server_default="unreviewed", nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("reviewed_by", sa.String(length=120), server_default="operator", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"evidence_role IN ('false_positive', 'false_negative')",
name="ck_detection_reviews_evidence_role",
),
sa.CheckConstraint(
"decision IN ('confirmed_model_false_positive', 'confirmed_model_false_negative', "
"'reference_gap_or_change', 'qa_alignment_mismatch', "
"'imagery_obscured_or_uncertain', 'uncertain', 'unreviewed')",
name="ck_detection_reviews_decision",
),
sa.ForeignKeyConstraint(["analysis_run_id"], ["analysis_runs.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["detection_id"], ["detections.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["quality_check_id"], ["quality_checks.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reference_feature_id"], ["vector_features.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"quality_check_id",
"evidence_role",
"evidence_feature_id",
name="uq_detection_reviews_evidence",
),
)
op.create_index("ix_detection_reviews_project_id", "detection_reviews", ["project_id"])
op.create_index("ix_detection_reviews_quality_check_id", "detection_reviews", ["quality_check_id"])
op.create_index("ix_detection_reviews_analysis_run_id", "detection_reviews", ["analysis_run_id"])
op.create_index("ix_detection_reviews_decision", "detection_reviews", ["decision"])
def downgrade() -> None:
op.drop_index("ix_detection_reviews_decision", table_name="detection_reviews")
op.drop_index("ix_detection_reviews_analysis_run_id", table_name="detection_reviews")
op.drop_index("ix_detection_reviews_quality_check_id", table_name="detection_reviews")
op.drop_index("ix_detection_reviews_project_id", table_name="detection_reviews")
op.drop_table("detection_reviews")
@@ -0,0 +1,22 @@
"""Index partitioned vector features by dataset and municipality."""
from alembic import op
import sqlalchemy as sa
revision = "202607160001"
down_revision = "202607150001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_vector_features_dataset_municipality",
"vector_features",
["dataset_id", sa.text("(properties_json ->> 'municipality')")],
)
def downgrade() -> None:
op.drop_index("ix_vector_features_dataset_municipality", table_name="vector_features")
@@ -0,0 +1,65 @@
"""Add resumable AOI parent and partition operations."""
from alembic import op
import sqlalchemy as sa
from geoalchemy2 import Geometry
revision = "202607260001"
down_revision = "202607160001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"aoi_operations",
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("project_id", sa.UUID(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("area_id", sa.UUID(), sa.ForeignKey("areas.id", ondelete="SET NULL")),
sa.Column("parent_job_id", sa.UUID(), sa.ForeignKey("jobs.id", ondelete="SET NULL")),
sa.Column("operation_type", sa.String(128), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
sa.Column("request_json", sa.JSON(), nullable=False),
sa.Column("plan_json", sa.JSON(), nullable=False),
sa.Column("result_json", sa.JSON()),
sa.Column("error_message", sa.Text()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(timezone=True)),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.CheckConstraint("status IN ('queued', 'running', 'partial', 'success', 'failed', 'cancelled')", name="ck_aoi_operations_status"),
)
op.create_index("ix_aoi_operations_project_status", "aoi_operations", ["project_id", "status"])
op.create_index("ix_aoi_operations_geometry", "aoi_operations", ["geometry"], postgresql_using="gist")
op.create_table(
"aoi_operation_partitions",
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("operation_id", sa.UUID(), sa.ForeignKey("aoi_operations.id", ondelete="CASCADE"), nullable=False),
sa.Column("child_job_id", sa.UUID(), sa.ForeignKey("jobs.id", ondelete="SET NULL")),
sa.Column("partition_key", sa.String(255), nullable=False),
sa.Column("provider_key", sa.String(120), nullable=False),
sa.Column("product_key", sa.String(120), nullable=False),
sa.Column("ordinal", sa.Integer(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
sa.Column("checkpoint_json", sa.JSON()),
sa.Column("result_json", sa.JSON()),
sa.Column("error_message", sa.Text()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(timezone=True)),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.CheckConstraint("status IN ('queued', 'running', 'success', 'failed', 'skipped')", name="ck_aoi_operation_partitions_status"),
sa.UniqueConstraint("operation_id", "partition_key", name="uq_aoi_operation_partition_key"),
)
op.create_index("ix_aoi_operation_partitions_operation_status", "aoi_operation_partitions", ["operation_id", "status"])
op.create_index("ix_aoi_operation_partitions_geometry", "aoi_operation_partitions", ["geometry"], postgresql_using="gist")
def downgrade() -> None:
op.drop_table("aoi_operation_partitions")
op.drop_table("aoi_operations")
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
"""Configure the immutable model source registry for governed snapshots.
The phase-2 seed intentionally registered model artifacts as unknown. Runtime
model provenance now records exact immutable snapshots, so the server-owned
registry must advertise that configured capability. The write guard is only
disabled for this narrowly-scoped, versioned migration and is restored in the
same transaction.
"""
from alembic import op
revision = "202608230001"
down_revision = "202608010001"
branch_labels = None
depends_on = None
def _set_status(*, freshness_status: str, ingest_status: str) -> None:
op.execute("ALTER TABLE source_registry DISABLE TRIGGER trg_source_registry_write_guard")
op.execute(
f"""
UPDATE source_registry
SET freshness_status = '{freshness_status}',
ingest_status = '{ingest_status}',
registry_metadata_json = (
registry_metadata_json::jsonb ||
'{{"runtime_model_contract": {{"key": "geointel.model.pytorch", "version": "1.0.0"}}}}'::jsonb
)::json,
updated_at = now()
WHERE source_key = 'model'
AND registry_metadata_json ->> 'registry_owner' = 'server'
"""
)
op.execute("ALTER TABLE source_registry ENABLE TRIGGER trg_source_registry_write_guard")
def upgrade() -> None:
_set_status(freshness_status="current", ingest_status="configured")
def downgrade() -> None:
_set_status(freshness_status="unknown", ingest_status="registered")
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
from app.models.entities import AnalysisRun, Area, Dataset, Export, Project
__all__ = ["AnalysisRun", "Area", "Dataset", "Export", "Project"]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from uuid import UUID
from fastapi import Request
from app.core.errors import AppError
def guest_project_scope(request: Request) -> UUID | None:
principal = getattr(request.state, "auth_principal", None)
if getattr(principal, "role", None) != "guest":
return None
project_id = getattr(principal, "project_id", None)
if isinstance(project_id, UUID):
return project_id
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def assert_guest_project_scope(request: Request, project_id: UUID) -> None:
guest_project_id = guest_project_scope(request)
if guest_project_id is not None and project_id != guest_project_id:
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def guest_scoped_project_filter(
request: Request,
requested_project_id: UUID | None,
) -> UUID | None:
guest_project_id = guest_project_scope(request)
if guest_project_id is None:
return requested_project_id
if requested_project_id is not None:
assert_guest_project_scope(request, requested_project_id)
return guest_project_id
View File
Whitespace-only changes.
+15
View File
@@ -0,0 +1,15 @@
__all__ = [
"analysis",
"areas",
"assistant",
"auth",
"datasets",
"exports",
"external",
"health",
"jobs",
"projects",
"qa",
"source_registry",
"temporal",
]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import assert_guest_project_scope
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Dataset
from app.schemas import Envelope, JobRead
from app.schemas.analysis import ChangeDetectionRequest
from app.services.change_detection_service import ChangeDetectionService
from app.services.job_service import JobService
from app.utils.response import envelope
router = APIRouter(prefix="/analysis", tags=["analysis"])
@router.post("/change-detection", response_model=Envelope[JobRead])
def run_change_detection(
payload: ChangeDetectionRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
source_dataset = db.get(Dataset, payload.source_dataset_id)
if not source_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404)
assert_guest_project_scope(request, source_dataset.project_id)
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
ChangeDetectionService._get_project_vector_dataset(db, payload.target_dataset_id, source_dataset.project_id, "Target")
job = JobService.run_sync_job(
db=db,
project_id=source_dataset.project_id,
job_type="analysis.change-detection",
parameters=payload.model_dump(mode="json"),
input_dataset_id=payload.source_dataset_id,
operation=lambda: ChangeDetectionService.compare_vector_datasets(
db=db,
project_id=source_dataset.project_id,
source_dataset_id=payload.source_dataset_id,
target_dataset_id=payload.target_dataset_id,
iou_threshold=payload.iou_threshold,
modified_threshold=payload.modified_threshold,
include_unchanged=payload.include_unchanged,
bbox=payload.bbox.model_dump() if payload.bbox is not None else None,
area_id=payload.area_id,
preview_limit=payload.preview_limit,
).model_dump(mode="json"),
)
return envelope(job)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.aoi_operation import AoiOperationCreate, AoiOperationList, AoiOperationRead, AoiPartitionCheckpoint, AoiPartitionComplete, AoiPartitionFail, AoiPartitionRead
from app.schemas.common import Envelope
from app.services.aoi_operation_service import AoiOperationService
from app.services.aoi_operation_executor import AoiOperationExecutor
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/aoi-operations", tags=["aoi-operations"])
@router.post("", status_code=201, response_model=Envelope[AoiOperationRead])
def create_operation(project_id: UUID, payload: AoiOperationCreate, db: Session = Depends(get_db)):
return envelope(AoiOperationService.create(db, project_id, payload))
@router.get("", response_model=Envelope[AoiOperationList])
def list_operations(project_id: UUID, limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db)):
return envelope(AoiOperationService.list(db, project_id, limit))
@router.get("/{operation_id}", response_model=Envelope[AoiOperationRead])
def read_operation(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
return envelope(AoiOperationService.read(db, project_id, operation_id))
@router.post("/{operation_id}/partitions/claim", response_model=Envelope[AoiPartitionRead | None])
def claim_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
partition = AoiOperationService.claim_next(db, project_id, operation_id)
return envelope(AoiPartitionRead.model_validate(partition).model_dump() if partition else None)
@router.post("/{operation_id}/execute-next", response_model=Envelope[AoiOperationRead])
def execute_next_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
return envelope(AoiOperationExecutor.execute_next(db, project_id, operation_id))
@router.put("/{operation_id}/partitions/{partition_id}/checkpoint", response_model=Envelope[AoiPartitionRead])
def checkpoint_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionCheckpoint, db: Session = Depends(get_db)):
partition = AoiOperationService.checkpoint(db, project_id, operation_id, partition_id, payload.checkpoint_json)
return envelope(AoiPartitionRead.model_validate(partition).model_dump())
@router.post("/{operation_id}/partitions/{partition_id}/complete", response_model=Envelope[AoiOperationRead])
def complete_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionComplete, db: Session = Depends(get_db)):
return envelope(AoiOperationService.complete(db, project_id, operation_id, partition_id, payload.result_json, payload.skipped))
@router.post("/{operation_id}/partitions/{partition_id}/fail", response_model=Envelope[AoiOperationRead])
def fail_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionFail, db: Session = Depends(get_db)):
return envelope(AoiOperationService.fail(db, project_id, operation_id, partition_id, payload.error_message, payload.retryable, payload.details))
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Area
from app.schemas import Envelope
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
from app.services.area_service import AreaService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
@router.get("", response_model=Envelope[AreaList])
def list_areas(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset})
@router.post("", status_code=201, response_model=Envelope[AreaRead])
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
area = AreaService.create_area(db, project_id, payload)
return envelope(AreaService.serialize_area(area))
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
def search_municipalities(
project_id: UUID,
query: str = Query(default="", max_length=120),
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
):
items, total = AreaService.search_municipalities(db, project_id, query, limit)
return envelope({"items": items, "total": total})
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
area = AreaService.activate_municipality(db, project_id, niscode)
return envelope(AreaService.serialize_area(area))
@router.get("/{area_id}", response_model=Envelope[AreaRead])
def get_area(
project_id: UUID,
area_id: UUID,
db: Session = Depends(get_db),
):
area = AreaService.get_area(db, area_id)
if area.project_id != project_id:
raise HTTPException(status_code=404, detail="Area not found")
return envelope(AreaService.serialize_area(area))
@router.patch("/{area_id}", response_model=Envelope[AreaRead])
def update_area(
project_id: UUID,
area_id: UUID,
payload: AreaUpdate,
db: Session = Depends(get_db),
):
existing = db.get(Area, area_id)
if not existing or existing.project_id != project_id:
raise HTTPException(status_code=404, detail="Area not found")
area = AreaService.update_area(db, area_id, payload)
return envelope(AreaService.serialize_area(area))
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.assistant import (
AssistantModelList,
AssistantQueryRequest,
AssistantQueryResponse,
AssistantStatus,
)
from app.services.geo_assistant_service import GeoAssistantService
from app.utils.response import envelope
router = APIRouter(tags=["assistant"])
@router.get("/assistant/status", response_model=Envelope[AssistantStatus])
def assistant_status() -> dict:
return envelope(GeoAssistantService().status().model_dump())
@router.get("/assistant/models", response_model=Envelope[AssistantModelList])
def assistant_models() -> dict:
service = GeoAssistantService()
models = service.list_models()
return envelope(
{
"items": [model.model_dump() for model in models],
"total": len(models),
"default_model": service.settings.ollama_default_model,
}
)
@router.post(
"/projects/{project_id}/assistant/query",
response_model=Envelope[AssistantQueryResponse],
)
def assistant_query(
project_id: UUID,
payload: AssistantQueryRequest,
db: Session = Depends(get_db),
) -> dict:
return envelope(GeoAssistantService().query(db, project_id=project_id, payload=payload).model_dump())
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
from app.services.auth_service import AuthPrincipal, AuthService
from app.services.authentik_oidc_service import AuthentikOidcService
from app.services.demo_workflow_service import DemoWorkflowService
router = APIRouter(prefix="/auth", tags=["auth"])
COOKIE_NAME = "geointel_session"
OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow"
logger = logging.getLogger("geointel.auth")
_TRUSTED_PROXY_NETWORKS = (
ip_network("127.0.0.0/8"),
ip_network("::1/128"),
ip_network("172.16.0.0/12"),
)
def _peer_is_trusted_proxy(request: Request) -> bool:
if request.client is None:
return False
try:
peer_address = ip_address(request.client.host)
except ValueError:
return False
return any(peer_address in network for network in _TRUSTED_PROXY_NETWORKS)
def _request_is_https(request: Request) -> bool:
if request.url.scheme == "https":
return True
if not _peer_is_trusted_proxy(request):
return False
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
return forwarded_proto == "https"
def _client_host(request: Request) -> str:
peer = request.client.host if request.client else "unknown"
if not _peer_is_trusted_proxy(request):
return peer
forwarded = request.headers.get("x-real-ip", "").strip()
if not forwarded:
return peer
try:
return str(ip_address(forwarded))
except ValueError:
return peer
def _session_from_principal(
principal: AuthPrincipal,
*,
guest_access_enabled: bool,
authentik_enabled: bool,
) -> AuthSession:
return AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
role=principal.role,
guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
guest_project_id=principal.project_id,
)
def _session_payload(request: Request) -> AuthSession:
settings = get_settings()
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
authentik_enabled = AuthentikOidcService(settings).enabled
if not settings.auth_enabled:
return AuthSession(
authentication_required=False,
authenticated=True,
guest_access_enabled=False,
authentik_enabled=False,
)
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
if principal is None:
return AuthSession(
authentication_required=True,
authenticated=False,
guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
)
return _session_from_principal(
principal,
guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
)
def _set_session_cookie(
*,
request: Request,
response: Response,
token: str,
max_age: int,
) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=max_age,
httponly=True,
secure=_request_is_https(request),
samesite="strict",
path="/",
)
@router.get("/session", response_model=AuthSessionEnvelope)
def session(request: Request) -> AuthSessionEnvelope:
return AuthSessionEnvelope(data=_session_payload(request))
@router.post("/login", response_model=AuthSessionEnvelope)
def login(payload: AuthLoginRequest, request: Request, response: Response) -> AuthSessionEnvelope:
settings = get_settings()
if not settings.auth_enabled:
raise AppError(
code="AUTHENTICATION_DISABLED",
message="Operator authentication is not enabled on this runtime",
status_code=status.HTTP_409_CONFLICT,
)
if settings.auth_require_https and not _request_is_https(request):
raise AppError(
code="AUTH_HTTPS_REQUIRED",
message="Operator authentication requires HTTPS on this runtime",
status_code=status.HTTP_426_UPGRADE_REQUIRED,
)
client_host = _client_host(request)
throttle_key = f"{client_host}:{payload.username.casefold()}"
retry_after = AuthService.retry_after_seconds(throttle_key)
if retry_after:
raise AppError(
code="LOGIN_RATE_LIMITED",
message="Te veel mislukte aanmeldpogingen. Probeer later opnieuw.",
details={"retry_after_seconds": retry_after},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not AuthService.credentials_match(payload.username, payload.password, settings):
AuthService.record_failure(throttle_key)
raise AppError(
code="INVALID_CREDENTIALS",
message="Gebruikersnaam of wachtwoord is onjuist.",
status_code=status.HTTP_401_UNAUTHORIZED,
)
AuthService.clear_failures(throttle_key)
token = AuthService.create_session_token(payload.username, settings)
principal = AuthService.verify_session_token(token, settings)
if principal is None: # pragma: no cover - defensive invariant
raise AppError(
code="SESSION_CREATION_FAILED",
message="De beveiligde sessie kon niet worden aangemaakt.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.auth_session_ttl_seconds,
)
return AuthSessionEnvelope(
data=_session_from_principal(
principal,
guest_access_enabled=settings.guest_access_enabled,
authentik_enabled=AuthentikOidcService(settings).enabled,
)
)
@router.get("/authentik/start")
def authentik_start(request: Request) -> RedirectResponse:
settings = get_settings()
service = AuthentikOidcService(settings)
try:
location, flow = service.start()
except Exception as exc:
logger.warning("Authentik authorization start failed: %s", type(exc).__name__)
raise AppError(
code="AUTHENTIK_UNAVAILABLE",
message="Authentik is momenteel niet beschikbaar.",
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
) from exc
response = RedirectResponse(location, status_code=status.HTTP_302_FOUND)
response.set_cookie(
OIDC_FLOW_COOKIE_NAME,
flow,
max_age=600,
httponly=True,
secure=True,
samesite="lax",
path=f"{settings.api_prefix}/auth/authentik",
)
return response
@router.get("/authentik/callback")
def authentik_callback(
request: Request,
code: str = "",
state: str = "",
) -> RedirectResponse:
settings = get_settings()
service = AuthentikOidcService(settings)
base_url = settings.public_base_url.rstrip("/")
try:
service.finish(
code=code,
state=state,
flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""),
)
token = AuthService.create_session_token(
settings.auth_username or "operator",
settings,
)
except Exception as exc:
logger.warning("Authentik callback rejected: %s", type(exc).__name__)
response = RedirectResponse(
f"{base_url}/?authentik=error",
status_code=status.HTTP_302_FOUND,
)
else:
response = RedirectResponse(
f"{base_url}/",
status_code=status.HTTP_302_FOUND,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.auth_session_ttl_seconds,
)
response.delete_cookie(
OIDC_FLOW_COOKIE_NAME,
path=f"{settings.api_prefix}/auth/authentik",
secure=True,
httponly=True,
samesite="lax",
)
return response
@router.post("/guest", response_model=AuthSessionEnvelope)
def guest_login(
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> AuthSessionEnvelope:
settings = get_settings()
if not settings.auth_enabled or not settings.guest_access_enabled:
raise AppError(
code="GUEST_ACCESS_DISABLED",
message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.",
status_code=status.HTTP_403_FORBIDDEN,
)
client_host = _client_host(request)
retry_after = AuthService.consume_guest_request(
f"guest-login:{client_host}",
max_requests=settings.guest_login_requests_per_minute,
)
if retry_after:
raise AppError(
code="GUEST_LOGIN_RATE_LIMITED",
message="Too many guest sessions were requested. Try again later.",
details={"retry_after_seconds": retry_after},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
demo = DemoWorkflowService.seed(db)
token = AuthService.create_session_token(
settings.guest_display_name,
settings,
role="guest",
project_id=demo.project_id,
ttl_seconds=settings.guest_session_ttl_seconds,
)
principal = AuthService.verify_session_token(token, settings)
if principal is None: # pragma: no cover - defensive invariant
raise AppError(
code="SESSION_CREATION_FAILED",
message="De tijdelijke gastensessie kon niet worden aangemaakt.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.guest_session_ttl_seconds,
)
return AuthSessionEnvelope(
data=_session_from_principal(
principal,
guest_access_enabled=True,
authentik_enabled=AuthentikOidcService(settings).enabled,
)
)
@router.post("/logout", response_model=AuthSessionEnvelope)
def logout(response: Response) -> AuthSessionEnvelope:
settings = get_settings()
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
return AuthSessionEnvelope(
data=AuthSession(
authentication_required=settings.auth_enabled,
authenticated=not settings.auth_enabled,
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
authentik_enabled=AuthentikOidcService(settings).enabled,
)
)
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.demo import DemoWorkflowResponse
from app.services.demo_workflow_service import DemoWorkflowService
from app.utils.response import envelope
router = APIRouter(prefix="/demo", tags=["demo"])
@router.post(
"/workflow",
status_code=status.HTTP_201_CREATED,
response_model=Envelope[DemoWorkflowResponse],
)
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
return envelope(result.model_dump())
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import (
assert_guest_project_scope,
guest_project_scope,
guest_scoped_project_filter,
)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
DetectionListResponse,
DetectionModelsResponse,
DetectionComparisonRequest,
DetectionComparisonResponse,
DetectionQaRequest,
DetectionRead,
DetectionRunListResponse,
DetectionRunRead,
DetectionRunRequest,
DetectionRunResponse,
Envelope,
GeoJsonFeatureCollection,
JobRead,
ModelAssetListResponse,
YoloPreflightResponse,
)
from app.services.detection_comparison_service import DetectionComparisonService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService
from app.services.yolo_preflight_service import YoloPreflightService
from app.utils.response import envelope
router = APIRouter(prefix="/detection", tags=["detection"])
@router.get("/models", response_model=Envelope[DetectionModelsResponse])
def list_detection_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
@router.get("/model-assets", response_model=Envelope[ModelAssetListResponse])
def list_detection_model_assets() -> dict:
return envelope(ModelAssetCatalogService.list_assets().model_dump())
@router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse])
def get_yolo_preflight(
tile_manifest_path: str | None = None,
check_model_load: bool = False,
model_asset_id: str | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
YoloPreflightService.run(
tile_manifest_path=tile_manifest_path,
check_model_load=check_model_load,
model_asset_id=model_asset_id,
db=db,
)
)
@router.post("/run", response_model=Envelope[DetectionRunResponse])
def run_detection(
payload: DetectionRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
assert_guest_project_scope(request, payload.project_id)
result = DetectionService.run_detection(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
model_asset_id=payload.model_asset_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(result.model_dump())
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_detection(
payload: DetectionRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Queue a detection run for the background worker.
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it
inside the request and is only appropriate for a handful of tiles. Poll
``GET /jobs/{id}`` for the queued run instead.
"""
assert_guest_project_scope(request, payload.project_id)
job = DetectionService.enqueue_detection(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
model_asset_id=payload.model_asset_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
def list_detection_runs(
request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope(
DetectionService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
).model_dump()
)
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
def get_detection_run(
analysis_run_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(run.model_dump())
@router.get(
"/runs/{analysis_run_id}/detections",
response_model=Envelope[DetectionListResponse],
)
def list_detection_run_detections(
analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
limit=limit,
offset=offset,
).model_dump()
)
@router.get(
"/datasets/{dataset_id}/detections",
response_model=Envelope[DetectionListResponse],
)
def list_dataset_detections(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
limit=limit,
offset=offset,
).model_dump()
)
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
def get_detection(
detection_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
detection = DetectionService.get_detection(db, detection_id)
assert_guest_project_scope(request, detection.project_id)
return envelope(detection.model_dump())
@router.get(
"/runs/{analysis_run_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_detection_run_geojson(
analysis_run_id: UUID,
request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.get(
"/datasets/{dataset_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_dataset_detection_geojson(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.post("/runs/compare", response_model=Envelope[DetectionComparisonResponse])
def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = Depends(get_db)) -> dict:
"""Rank several runs against one reference on average precision.
The workbench ranks model variants by a stored F1 measured at each
variant's own confidence threshold, which orders the thresholds as much as
the models. Average precision describes the whole ranking a model produced.
Comparability is reported first: runs over different rasters, different
references or different inference coverage are not alternatives.
"""
return envelope(
DetectionComparisonService.compare_runs(
db,
analysis_run_ids=payload.analysis_run_ids,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
)
)
@router.post(
"/runs/{analysis_run_id}/qa/reference",
response_model=Envelope[AnalysisQaResponse],
)
def compare_detection_run_with_reference(
analysis_run_id: UUID,
payload: DetectionQaRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
class_name=payload.class_name,
min_confidence=payload.min_confidence,
calibration_thresholds=payload.calibration_thresholds,
)
)
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.guest_scope import assert_guest_project_scope, guest_project_scope
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.export import (
ExportContentResponse,
ExportCreateResponse,
ExportListResponse,
ExportRead,
GeoJsonExportRequest,
MapResultExportRequest,
MetadataExportRequest,
ReportExportRequest,
)
from app.services.export_service import ExportService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/exports", tags=["exports"])
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
def export_geojson(
payload: GeoJsonExportRequest,
request: Request,
db: Session = Depends(get_db),
):
if guest_project_scope(request) is not None:
if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None:
dataset = DatasetService.get_dataset(db, payload.dataset_id)
assert_guest_project_scope(request, dataset.project_id)
elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
run = DetectionService.get_run(db, payload.analysis_run_id)
assert_guest_project_scope(request, run.project_id)
elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
run = SegmentationService.get_run(db, payload.analysis_run_id)
assert_guest_project_scope(request, run.project_id)
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
return envelope(
ExportService.export_vector_selection_geojson(
db,
payload.dataset_id,
payload.bbox.model_dump(),
area_id=payload.area_id,
limit=payload.limit,
name=payload.name,
).model_dump(mode="json")
)
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_detection_run_geojson(
db,
payload.analysis_run_id,
payload.name,
intended_use=payload.intended_use,
).model_dump(mode="json")
)
if payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_segmentation_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
)
if payload.dataset_id is not None:
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
raise AppError(
code="INVALID_EXPORT_REQUEST",
message="GeoJSON export request does not match any supported export target",
status_code=422,
)
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
def export_project_metadata(
payload: MetadataExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/report", response_model=Envelope[ExportCreateResponse])
def export_project_report(
payload: ReportExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
def export_map_result(
payload: MapResultExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
@router.get(
"/projects/{project_id}/exports",
response_model=Envelope[ExportListResponse],
)
def list_project_exports(
project_id: UUID,
request: Request,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, project_id)
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
@router.get("/{export_id}", response_model=Envelope[ExportRead])
def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
return envelope(export.model_dump(mode="json"))
@router.get("/{export_id}/download")
def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
if guest_project_scope(request) is not None:
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
path = ExportService.get_export_download_path(db, export_id)
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
return FileResponse(path, filename=path.name, media_type=media_type)
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)):
if guest_project_scope(request) is not None:
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Area, Project
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
from app.schemas import (
CoverageCatalogResponse,
CoverageResolveRequest,
CoverageResolveResponse,
Envelope,
ExternalFetchRequest,
ExternalFetchResponse,
ProviderCapabilitiesResponse,
ProviderCapabilityResponse,
ProviderImportRequest,
ProviderImportResponse,
ProviderLayersResponse,
ProviderStatusResponse,
)
from app.services.coverage_registry_service import CoverageRegistryService
from app.utils.response import envelope
router = APIRouter(prefix="/external", tags=["external"])
def _validate_area_in_project(db: Session, project_id, area_id: str | None) -> None:
if area_id is None:
return
area = db.get(Area, area_id)
if not area:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
def _assert_project_exists(db: Session, project_id):
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
def _assert_guest_project_scope(request: Request, project_id) -> None:
principal = getattr(request.state, "auth_principal", None)
if (
getattr(principal, "role", None) == "guest"
and getattr(principal, "project_id", None) != project_id
):
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
def _provider_payload(provider_name: str) -> dict:
return get_provider(provider_name).capability.to_dict()
@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse])
def list_external_providers() -> dict:
return envelope({
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
})
@router.get("/coverage/catalog", response_model=Envelope[CoverageCatalogResponse])
def get_coverage_catalog() -> dict:
return envelope(CoverageRegistryService.catalog().model_dump())
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
def resolve_project_coverage(
payload: CoverageResolveRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
_assert_guest_project_scope(request, payload.project_id)
result = CoverageRegistryService.resolve(
db,
project_id=payload.project_id,
bbox=payload.bbox,
themes=payload.themes,
)
return envelope(result.model_dump())
@router.get(
"/providers/capabilities",
response_model=Envelope[ProviderCapabilitiesResponse],
)
def get_external_provider_capabilities() -> dict:
return envelope({
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
})
@router.get(
"/providers/{provider_name}",
response_model=Envelope[ProviderCapabilityResponse],
)
def get_external_provider(provider_name: str) -> dict:
return envelope(_provider_payload(provider_name))
@router.get(
"/providers/{provider_name}/layers",
response_model=Envelope[ProviderLayersResponse],
)
def get_external_provider_layers(provider_name: str) -> dict:
provider = get_provider(provider_name)
return envelope({
"provider_name": provider.provider_name,
"layers": provider.supported_layers,
})
@router.get(
"/providers/{provider_name}/status",
response_model=Envelope[ProviderStatusResponse],
)
def get_external_provider_status(provider_name: str) -> dict:
provider = get_provider(provider_name)
return envelope({
"provider_name": provider.provider_name,
"configured": provider.is_configured,
"status": provider.capability.status,
"limitation_message": provider.limitation_message,
})
@router.post(
"/providers/{provider_name}/import",
response_model=Envelope[ProviderImportResponse],
)
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
result = import_provider_dataset(
provider_name=provider_name,
project_id=payload.project_id,
area_id=payload.area_id,
layers=_normalize_layer_input(payload.layers),
requested_dataset_role=payload.dataset_role,
)
return envelope(result.model_dump())
def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFetchResponse:
area_id_str = str(payload.area_id) if payload.area_id else None
response = fetch_provider_data(
provider_name=provider_name,
project_id=str(payload.project_id),
area_id=area_id_str,
layers=_normalize_layer_input(payload.layers),
)
return ExternalFetchResponse(
provider=provider_name,
status=response.get("status", "not_configured"),
message=response.get("message", "Provider fetch executed."),
requested_layers=_normalize_layer_input(payload.layers),
project_id=payload.project_id,
area_id=payload.area_id,
)
@router.post("/osm/fetch", response_model=Envelope[ExternalFetchResponse])
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
_assert_project_exists(db, payload.project_id)
_validate_area_in_project(db, payload.project_id, payload.area_id)
return envelope(_run_fetch(payload, "osm").model_dump())
@router.post("/grb/fetch", response_model=Envelope[ExternalFetchResponse])
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
_assert_project_exists(db, payload.project_id)
_validate_area_in_project(db, payload.project_id, payload.area_id)
return envelope(_run_fetch(payload, "grb").model_dump())
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from importlib import import_module
from pathlib import Path
from tempfile import NamedTemporaryFile
from alembic.config import Config
from alembic.script import ScriptDirectory
from fastapi import APIRouter, Response, status
from sqlalchemy import text
from app.core.config import get_settings
from app.db.session import get_engine
from app.providers.registry import list_provider_capabilities
from app.schemas.health import (
HealthResponse,
SystemCapabilities,
SystemCapabilitiesEnvelope,
)
from app.services.model_registry_service import ModelRegistryService
router = APIRouter()
def _dependency_enabled(module: str) -> bool:
try:
import_module(module)
return True
except Exception:
return False
def _expected_migration_heads() -> list[str]:
backend_root = Path(__file__).resolve().parents[3]
config = Config(str(backend_root / "alembic.ini"))
config.set_main_option("script_location", str(backend_root / "alembic"))
return list(ScriptDirectory.from_config(config).get_heads())
def _database_checks() -> dict[str, str]:
checks = {
"database": "degraded",
"postgis": "degraded",
"migration": "degraded",
}
try:
with get_engine().connect() as connection:
connection.execute(text("SELECT 1"))
checks["database"] = "ok"
connection.execute(
text("SELECT PostGIS_Version()")
).scalar_one()
checks["postgis"] = "ok"
database_head = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
expected_heads = _expected_migration_heads()
if len(expected_heads) == 1 and database_head == expected_heads[0]:
checks["migration"] = "ok"
else:
checks["migration"] = "degraded"
except Exception:
return checks
return checks
def _storage_check(storage_root: str) -> str:
root = Path(storage_root).expanduser()
try:
root.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(
prefix=".geointel-readiness-",
dir=root,
delete=True,
) as handle:
handle.write(b"ok")
handle.flush()
return "ok"
except OSError:
return "degraded"
def _readiness_payload() -> HealthResponse:
settings = get_settings()
checks = _database_checks()
checks["storage"] = _storage_check(settings.storage_root)
ready = all(
value == "ok" or value.startswith("ok:")
for value in checks.values()
)
return HealthResponse(
status="ok" if ready else "degraded",
service="geointel-backend",
version="public",
database=checks["database"],
postgis=checks["postgis"],
migration=checks["migration"],
storage=checks["storage"],
checks=checks,
)
@router.get("/health/live", response_model=HealthResponse)
def liveness() -> HealthResponse:
return HealthResponse(
status="ok",
service="geointel-backend",
version="public",
)
def _readiness_response(response: Response) -> HealthResponse:
payload = _readiness_payload()
if payload.status != "ok":
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return payload
@router.get("/health", response_model=HealthResponse)
def readiness(response: Response) -> HealthResponse:
return _readiness_response(response)
@router.get("/health/ready", response_model=HealthResponse)
def readiness_explicit(response: Response) -> HealthResponse:
return _readiness_response(response)
@router.get(
"/api/v1/system/capabilities",
response_model=SystemCapabilitiesEnvelope,
)
def capabilities() -> SystemCapabilitiesEnvelope:
settings = get_settings()
providers = [item.to_dict() for item in list_provider_capabilities()]
configured_yolo = ModelRegistryService.get_model_capability(
settings.yolo_model_id,
settings=settings,
)
yolo_configured = bool(configured_yolo and configured_yolo.configured)
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
configured_sam = ModelRegistryService.get_model_capability(
settings.sam_model_id,
settings=settings,
task_type="segmentation",
)
postgis_ready = _database_checks()["postgis"].startswith("ok:")
return SystemCapabilitiesEnvelope(
data=SystemCapabilities(
postgis=postgis_ready,
rasterio=_dependency_enabled("rasterio"),
geopandas=_dependency_enabled("geopandas"),
yolo=yolo_configured,
yolo_status=yolo_status,
sam=bool(configured_sam and configured_sam.configured),
grb="bounded",
sentinel="planned",
version=settings.app_version,
build_sha=settings.build_sha,
providers=providers,
)
)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope, JobCreate, JobList, JobRead, JobStatus
from app.services.job_service import JobService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"])
@router.post("/jobs", status_code=201, response_model=Envelope[JobRead])
def create_job(
project_id: UUID,
payload: JobCreate,
db: Session = Depends(get_db),
):
if payload.project_id != project_id:
raise HTTPException(status_code=400, detail="project_id mismatch")
return envelope(JobService.create_job(db, payload).model_dump())
@router.get("/jobs", response_model=Envelope[JobList])
def list_jobs(
project_id: UUID,
dataset_id: UUID | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
items, total = JobService.list_jobs(
db,
project_id=project_id,
dataset_id=dataset_id,
limit=limit,
offset=offset,
)
return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump())
@router.get("/jobs/{job_id}", response_model=Envelope[JobRead])
def read_job(
project_id: UUID,
job_id: UUID,
db: Session = Depends(get_db),
):
job = JobService.get_job(db, job_id)
if job.project_id != project_id:
raise HTTPException(status_code=404, detail="Job not found")
return envelope(job.model_dump())
@router.get("/jobs/{job_id}/status", response_model=Envelope[JobStatus])
def read_job_status(
project_id: UUID,
job_id: UUID,
db: Session = Depends(get_db),
):
status_row = JobService.get_job_status(db, job_id)
if status_row["project_id"] != str(project_id):
raise HTTPException(status_code=404, detail="Job not found")
return envelope(JobStatus(**status_row).model_dump())
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
from app.services.project_service import ProjectService
from app.utils.response import envelope
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=Envelope[ProjectList])
def list_projects(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
name: str | None = Query(default=None, min_length=1, max_length=255),
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
db: Session = Depends(get_db),
):
principal = getattr(request.state, "auth_principal", None)
if principal is not None and principal.role == "guest":
project = ProjectService.get_project(db, principal.project_id)
status_matches = bool(
project is not None
and (project_status == "all" or project.status == project_status)
)
name_matches = bool(
project is not None
and (name is None or name.casefold() in project.name.casefold())
)
matches = project is not None and status_matches and name_matches
visible = [project] if matches and offset == 0 else []
return envelope(
{
"items": [ProjectRead.model_validate(item).model_dump() for item in visible[:limit]],
"total": 1 if matches else 0,
"limit": limit,
"offset": offset,
}
)
projects, total = ProjectService.list_projects(
db,
limit=limit,
offset=offset,
name=name,
project_status=project_status,
)
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
@router.post("", status_code=status.HTTP_201_CREATED, response_model=Envelope[ProjectRead])
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
project = ProjectService.create_project(db, payload)
return envelope(ProjectRead.model_validate(project).model_dump())
@router.get("/{project_id}", response_model=Envelope[ProjectRead])
def get_project(project_id: UUID, db: Session = Depends(get_db)):
project = ProjectService.get_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return envelope(ProjectRead.model_validate(project).model_dump())
@router.patch("/{project_id}", response_model=Envelope[ProjectRead])
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
project = ProjectService.update_project(db, project_id, payload)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return envelope(ProjectRead.model_validate(project).model_dump())
@router.delete(
"/{project_id}",
status_code=status.HTTP_200_OK,
response_model=Envelope[ProjectDeleteResult],
)
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
if not ProjectService.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return envelope({"deleted": True})
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.errors import AppError
from app.models import Dataset, Job
from app.schemas import Envelope, JobRead, QaProviderComparisonRequest
from app.services.qa_service import QaService
from app.services.job_service import JobService
from app.services.quality_service import QualityService
from app.utils.response import envelope
router = APIRouter(prefix="/qa", tags=["qa"])
@router.post("/detections-vs-reference", response_model=Envelope[JobRead])
def compare_candidate_with_reference(
payload: QaProviderComparisonRequest,
db: Session = Depends(get_db),
) -> dict:
candidate_dataset = db.get(Dataset, payload.candidate_dataset_id)
if not candidate_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Candidate dataset not found", status_code=404)
job = JobService.run_sync_job(
db=db,
project_id=candidate_dataset.project_id,
job_type="qa.compare-candidate-with-reference",
parameters=payload.model_dump(mode="json"),
input_dataset_id=candidate_dataset.id,
operation=lambda: QaService.compare_candidate_with_reference(
db=db,
project_id=candidate_dataset.project_id,
candidate_dataset_id=payload.candidate_dataset_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
area_id=payload.area_id,
).model_dump(mode="json"),
)
result_json = job.get("result_json") if isinstance(job, dict) else None
if isinstance(result_json, dict) and job.get("status") == "success":
quality_check = QualityService.persist_quality_check(
db=db,
project_id=candidate_dataset.project_id,
job_id=uuid.UUID(str(job["id"])),
candidate_dataset_id=payload.candidate_dataset_id,
reference_dataset_id=payload.reference_dataset_id,
check_type="candidate_vs_reference",
status=str(result_json.get("status", "ok")),
score=result_json.get("f1_score"),
parameters=payload.model_dump(mode="json"),
findings={
"matches": result_json.get("matches"),
"false_positives": result_json.get("false_positives"),
"false_negatives": result_json.get("false_negatives"),
"warnings": result_json.get("warnings", []),
"unsupported_geometry": result_json.get("unsupported_geometry", False),
"unsupported_geometries": result_json.get("unsupported_geometries", []),
"match_evidence": result_json.get("match_evidence", []),
"false_positive_evidence": result_json.get("false_positive_evidence", []),
"false_negative_evidence": result_json.get("false_negative_evidence", []),
},
metrics={
"precision": result_json.get("precision"),
"recall": result_json.get("recall"),
"f1": result_json.get("f1_score"),
"mean_iou": result_json.get("mean_iou"),
"false_positive_count": result_json.get("false_positives"),
"false_negative_count": result_json.get("false_negatives"),
},
)
result_json["quality_check_id"] = str(quality_check.id)
job_record = db.get(Job, uuid.UUID(str(job["id"])))
if job_record:
job_record.result_json = result_json
db.add(job_record)
db.commit()
return envelope(job)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope, QualityEvidenceResponse
from app.schemas.detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewUpsert
from app.schemas.qa import QualityCheckList
from app.services.detection_review_service import DetectionReviewService
from app.services.quality_evidence_service import QualityEvidenceService
from app.services.quality_check_service import QualityCheckService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"])
@router.get("/quality-checks", response_model=Envelope[QualityCheckList])
def list_quality_checks(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
items, total = QualityCheckService.list_quality_checks(
db,
project_id=project_id,
limit=limit,
offset=offset,
)
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
@router.get(
"/quality-checks/{quality_check_id}/evidence/geojson",
response_model=Envelope[QualityEvidenceResponse],
)
def get_quality_check_evidence_geojson(
project_id: UUID,
quality_check_id: UUID,
limit: int = Query(
default=QualityEvidenceService.DEFAULT_EVIDENCE_LIMIT,
ge=0,
le=100_000,
description="Maximum evidence features to draw; 0 returns everything. Misses and false positives first.",
),
db: Session = Depends(get_db),
) -> dict:
return envelope(
QualityEvidenceService.evidence_geojson(
db,
project_id=project_id,
quality_check_id=quality_check_id,
limit=limit,
)
)
@router.get(
"/quality-checks/{quality_check_id}/reviews",
response_model=Envelope[DetectionReviewList],
)
def list_detection_reviews(
project_id: UUID,
quality_check_id: UUID,
evidence_role: str | None = Query(default=None, pattern="^(false_positive|false_negative)$"),
decision: str | None = Query(default=None, max_length=64),
reviewed: bool | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionReviewService.list_reviews(
db,
project_id=project_id,
quality_check_id=quality_check_id,
evidence_role=evidence_role,
decision=decision,
reviewed=reviewed,
limit=limit,
offset=offset,
).model_dump()
)
@router.post(
"/quality-checks/{quality_check_id}/reviews",
response_model=Envelope[DetectionReviewRead],
)
def upsert_detection_review(
project_id: UUID,
quality_check_id: UUID,
payload: DetectionReviewUpsert,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionReviewService.upsert_review(
db,
project_id=project_id,
quality_check_id=quality_check_id,
payload=payload,
).model_dump()
)
+284
View File
@@ -0,0 +1,284 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import (
assert_guest_project_scope,
guest_project_scope,
guest_scoped_project_filter,
)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
Envelope,
GeoJsonFeatureCollection,
JobRead,
SegmentationListResponse,
SegmentationModelsResponse,
SegmentationQaRequest,
SegmentationRead,
SegmentationRunListResponse,
SegmentationRunRead,
SegmentationRunRequest,
SegmentationRunResponse,
)
from app.services.model_registry_service import ModelRegistryService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
@router.get("/models", response_model=Envelope[SegmentationModelsResponse])
def list_segmentation_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
def run_segmentation(
payload: SegmentationRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
assert_guest_project_scope(request, payload.project_id)
result = SegmentationService.run_segmentation(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(result.model_dump())
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_segmentation(
payload: SegmentationRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Queue a segmentation run for the background worker.
Configured segmentation walks the same tile manifest as detection and is
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
"""
assert_guest_project_scope(request, payload.project_id)
job = SegmentationService.enqueue_segmentation(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
def list_segmentation_runs(
request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope(
SegmentationService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
).model_dump()
)
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
def get_segmentation_run(
analysis_run_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(run.model_dump())
@router.get(
"/runs/{analysis_run_id}/segmentations",
response_model=Envelope[SegmentationListResponse],
)
def list_segmentation_run_outputs(
analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
limit=limit,
offset=offset,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get(
"/datasets/{dataset_id}/segmentations",
response_model=Envelope[SegmentationListResponse],
)
def list_dataset_segmentations(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
limit=limit,
offset=offset,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
def get_segmentation(
segmentation_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
segmentation = SegmentationService.get_segmentation(db, segmentation_id)
assert_guest_project_scope(request, segmentation.project_id)
return envelope(segmentation.model_dump())
@router.get(
"/runs/{analysis_run_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_segmentation_run_geojson(
analysis_run_id: UUID,
request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.get(
"/datasets/{dataset_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_dataset_segmentation_geojson(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.post(
"/runs/{analysis_run_id}/qa/reference",
response_model=Envelope[AnalysisQaResponse],
)
def compare_segmentation_run_with_reference(
analysis_run_id: UUID,
payload: SegmentationQaRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
class_name=payload.class_name,
min_confidence=payload.min_confidence,
calibration_thresholds=payload.calibration_thresholds,
)
)
@@ -0,0 +1,87 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Area, Dataset
from app.schemas.common import Envelope
from app.schemas.operations import VectorSelectionResponse
from app.schemas.selection_partitions import VectorPartitionSelectionRequest
from app.services.vector_feature_service import VectorFeatureService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["selection-partitions"])
def _product_identity(dataset: Dataset) -> str:
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
return str(metadata.get("product_key") or dataset.reference_layer_name or "")
@router.post(
"/datasets/vector/partitions/select",
response_model=Envelope[VectorSelectionResponse],
)
def select_vector_partitions(
project_id: UUID,
payload: VectorPartitionSelectionRequest,
db: Session = Depends(get_db),
):
datasets = db.query(Dataset).filter(Dataset.id.in_(payload.dataset_ids)).all()
by_id = {dataset.id: dataset for dataset in datasets}
ordered = [by_id.get(dataset_id) for dataset_id in payload.dataset_ids]
if any(dataset is None or dataset.project_id != project_id for dataset in ordered):
raise AppError(code="DATASET_NOT_FOUND", message="One or more selection partitions were not found", status_code=404)
typed_datasets = [dataset for dataset in ordered if dataset is not None]
if any(dataset.dataset_type not in {"vector", "geojson"} or dataset.status != "ready" for dataset in typed_datasets):
raise AppError(
code="INVALID_VECTOR_PARTITIONS",
message="Every selection partition must be a ready vector dataset",
status_code=409,
)
source_names = {dataset.source_name for dataset in typed_datasets}
product_keys = {_product_identity(dataset) for dataset in typed_datasets}
if len(source_names) != 1 or len(product_keys) != 1:
raise AppError(
code="VECTOR_PARTITION_SOURCE_MISMATCH",
message="Selection partitions must belong to one governed source product",
details={"source_names": sorted(str(value) for value in source_names), "product_keys": sorted(product_keys)},
status_code=409,
)
selection_geometry = None
selection_area_id = None
if payload.area_id is not None:
selection_area = db.get(Area, payload.area_id)
if selection_area is None or selection_area.project_id != project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
selection_area_id = selection_area.id
representative = typed_datasets[0]
dataset_ids = [dataset.id for dataset in typed_datasets]
result = VectorFeatureService.select_features_by_bbox(
db,
dataset_id=representative.id,
dataset_ids=dataset_ids,
bbox=payload.bbox.model_dump(),
limit=payload.limit,
dataset=representative,
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
deduplicate_source_features=True,
)
result.update(
partition_count=len(dataset_ids),
source_name=representative.source_name,
dataset_ids=dataset_ids,
)
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Dataset, DatasetLineageEdge, DatasetQuarantine, Project, SourceRegistry, SourceSnapshot
from app.schemas import (
DatasetLineageEdgeRead,
DatasetProvenanceRead,
DatasetQuarantineRead,
Envelope,
ItemList,
SourceRegistryDetailRead,
SourceRegistryRead,
SourceSnapshotRead,
)
from app.utils.response import envelope
router = APIRouter(tags=["source-registry"])
def _source_read(source: SourceRegistry, *, snapshot_count: int = 0) -> SourceRegistryRead:
return SourceRegistryRead.model_validate(source).model_copy(update={"snapshot_count": int(snapshot_count)})
@router.get("/source-registry", response_model=Envelope[ItemList[SourceRegistryRead]])
def list_source_registry(
classification: str | None = None,
db: Session = Depends(get_db),
) -> dict:
query = (
db.query(SourceRegistry, func.count(SourceSnapshot.id).label("snapshot_count"))
.outerjoin(SourceSnapshot, SourceSnapshot.source_registry_id == SourceRegistry.id)
)
if classification:
query = query.filter(SourceRegistry.classification == classification.strip().lower())
rows = (
query.group_by(SourceRegistry.id)
.order_by(SourceRegistry.classification.asc(), SourceRegistry.display_name.asc())
.all()
)
items = [_source_read(source, snapshot_count=count) for source, count in rows]
return envelope({"items": items, "total": len(items)})
@router.get("/source-registry/{source_key}", response_model=Envelope[SourceRegistryDetailRead])
def get_source_registry_entry(source_key: str, db: Session = Depends(get_db)) -> dict:
normalized_key = source_key.strip().lower()
source = db.query(SourceRegistry).filter(SourceRegistry.source_key == normalized_key).one_or_none()
if source is None:
raise AppError(code="SOURCE_REGISTRY_ENTRY_NOT_FOUND", message="Source registry entry was not found", status_code=404)
snapshots = (
db.query(SourceSnapshot)
.filter(SourceSnapshot.source_registry_id == source.id)
.order_by(SourceSnapshot.fetched_at.desc(), SourceSnapshot.created_at.desc())
.all()
)
detail = SourceRegistryDetailRead(
source=_source_read(source, snapshot_count=len(snapshots)),
snapshots=[SourceSnapshotRead.model_validate(snapshot) for snapshot in snapshots],
)
return envelope(detail)
@router.get(
"/projects/{project_id}/datasets/{dataset_id}/provenance",
response_model=Envelope[DatasetProvenanceRead],
)
def get_dataset_provenance(
project_id: UUID,
dataset_id: UUID,
db: Session = Depends(get_db),
) -> dict:
if db.get(Project, project_id) is None:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
dataset = db.get(Dataset, dataset_id)
if dataset is None or dataset.project_id != project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
source = db.get(SourceRegistry, dataset.source_registry_id) if dataset.source_registry_id else None
snapshot = db.get(SourceSnapshot, dataset.source_snapshot_id) if dataset.source_snapshot_id else None
lineage = (
db.query(DatasetLineageEdge)
.filter(
or_(
DatasetLineageEdge.parent_dataset_id == dataset.id,
DatasetLineageEdge.child_dataset_id == dataset.id,
)
)
.order_by(DatasetLineageEdge.created_at.asc(), DatasetLineageEdge.id.asc())
.all()
)
quarantines = (
db.query(DatasetQuarantine)
.filter(DatasetQuarantine.dataset_id == dataset.id)
.order_by(DatasetQuarantine.created_at.desc(), DatasetQuarantine.id.desc())
.all()
)
result = DatasetProvenanceRead(
dataset_id=dataset.id,
source=_source_read(source) if source else None,
snapshot=SourceSnapshotRead.model_validate(snapshot) if snapshot else None,
data_contract_key=dataset.data_contract_key,
data_contract_version=dataset.data_contract_version,
validation_status=dataset.validation_status,
validation_report_json=dataset.validation_report_json,
provenance_status=dataset.provenance_status,
lineage_status=dataset.lineage_status,
quarantine_status=dataset.quarantine_status,
lineage=[DatasetLineageEdgeRead.model_validate(item) for item in lineage],
quarantines=[DatasetQuarantineRead.model_validate(item) for item in quarantines],
)
return envelope(result)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope, ItemList
from app.schemas.temporal import (
TemporalComparisonRequest,
TemporalComparisonResponse,
TemporalSeriesRead,
)
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/temporal", tags=["temporal"])
@router.get("/series", response_model=Envelope[ItemList[TemporalSeriesRead]])
def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)):
series = TemporalAnalysisService.list_series(db, project_id)
return envelope({"items": [item.model_dump() for item in series], "total": len(series)})
@router.post("/compare", response_model=Envelope[TemporalComparisonResponse])
def compare_temporal_snapshots(
project_id: UUID,
payload: TemporalComparisonRequest,
db: Session = Depends(get_db),
):
return envelope(TemporalAnalysisService.compare(db, project_id=project_id, payload=payload).model_dump())
View File
Whitespace-only changes.
+584
View File
@@ -0,0 +1,584 @@
from urllib.parse import urlsplit
from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
populate_by_name=True,
)
app_env: str = Field(default="development", validation_alias="GEOINTEL_ENV")
app_version: str = Field(
default="1.0.0",
validation_alias="GEOINTEL_APP_VERSION",
)
build_sha: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_SHA")
build_time: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_TIME")
api_prefix: str = Field(default="/api/v1", validation_alias="GEOINTEL_API_PREFIX")
auth_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_ENABLED")
auth_require_https: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_REQUIRE_HTTPS")
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER")
authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID")
authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET")
authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL")
public_base_url: str = Field(
default="http://localhost:1202",
validation_alias="GEOINTEL_PUBLIC_BASE_URL",
)
auth_session_ttl_seconds: int = Field(
default=43_200,
ge=900,
le=604_800,
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
)
guest_access_enabled: bool = Field(
default=False,
validation_alias="GEOINTEL_GUEST_ACCESS_ENABLED",
)
guest_display_name: str = Field(
default="Gast",
min_length=1,
max_length=64,
validation_alias="GEOINTEL_GUEST_DISPLAY_NAME",
)
guest_session_ttl_seconds: int = Field(
default=7_200,
ge=900,
le=86_400,
validation_alias="GEOINTEL_GUEST_SESSION_TTL_SECONDS",
)
guest_login_requests_per_minute: int = Field(
default=3,
ge=1,
le=60,
validation_alias="GEOINTEL_GUEST_LOGIN_REQUESTS_PER_MINUTE",
)
guest_compute_requests_per_minute: int = Field(
default=4,
ge=1,
le=120,
validation_alias="GEOINTEL_GUEST_COMPUTE_REQUESTS_PER_MINUTE",
)
guest_compute_max_concurrency: int = Field(
default=2,
ge=1,
le=16,
validation_alias="GEOINTEL_GUEST_COMPUTE_MAX_CONCURRENCY",
)
database_url: str = Field(
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
validation_alias="DATABASE_URL",
)
storage_root: str = Field(default="./storage", validation_alias="STORAGE_ROOT")
# Analysis consumes only artifacts under storage_root. Provisioning
# workflows that stage tiles elsewhere before ingest can opt out.
allow_external_artifact_paths: bool = Field(
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
)
max_upload_mb: int = Field(
default=500,
ge=1,
le=2_048,
validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"),
)
max_in_memory_vector_mb: int = Field(
default=64,
ge=1,
le=256,
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
)
max_raster_pixels: int = Field(
default=40_000_000,
ge=1,
le=500_000_000,
validation_alias="GEOINTEL_MAX_RASTER_PIXELS",
)
max_raster_bands: int = Field(
default=16,
ge=1,
le=256,
validation_alias="GEOINTEL_MAX_RASTER_BANDS",
)
max_decoded_raster_mb: int = Field(
default=1024,
ge=16,
le=8192,
validation_alias="GEOINTEL_MAX_DECODED_RASTER_MB",
)
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
orthophoto_wms_url: str = Field(
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
validation_alias="ORTHOPHOTO_WMS_URL",
)
orthophoto_wms_layer: str = Field(default="Ortho", validation_alias="ORTHOPHOTO_WMS_LAYER")
spw_orthophoto_wms_url: str = Field(
default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer",
validation_alias="SPW_ORTHOPHOTO_WMS_URL",
)
brussels_orthophoto_wms_url: str = Field(
default="https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows",
validation_alias="BRUSSELS_ORTHOPHOTO_WMS_URL",
)
orthophoto_resolution_m: float = Field(default=1.0, gt=0, validation_alias="ORTHOPHOTO_RESOLUTION_M")
orthophoto_min_side_m: float = Field(default=128.0, gt=0, validation_alias="ORTHOPHOTO_MIN_SIDE_M")
orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M")
orthophoto_timeout_seconds: int = Field(default=120, ge=1, validation_alias="ORTHOPHOTO_TIMEOUT_SECONDS")
orthophoto_max_response_mb: int = Field(default=32, ge=1, validation_alias="ORTHOPHOTO_MAX_RESPONSE_MB")
orthophoto_cache_ttl_hours: int = Field(default=24, ge=0, validation_alias="ORTHOPHOTO_CACHE_TTL_HOURS")
source_catalog_probe_enabled: bool = Field(default=True, validation_alias="SOURCE_CATALOG_PROBE_ENABLED")
source_catalog_grb_wfs_url: str = Field(
default="https://geo.api.vlaanderen.be/GRB/wfs",
validation_alias="SOURCE_CATALOG_GRB_WFS_URL",
)
source_catalog_alz_release_url: str = Field(
default="https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen",
validation_alias="SOURCE_CATALOG_ALZ_RELEASE_URL",
)
source_catalog_statbel_dcat_url: str = Field(
default="https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl",
validation_alias="SOURCE_CATALOG_STATBEL_DCAT_URL",
)
source_catalog_statbel_max_response_mb: int = Field(
default=5,
ge=1,
le=10,
validation_alias="SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB",
)
source_catalog_probe_timeout_seconds: int = Field(
default=10,
ge=1,
le=60,
validation_alias="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS",
)
source_catalog_probe_max_response_mb: int = Field(
default=2,
ge=1,
le=10,
validation_alias="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB",
)
source_catalog_probe_cache_ttl_seconds: int = Field(
default=900,
ge=0,
le=86_400,
validation_alias="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS",
)
grb_enabled: bool = Field(default=True, validation_alias="GRB_ENABLED")
grb_ogc_api_url: str = Field(
default="https://geo.api.vlaanderen.be/GRB/ogc/features/v1",
validation_alias="GRB_OGC_API_URL",
)
grb_min_side_m: float = Field(default=10.0, gt=0, validation_alias="GRB_MIN_SIDE_M")
grb_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="GRB_MAX_SIDE_M")
grb_page_size: int = Field(default=1000, ge=1, le=1000, validation_alias="GRB_PAGE_SIZE")
grb_max_pages: int = Field(default=200, ge=1, le=1000, validation_alias="GRB_MAX_PAGES")
grb_max_features: int = Field(default=150_000, ge=1, validation_alias="GRB_MAX_FEATURES")
grb_timeout_seconds: int = Field(default=180, ge=1, le=600, validation_alias="GRB_TIMEOUT_SECONDS")
grb_max_response_mb: int = Field(default=20, ge=1, le=100, validation_alias="GRB_MAX_RESPONSE_MB")
grb_max_total_response_mb: int = Field(
default=256,
ge=1,
le=2048,
validation_alias="GRB_MAX_TOTAL_RESPONSE_MB",
)
grb_cache_ttl_hours: int = Field(default=24, ge=0, le=8760, validation_alias="GRB_CACHE_TTL_HOURS")
official_vector_enabled: bool = Field(default=True, validation_alias="OFFICIAL_VECTOR_ENABLED")
bwk_wfs_url: str = Field(
default="https://geo.api.vlaanderen.be/BWK/wfs",
validation_alias="BWK_WFS_URL",
)
dov_soil_wfs_url: str = Field(
default="https://www.dov.vlaanderen.be/geoserver/wfs",
validation_alias="DOV_SOIL_WFS_URL",
)
official_vector_min_side_m: float = Field(
default=10.0,
gt=0,
validation_alias="OFFICIAL_VECTOR_MIN_SIDE_M",
)
official_vector_max_side_m: float = Field(
default=20_000.0,
gt=0,
validation_alias="OFFICIAL_VECTOR_MAX_SIDE_M",
)
official_vector_page_size: int = Field(
default=1000,
ge=1,
le=2000,
validation_alias="OFFICIAL_VECTOR_PAGE_SIZE",
)
official_vector_max_pages: int = Field(
default=200,
ge=1,
le=1000,
validation_alias="OFFICIAL_VECTOR_MAX_PAGES",
)
official_vector_max_features: int = Field(
default=100_000,
ge=1,
validation_alias="OFFICIAL_VECTOR_MAX_FEATURES",
)
official_vector_timeout_seconds: int = Field(
default=180,
ge=1,
le=600,
validation_alias="OFFICIAL_VECTOR_TIMEOUT_SECONDS",
)
official_vector_max_response_mb: int = Field(
default=20,
ge=1,
le=100,
validation_alias="OFFICIAL_VECTOR_MAX_RESPONSE_MB",
)
official_vector_max_total_response_mb: int = Field(
default=256,
ge=1,
le=2048,
validation_alias="OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB",
)
official_vector_cache_ttl_hours: int = Field(
default=24,
ge=0,
le=8760,
validation_alias="OFFICIAL_VECTOR_CACHE_TTL_HOURS",
)
spw_picc_enabled: bool = Field(default=True, validation_alias="SPW_PICC_ENABLED")
spw_picc_mapserver_url: str = Field(
default=(
"https://geoservices.wallonie.be/arcgis/rest/services/"
"TOPOGRAPHIE/PICC_VDIFF/MapServer"
),
validation_alias="SPW_PICC_MAPSERVER_URL",
)
spw_flood_hazard_enabled: bool = Field(default=True, validation_alias="SPW_FLOOD_HAZARD_ENABLED")
spw_flood_hazard_mapserver_url: str = Field(
default=(
"https://geoservices.wallonie.be/arcgis/rest/services/"
"EAU/ALEA_INOND/MapServer"
),
validation_alias="SPW_FLOOD_HAZARD_MAPSERVER_URL",
)
urbis_enabled: bool = Field(default=True, validation_alias="URBIS_ENABLED")
urbis_wfs_url: str = Field(
default="https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows",
validation_alias="URBIS_WFS_URL",
)
dhmv_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED")
dhmv_wcs_url: str = Field(
default="https://geo.api.vlaanderen.be/DHMV/wcs",
validation_alias="DHMV_WCS_URL",
)
dhmv_resolution_m: float = Field(default=5.0, ge=1.0, le=10.0, validation_alias="DHMV_RESOLUTION_M")
dhmv_min_side_m: float = Field(default=10.0, gt=0, validation_alias="DHMV_MIN_SIDE_M")
dhmv_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="DHMV_MAX_SIDE_M")
dhmv_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="DHMV_MAX_PIXELS")
dhmv_timeout_seconds: int = Field(default=300, ge=1, validation_alias="DHMV_TIMEOUT_SECONDS")
dhmv_max_response_mb: int = Field(default=160, ge=1, validation_alias="DHMV_MAX_RESPONSE_MB")
flood_hazard_enabled: bool = Field(default=True, validation_alias="FLOOD_HAZARD_ENABLED")
flood_hazard_wcs_url: str = Field(
default="https://geoservice.waterinfo.be/OGRK/wcs",
validation_alias="FLOOD_HAZARD_WCS_URL",
)
flood_hazard_resolution_m: float = Field(default=5.0, ge=2.0, le=20.0, validation_alias="FLOOD_HAZARD_RESOLUTION_M")
flood_hazard_min_side_m: float = Field(default=10.0, gt=0, validation_alias="FLOOD_HAZARD_MIN_SIDE_M")
flood_hazard_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="FLOOD_HAZARD_MAX_SIDE_M")
flood_hazard_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="FLOOD_HAZARD_MAX_PIXELS")
flood_hazard_timeout_seconds: int = Field(default=300, ge=1, validation_alias="FLOOD_HAZARD_TIMEOUT_SECONDS")
flood_hazard_max_response_mb: int = Field(default=160, ge=1, validation_alias="FLOOD_HAZARD_MAX_RESPONSE_MB")
bathymetry_profiles_enabled: bool = Field(default=True, validation_alias="BATHYMETRY_PROFILES_ENABLED")
bathymetry_profiles_layer_url: str = Field(
default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0",
validation_alias="BATHYMETRY_PROFILES_LAYER_URL",
)
bathymetry_watercourse_layer_url: str = Field(
default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1",
validation_alias="BATHYMETRY_WATERCOURSE_LAYER_URL",
)
bathymetry_profiles_page_size: int = Field(
default=1000,
ge=1,
le=2000,
validation_alias="BATHYMETRY_PROFILES_PAGE_SIZE",
)
bathymetry_profiles_max_features: int = Field(
default=50_000,
ge=1,
le=250_000,
validation_alias="BATHYMETRY_PROFILES_MAX_FEATURES",
)
bathymetry_profiles_max_pages: int = Field(
default=200,
ge=1,
le=5_000,
validation_alias="BATHYMETRY_PROFILES_MAX_PAGES",
)
bathymetry_profiles_timeout_seconds: int = Field(
default=120,
ge=1,
le=600,
validation_alias="BATHYMETRY_PROFILES_TIMEOUT_SECONDS",
)
bathymetry_profiles_max_response_mb: int = Field(
default=32,
ge=1,
le=256,
validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB",
)
bathymetry_raster_max_pixels: int = Field(
default=30_000_000,
ge=1,
validation_alias="BATHYMETRY_RASTER_MAX_PIXELS",
)
mdk_bathymetry_probe_enabled: bool = Field(default=True, validation_alias="MDK_BATHYMETRY_PROBE_ENABLED")
mdk_bathymetry_wcs_url: str = Field(
default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
validation_alias="MDK_BATHYMETRY_WCS_URL",
)
mdk_bathymetry_probe_timeout_seconds: int = Field(
default=20,
ge=1,
le=120,
validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS",
)
mdk_bathymetry_probe_max_response_mb: int = Field(
default=4,
ge=1,
le=16,
validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB",
)
thematic_raster_enabled: bool = Field(default=True, validation_alias="THEMATIC_RASTER_ENABLED")
thematic_raster_wcs_url: str = Field(
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
validation_alias="THEMATIC_RASTER_WCS_URL",
)
mdk_bathymetry_acquisition_enabled: bool = Field(
default=False,
validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED",
)
mdk_bathymetry_coverage_id: str | None = Field(default=None, validation_alias="MDK_BATHYMETRY_COVERAGE_ID")
mdk_bathymetry_request_crs: str = Field(default="EPSG:4326", validation_alias="MDK_BATHYMETRY_REQUEST_CRS")
mdk_bathymetry_max_bbox_deg2: float = Field(
default=0.25,
gt=0,
validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2",
)
mdk_bathymetry_acquisition_timeout_seconds: int = Field(
default=120,
ge=1,
validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS",
)
mdk_bathymetry_acquisition_max_response_mb: int = Field(
default=160,
ge=1,
validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB",
)
thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M")
thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M")
thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS")
thematic_raster_timeout_seconds: int = Field(default=300, ge=1, validation_alias="THEMATIC_RASTER_TIMEOUT_SECONDS")
thematic_raster_max_response_mb: int = Field(default=160, ge=1, validation_alias="THEMATIC_RASTER_MAX_RESPONSE_MB")
walous_enabled: bool = Field(default=True, validation_alias="WALOUS_ENABLED")
walous_source_dir: str = Field(
default="/app/storage/source-cache/walous",
validation_alias="WALOUS_SOURCE_DIR",
)
walous_analysis_resolution_m: float = Field(
default=10.0,
ge=1.0,
le=100.0,
validation_alias="WALOUS_ANALYSIS_RESOLUTION_M",
)
walous_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="WALOUS_MAX_SIDE_M")
walous_max_pixels: int = Field(default=36_000_000, ge=1, validation_alias="WALOUS_MAX_PIXELS")
spw_terrain_enabled: bool = Field(default=True, validation_alias="SPW_TERRAIN_ENABLED")
spw_terrain_source_dir: str = Field(
default="/app/storage/source-cache/spw-terrain",
validation_alias="SPW_TERRAIN_SOURCE_DIR",
)
spw_terrain_analysis_resolution_m: float = Field(
default=5.0,
ge=1.0,
le=10.0,
validation_alias="SPW_TERRAIN_ANALYSIS_RESOLUTION_M",
)
spw_terrain_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="SPW_TERRAIN_MAX_SIDE_M")
spw_terrain_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="SPW_TERRAIN_MAX_PIXELS")
redis_url: str | None = Field(default=None, validation_alias="REDIS_URL")
log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL")
sql_log_level: str = Field(default="WARNING", validation_alias="GEOINTEL_SQL_LOG_LEVEL")
reconcile_interrupted_runs_on_startup: bool = Field(
default=False,
validation_alias="GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP",
)
aoi_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AOI_WORKER_ENABLED")
aoi_worker_poll_seconds: float = Field(default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_AOI_WORKER_POLL_SECONDS")
# Executes queued detection.run / segmentation.run jobs so tiled GPU
# inference never blocks an HTTP request.
analysis_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_ANALYSIS_WORKER_ENABLED")
analysis_worker_poll_seconds: float = Field(
default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS"
)
database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS")
yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED")
yolo_models_dir: str = Field(default="/app/models", validation_alias="YOLO_MODELS_DIR")
yolo_model_path: str | None = Field(default=None, validation_alias="YOLO_MODEL_PATH")
yolo_model_id: str = Field(default="yolo-configured", validation_alias="YOLO_MODEL_ID")
yolo_model_display_name: str = Field(default="Configured YOLO detector", validation_alias="YOLO_MODEL_DISPLAY_NAME")
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
yolo_validation_scope_manifest_path: str | None = Field(
default=None,
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH",
)
yolo_validation_scope_manifest_sha256: str | None = Field(
default=None,
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256",
)
# Deprecated compatibility field. Mutable Area names are never an
# inference authorization boundary; deployments must use the immutable
# checksum-bound scope manifest above.
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
yolo_image_size: int = Field(default=640, validation_alias="YOLO_IMAGE_SIZE")
yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES")
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
yolo_suppress_tile_edge_detections: bool = Field(
default=True, validation_alias="YOLO_SUPPRESS_TILE_EDGE_DETECTIONS"
)
# Intersection over the smaller box. The candidate evaluation freezes this
# during calibration; serving a promoted model at a different value means
# the runtime suppresses detections the gate counted.
yolo_containment_nms_threshold: float = Field(
default=0.85, ge=0.0, le=1.0, validation_alias="YOLO_CONTAINMENT_NMS_THRESHOLD"
)
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED")
yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH")
yolo_seg_model_id: str = Field(default="yolo-seg-configured", validation_alias="YOLO_SEG_MODEL_ID")
yolo_seg_model_display_name: str = Field(
default="Configured YOLO segmentation",
validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME",
)
yolo_seg_model_version: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_VERSION")
sam_enabled: bool = Field(default=False, validation_alias="SAM_ENABLED")
sam_model_path: str | None = Field(default=None, validation_alias="SAM_MODEL_PATH")
sam_model_id: str = Field(default="sam-configured", validation_alias="SAM_MODEL_ID")
sam_model_display_name: str = Field(
default="Configured SAM segmentation",
validation_alias="SAM_MODEL_DISPLAY_NAME",
)
sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION")
segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE")
# Masks and boxes overlap differently, so segmentation carries its own
# containment value rather than borrowing the detector's.
segmentation_containment_nms_threshold: float = Field(
default=0.85,
ge=0.0,
le=1.0,
validation_alias="SEGMENTATION_CONTAINMENT_NMS_THRESHOLD",
)
segmentation_duplicate_iou_threshold: float = Field(
default=0.5,
ge=0.0,
le=1.0,
validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
)
ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED")
ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL")
ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL")
ollama_timeout_seconds: int = Field(default=120, ge=5, le=600, validation_alias="OLLAMA_TIMEOUT_SECONDS")
ollama_max_output_tokens: int = Field(default=1_200, ge=100, le=4_000, validation_alias="OLLAMA_MAX_OUTPUT_TOKENS")
ollama_context_tokens: int = Field(default=16_384, ge=4_096, le=131_072, validation_alias="OLLAMA_CONTEXT_TOKENS")
cors_origins: list[str] | str = Field(
default=["http://localhost:5173", "http://127.0.0.1:5173"],
validation_alias="CORS_ORIGINS",
)
@field_validator("cors_origins", mode="before")
@classmethod
def parse_cors_origins(cls, value: object) -> list[str]:
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, list):
return value
if value is None:
return ["http://localhost:5173", "http://127.0.0.1:5173"]
return [str(value)]
@field_validator("ollama_base_url")
@classmethod
def validate_ollama_base_url(cls, value: str) -> str:
normalized = value.strip().rstrip("/")
if not normalized.startswith(("http://", "https://")):
raise ValueError("OLLAMA_BASE_URL must use http or https")
return normalized
@model_validator(mode="after")
def validate_operator_auth(self) -> "Settings":
self.guest_display_name = self.guest_display_name.strip()
if not self.guest_display_name:
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
for field_name in (
"authentik_issuer",
"authentik_client_id",
"authentik_client_secret",
"authentik_allowed_email",
):
value = getattr(self, field_name)
setattr(self, field_name, value.strip() if value else None)
self.public_base_url = self.public_base_url.strip().rstrip("/")
authentik_values = (
self.authentik_issuer,
self.authentik_client_id,
self.authentik_client_secret,
self.authentik_allowed_email,
)
if any(authentik_values) and not all(authentik_values):
raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together")
if all(authentik_values):
if not self.auth_enabled:
raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured")
for label, value in (
("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer),
("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url),
):
parsed = urlsplit(str(value))
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment")
public_url = urlsplit(self.public_base_url)
if public_url.path not in ("", "/"):
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path")
if "@" not in str(self.authentik_allowed_email) or any(
character.isspace() for character in str(self.authentik_allowed_email)
):
raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address")
if not self.auth_enabled:
return self
if not (self.auth_username or "").strip():
raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled")
if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"):
raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash")
if len(self.auth_session_secret or "") < 32:
raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters")
return self
def get_settings() -> Settings:
return Settings()
+15
View File
@@ -0,0 +1,15 @@
class AppError(Exception):
"""Domain error used by services to return canonical API errors."""
def __init__(
self,
code: str,
message: str,
details: dict | list | None = None,
status_code: int = 400,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
self.status_code = status_code
+14
View File
@@ -0,0 +1,14 @@
import logging
import sys
def configure_logging(level: str = "INFO", sql_level: str = "WARNING") -> None:
logging.basicConfig(
level=level,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
stream=sys.stdout,
force=True,
)
for name in ["uvicorn", "uvicorn.error", "uvicorn.access"]:
logging.getLogger(name).setLevel(level)
logging.getLogger("sqlalchemy.engine").setLevel(sql_level)
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from uuid import UUID
# Stable server-owned identity: a public session must never attach itself to an
# operator project merely because the display names happen to match.
PUBLIC_DEMO_PROJECT_ID = UUID("6f7e6f12-9b62-4a3f-a5a0-4b3bb6b2c901")
PUBLIC_DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
PUBLIC_DEMO_PROJECT_MARKER = "geointel:public-demo:v1"
def is_public_demo_project(project_id: UUID) -> bool:
return project_id == PUBLIC_DEMO_PROJECT_ID
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from contextvars import ContextVar, Token
_request_id: ContextVar[str] = ContextVar("geointel_request_id", default="-")
def get_request_id() -> str:
return _request_id.get()
def set_request_id(value: str) -> Token:
return _request_id.set(value)
def reset_request_id(token: Token) -> None:
_request_id.reset(token)
View File
Whitespace-only changes.
+4
View File
@@ -0,0 +1,4 @@
from .base import Base
from .session import get_db, get_engine
__all__ = ["Base", "get_db", "get_engine"]
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from app.core.config import get_settings
engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True)
def get_db():
db: Session = SessionLocal()
try:
yield db
finally:
db.close()
def get_engine():
return engine
View File
Whitespace-only changes.
+467
View File
@@ -0,0 +1,467 @@
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, source_registry, 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.analysis_job_worker import AnalysisJobWorker
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
analysis_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))
if settings.analysis_worker_enabled:
analysis_worker_task = asyncio.create_task(
AnalysisJobWorker.run(worker_stop, settings.analysis_worker_poll_seconds)
)
try:
yield
finally:
worker_stop.set()
for task in (worker_task, analysis_worker_task):
if task is not None:
await 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(source_registry.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 "")
guest_compute_acquired = False
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",
f"{settings.api_prefix}/auth/authentik/start",
f"{settings.api_prefix}/auth/authentik/callback",
}
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",
f"{settings.api_prefix}/assistant/status",
f"{settings.api_prefix}/assistant/models",
f"{settings.api_prefix}/detection/models",
f"{settings.api_prefix}/detection/model-assets",
f"{settings.api_prefix}/detection/yolo/preflight",
f"{settings.api_prefix}/segmentation/models",
}
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 == f"{settings.api_prefix}/detection/yolo/preflight"
and request.query_params.get("check_model_load", "").lower() in {"1", "true", "yes", "on"}
):
response = JSONResponse(
status_code=403,
content=_to_error_payload(
"GUEST_MODEL_LOAD_FORBIDDEN",
"Model loading is available to authenticated operators only.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
guest_scoped_analysis_read = (
query_project_id == str(principal.project_id)
and normalized_path.startswith(
(
f"{settings.api_prefix}/detection/",
f"{settings.api_prefix}/segmentation/",
f"{settings.api_prefix}/exports/",
)
)
)
if (
normalized_path not in guest_safe_read_paths
and not guest_project_read
and not guest_scoped_analysis_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",
f"{settings.api_prefix}/analysis/change-detection",
}
guest_scoped_analysis_post_paths = {
f"{settings.api_prefix}/detection/run",
f"{settings.api_prefix}/detection/run-async",
f"{settings.api_prefix}/segmentation/run",
f"{settings.api_prefix}/segmentation/run-async",
f"{settings.api_prefix}/qa/detections-vs-reference",
f"{settings.api_prefix}/exports/geojson",
f"{settings.api_prefix}/exports/metadata",
f"{settings.api_prefix}/exports/report",
f"{settings.api_prefix}/exports/map-result",
}
guest_safe_post_suffixes = (
"/acquire",
"/vector/select",
"/vector/select/derive",
"/raster/tile",
"/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 in guest_scoped_analysis_post_paths
and query_project_id == str(principal.project_id)
)
or (
query_project_id == str(principal.project_id)
and raw_path.startswith(
(
f"{settings.api_prefix}/detection/runs/",
f"{settings.api_prefix}/segmentation/runs/",
)
)
and raw_path.endswith("/qa/reference")
)
or (
raw_path.startswith(project_path_prefix)
and (
raw_path.endswith(guest_safe_post_suffixes)
or raw_path.endswith("/assistant/query")
)
)
)
if not is_guest_safe_post:
response = JSONResponse(
status_code=403,
content=_to_error_payload(
"GUEST_READ_ONLY",
"Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
retry_after = AuthService.consume_guest_request(
f"guest-compute:{principal.session_id}",
max_requests=settings.guest_compute_requests_per_minute,
)
if retry_after:
response = JSONResponse(
status_code=429,
content=_to_error_payload(
"GUEST_COMPUTE_RATE_LIMITED",
"The public demo compute budget is temporarily exhausted.",
request_id=request_id,
),
)
response.headers["retry-after"] = str(retry_after)
response.headers["x-request-id"] = request_id
return response
guest_compute_acquired = AuthService.try_acquire_guest_compute(
max_concurrency=settings.guest_compute_max_concurrency,
)
if not guest_compute_acquired:
response = JSONResponse(
status_code=429,
content=_to_error_payload(
"GUEST_COMPUTE_BUSY",
"The public demo is already processing its maximum number of jobs.",
request_id=request_id,
),
)
response.headers["retry-after"] = "10"
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:
if guest_compute_acquired:
AuthService.release_guest_compute()
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
View File
@@ -0,0 +1 @@
from app.models import * # noqa: F403 - legacy compatibility shim re-exports the package API
View File
Whitespace-only changes.
+43
View File
@@ -0,0 +1,43 @@
from .entities import (
AoiOperation,
AoiOperationPartition,
AnalysisRun,
Area,
Dataset,
DatasetLineageEdge,
DatasetQuarantine,
DatasetVersion,
Detection,
DetectionReview,
Export,
Job,
Metric,
Project,
QualityCheck,
Segmentation,
SourceRegistry,
SourceSnapshot,
VectorFeature,
)
__all__ = [
"AnalysisRun",
"AoiOperation",
"AoiOperationPartition",
"Area",
"Dataset",
"DatasetLineageEdge",
"DatasetQuarantine",
"DatasetVersion",
"Detection",
"DetectionReview",
"Export",
"Job",
"Metric",
"Project",
"QualityCheck",
"Segmentation",
"SourceRegistry",
"SourceSnapshot",
"VectorFeature",
]
+788
View File
@@ -0,0 +1,788 @@
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
SOURCE_CLASSIFICATIONS = (
"authoritative",
"corroborative",
"contextual",
"derived",
"experimental",
)
SOURCE_FRESHNESS_STATUSES = (
"unknown",
"current",
"due",
"stale",
"not_applicable",
"review_required",
)
SOURCE_INGEST_STATUSES = (
"registered",
"configured",
"not_configured",
"available",
"ingested",
"failed",
"quarantined",
"legacy_unverified",
)
PROVENANCE_STATUSES = ("complete", "incomplete", "not_applicable")
LINEAGE_STATUSES = ("complete", "incomplete", "not_applicable")
VALIDATION_STATUSES = ("not_validated", "passed", "failed")
QUARANTINE_STATUSES = ("not_quarantined", "quarantined")
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 SourceRegistry(Base):
"""Server-owned source identity and authority contract.
Dataset metadata remains descriptive until a governed importer binds a
dataset to both this registry entry and an immutable SourceSnapshot.
"""
__tablename__ = "source_registry"
__table_args__ = (
UniqueConstraint("source_key", name="uq_source_registry_source_key"),
CheckConstraint(
"classification IN ('authoritative', 'corroborative', 'contextual', 'derived', 'experimental')",
name="ck_source_registry_classification",
),
CheckConstraint(
"freshness_status IN ('unknown', 'current', 'due', 'stale', 'not_applicable', 'review_required')",
name="ck_source_registry_freshness_status",
),
CheckConstraint(
"ingest_status IN ('registered', 'configured', 'not_configured', 'available', 'ingested', "
"'failed', 'quarantined', 'legacy_unverified')",
name="ck_source_registry_ingest_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_key: Mapped[str] = mapped_column(String(120), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
classification: Mapped[str] = mapped_column(String(32), nullable=False)
authority_name: Mapped[str] = mapped_column(String(255), nullable=False, default="unknown", server_default="unknown")
authority_scope_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
provider_adapter_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
license_name: Mapped[str] = mapped_column(String(255), nullable=False, default="unknown", server_default="unknown")
license_url: Mapped[str | None] = mapped_column(Text, nullable=True)
usage_restrictions: Mapped[str] = mapped_column(Text, nullable=False, default="unknown", server_default="unknown")
default_crs: Mapped[str] = mapped_column(String(64), nullable=False, default="unknown", server_default="unknown")
default_units: Mapped[str] = mapped_column(String(120), nullable=False, default="unknown", server_default="unknown")
spatial_resolution_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
temporal_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
geographic_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
expected_geometry_types_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
expected_attributes_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
usage_policy_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
freshness_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="unknown", server_default="unknown"
)
ingest_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="registered", server_default="registered"
)
known_limitations_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
registry_metadata_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
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())
snapshots: Mapped[list["SourceSnapshot"]] = relationship(
"SourceSnapshot", back_populates="source_registry", cascade="all, delete-orphan"
)
datasets: Mapped[list["Dataset"]] = relationship("Dataset", back_populates="source_registry")
dataset_versions: Mapped[list["DatasetVersion"]] = relationship("DatasetVersion", back_populates="source_registry")
class SourceSnapshot(Base):
"""Immutable source-version evidence recorded by governed ingestion."""
__tablename__ = "source_snapshots"
__table_args__ = (
UniqueConstraint("source_registry_id", "snapshot_key", name="uq_source_snapshots_registry_key"),
CheckConstraint(
"freshness_status IN ('unknown', 'current', 'due', 'stale', 'not_applicable', 'review_required')",
name="ck_source_snapshots_freshness_status",
),
CheckConstraint(
"ingest_status IN ('registered', 'configured', 'not_configured', 'available', 'ingested', "
"'failed', 'quarantined', 'legacy_unverified')",
name="ck_source_snapshots_ingest_status",
),
CheckConstraint(
"checksum_sha256 = lower(checksum_sha256) AND checksum_sha256 ~ '^[0-9a-f]{64}$'",
name="ck_source_snapshots_checksum_sha256",
),
Index("ix_source_snapshots_registry_fetched", "source_registry_id", "fetched_at"),
Index("ix_source_snapshots_checksum", "checksum_sha256"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_registry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_registry.id", ondelete="CASCADE"), nullable=False
)
snapshot_key: Mapped[str] = mapped_column(String(255), nullable=False)
source_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
crs: Mapped[str | None] = mapped_column(String(64), nullable=True)
units: Mapped[str | None] = mapped_column(String(120), nullable=True)
spatial_resolution_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
temporal_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
geographic_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
observed_schema_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
freshness_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="unknown", server_default="unknown"
)
ingest_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="registered", server_default="registered"
)
known_limitations_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
snapshot_metadata_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
source_registry: Mapped[SourceRegistry] = relationship("SourceRegistry", back_populates="snapshots")
datasets: Mapped[list["Dataset"]] = relationship("Dataset", back_populates="source_snapshot")
dataset_versions: Mapped[list["DatasetVersion"]] = relationship("DatasetVersion", back_populates="source_snapshot")
quarantines: Mapped[list["DatasetQuarantine"]] = relationship("DatasetQuarantine", back_populates="source_snapshot")
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",
),
CheckConstraint(
"validation_status IN ('not_validated', 'passed', 'failed')",
name="ck_datasets_validation_status",
),
CheckConstraint(
"provenance_status IN ('complete', 'incomplete', 'not_applicable')",
name="ck_datasets_provenance_status",
),
CheckConstraint(
"lineage_status IN ('complete', 'incomplete', 'not_applicable')",
name="ck_datasets_lineage_status",
),
CheckConstraint(
"quarantine_status IN ('not_quarantined', 'quarantined')",
name="ck_datasets_quarantine_status",
),
CheckConstraint(
"ingest_key IS NULL OR btrim(ingest_key) <> ''",
name="ck_datasets_ingest_key_not_blank",
),
UniqueConstraint("project_id", "ingest_key", name="uq_datasets_project_ingest_key"),
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)
ingest_key: Mapped[str | None] = mapped_column(String(255), 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)
source_registry_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_registry.id", ondelete="SET NULL"), nullable=True
)
source_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_snapshots.id", ondelete="SET NULL"), nullable=True
)
data_contract_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
data_contract_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
validation_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="not_validated",
server_default="not_validated",
comment="not_validated | passed | failed",
)
validation_report_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
provenance_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="incomplete",
server_default="incomplete",
comment="complete | incomplete | not_applicable",
)
lineage_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="incomplete",
server_default="incomplete",
comment="complete | incomplete | not_applicable",
)
quarantine_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="not_quarantined",
server_default="not_quarantined",
comment="not_quarantined | quarantined",
)
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",
)
source_registry: Mapped[SourceRegistry | None] = relationship("SourceRegistry", back_populates="datasets")
source_snapshot: Mapped[SourceSnapshot | None] = relationship("SourceSnapshot", back_populates="datasets")
parent_lineage_edges: Mapped[list["DatasetLineageEdge"]] = relationship(
"DatasetLineageEdge",
foreign_keys="DatasetLineageEdge.parent_dataset_id",
back_populates="parent_dataset",
cascade="all, delete-orphan",
)
child_lineage_edges: Mapped[list["DatasetLineageEdge"]] = relationship(
"DatasetLineageEdge",
foreign_keys="DatasetLineageEdge.child_dataset_id",
back_populates="child_dataset",
cascade="all, delete-orphan",
)
quarantines: Mapped[list["DatasetQuarantine"]] = relationship(
"DatasetQuarantine", 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",
),
CheckConstraint(
"validation_status IN ('not_validated', 'passed', 'failed')",
name="ck_dataset_versions_validation_status",
),
CheckConstraint(
"provenance_status IN ('complete', 'incomplete', 'not_applicable')",
name="ck_dataset_versions_provenance_status",
),
CheckConstraint(
"lineage_status IN ('complete', 'incomplete', 'not_applicable')",
name="ck_dataset_versions_lineage_status",
),
CheckConstraint(
"ingest_key IS NULL OR btrim(ingest_key) <> ''",
name="ck_dataset_versions_ingest_key_not_blank",
),
Index("ix_dataset_versions_dataset_version", "dataset_id", "version", unique=True),
UniqueConstraint("dataset_id", "ingest_key", name="uq_dataset_versions_dataset_ingest_key"),
)
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)
ingest_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
provenance_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
source_registry_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_registry.id", ondelete="SET NULL"), nullable=True
)
source_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_snapshots.id", ondelete="SET NULL"), nullable=True
)
data_contract_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
data_contract_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
validation_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="not_validated",
server_default="not_validated",
comment="not_validated | passed | failed",
)
validation_report_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
provenance_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="incomplete",
server_default="incomplete",
comment="complete | incomplete | not_applicable",
)
lineage_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="incomplete",
server_default="incomplete",
comment="complete | incomplete | not_applicable",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
dataset: Mapped[Dataset] = relationship("Dataset", back_populates="versions")
source_registry: Mapped[SourceRegistry | None] = relationship("SourceRegistry", back_populates="dataset_versions")
source_snapshot: Mapped[SourceSnapshot | None] = relationship("SourceSnapshot", back_populates="dataset_versions")
quarantines: Mapped[list["DatasetQuarantine"]] = relationship("DatasetQuarantine", back_populates="dataset_version")
class DatasetLineageEdge(Base):
"""Immutable relationship between input/output datasets and transforms."""
__tablename__ = "dataset_lineage_edges"
__table_args__ = (
CheckConstraint("parent_dataset_id <> child_dataset_id", name="ck_dataset_lineage_edges_distinct_datasets"),
UniqueConstraint(
"parent_dataset_id",
"child_dataset_id",
"relation_type",
"transformation_name",
name="uq_dataset_lineage_edges_relation",
),
Index("ix_dataset_lineage_edges_parent", "parent_dataset_id"),
Index("ix_dataset_lineage_edges_child", "child_dataset_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
parent_dataset_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
)
child_dataset_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
)
parent_dataset_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dataset_versions.id", ondelete="SET NULL"), nullable=True
)
child_dataset_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dataset_versions.id", ondelete="SET NULL"), nullable=True
)
relation_type: Mapped[str] = mapped_column(String(64), nullable=False)
transformation_name: Mapped[str] = mapped_column(String(255), nullable=False)
transformation_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
parameters_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
input_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
output_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
parent_dataset: Mapped[Dataset] = relationship(
"Dataset", foreign_keys=[parent_dataset_id], back_populates="parent_lineage_edges"
)
child_dataset: Mapped[Dataset] = relationship(
"Dataset", foreign_keys=[child_dataset_id], back_populates="child_lineage_edges"
)
class DatasetQuarantine(Base):
"""Durable fail-closed record for rejected or doubtful source artifacts."""
__tablename__ = "dataset_quarantines"
__table_args__ = (
CheckConstraint(
"dataset_id IS NOT NULL OR dataset_version_id IS NOT NULL OR source_snapshot_id IS NOT NULL",
name="ck_dataset_quarantines_target_present",
),
CheckConstraint(
"status IN ('quarantined', 'released', 'rejected')",
name="ck_dataset_quarantines_status",
),
Index("ix_dataset_quarantines_dataset_status", "dataset_id", "status"),
Index("ix_dataset_quarantines_snapshot_status", "source_snapshot_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
dataset_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True
)
dataset_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dataset_versions.id", ondelete="SET NULL"), nullable=True
)
source_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_snapshots.id", ondelete="SET NULL"), nullable=True
)
stage: Mapped[str] = mapped_column(String(64), nullable=False)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
artifact_path: Mapped[str | None] = mapped_column(Text, nullable=True)
artifact_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="quarantined", server_default="quarantined"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
dataset: Mapped[Dataset | None] = relationship("Dataset", back_populates="quarantines")
dataset_version: Mapped[DatasetVersion | None] = relationship("DatasetVersion", back_populates="quarantines")
source_snapshot: Mapped[SourceSnapshot | None] = relationship("SourceSnapshot", back_populates="quarantines")
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())
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from app.providers import base, fixture, grb, manual, osm, registry
__all__ = ["base", "fixture", "grb", "manual", "osm", "registry"]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ProviderCapability:
provider_name: str
display_name: str
authority_level: str
supported_layers: list[str]
supported_geometry_types: list[str]
supported_query_modes: list[str]
fetch_signature: str
configured: bool
status: str
limitation_message: str
attribution: str
license_note: str
not_configured_reason: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"provider_name": self.provider_name,
"display_name": self.display_name,
"authority_level": self.authority_level,
"supported_layers": self.supported_layers,
"supported_geometry_types": self.supported_geometry_types,
"supported_query_modes": self.supported_query_modes,
"fetch_signature": self.fetch_signature,
"configured": self.configured,
"status": self.status,
"limitation_message": self.limitation_message,
"attribution": self.attribution,
"license_note": self.license_note,
"not_configured_reason": self.not_configured_reason,
}
class BaseReferenceProvider:
def __init__(
self,
provider_name: str,
display_name: str,
authority_level: str,
supported_layers: list[str],
supported_geometry_types: list[str],
supported_query_modes: list[str],
fetch_signature: str,
limitation_message: str,
attribution: str,
license_note: str,
configured: bool = False,
) -> None:
self.provider_name = provider_name
self.display_name = display_name
self.authority_level = authority_level
self.supported_layers = supported_layers
self.supported_geometry_types = supported_geometry_types
self.supported_query_modes = supported_query_modes
self.fetch_signature = fetch_signature
self.limitation_message = limitation_message
self.attribution = attribution
self.license_note = license_note
self._configured = configured
@property
def capability(self) -> ProviderCapability:
return ProviderCapability(
provider_name=self.provider_name,
display_name=self.display_name,
authority_level=self.authority_level,
supported_layers=self.supported_layers,
supported_geometry_types=self.supported_geometry_types,
supported_query_modes=self.supported_query_modes,
fetch_signature=self.fetch_signature,
configured=self.is_configured,
status="configured" if self.is_configured else "not_configured",
limitation_message=self.limitation_message,
attribution=self.attribution,
license_note=self.license_note,
not_configured_reason=None if self.is_configured else "Provider integration is not configured yet",
)
@property
def is_configured(self) -> bool:
return self._configured
def fetch(self, project_id: str, area_id: str | None, layers: list[str]) -> dict[str, Any]:
del project_id, area_id, layers
return {
"provider": self.provider_name,
"status": "not_configured",
"message": "Provider integration is not configured yet",
}
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.providers.base import BaseReferenceProvider
class FixtureProvider(BaseReferenceProvider):
def __init__(self) -> None:
super().__init__(
provider_name="fixture",
display_name="Fixture data",
authority_level="fixture",
supported_layers=["buildings", "roads", "water", "landuse", "custom"],
supported_geometry_types=["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"],
supported_query_modes=["fixture"],
fetch_signature="tests/fixtures and demo fixture upload flow",
limitation_message="Fixture provider represents local test/demo fixtures only.",
attribution="GeoIntel local fixtures",
license_note="Fixtures are for local development and tests; do not present them as official data.",
configured=True,
)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from app.providers.base import BaseReferenceProvider
class GRBProvider(BaseReferenceProvider):
def __init__(self) -> None:
super().__init__(
provider_name="grb",
display_name="GRB",
authority_level="authoritative",
supported_layers=["buildings", "roads", "water", "parcels"],
supported_geometry_types=["Polygon", "MultiPolygon", "LineString", "MultiLineString"],
supported_query_modes=["bbox", "persisted_area"],
fetch_signature="POST /api/v1/projects/{project_id}/datasets/grb/acquire",
limitation_message=(
"Alleen expliciet begrensde selecties tot 20 km per zijde worden opgehaald. "
"Volledige providerdownloads en onbeperkte queries zijn niet toegestaan."
),
attribution="Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen",
license_note="Hergebruik volgens de open-datavoorwaarden en bronvermelding van Digitaal Vlaanderen.",
configured=True,
)
def fetch(self, project_id: str, area_id: str | None, layers: list[str]) -> dict:
del project_id, area_id, layers
return {
"provider": self.provider_name,
"status": "bounded_request_required",
"message": (
"Use POST /api/v1/projects/{project_id}/datasets/grb/acquire with an EPSG:4326 "
"bounding box and one governed product key."
),
}
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.providers.base import BaseReferenceProvider
class ManualProvider(BaseReferenceProvider):
def __init__(self) -> None:
super().__init__(
provider_name="manual",
display_name="Manual upload",
authority_level="manual",
supported_layers=["buildings", "roads", "water", "landuse", "custom"],
supported_geometry_types=["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"],
supported_query_modes=["upload"],
fetch_signature="POST /api/v1/projects/{project_id}/datasets/upload",
limitation_message="Manual provider data is supplied through the existing dataset upload flow.",
attribution="User supplied",
license_note="License and attribution must be supplied by the uploader in source metadata.",
configured=True,
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.providers.base import BaseReferenceProvider
class OSMProvider(BaseReferenceProvider):
def __init__(self) -> None:
super().__init__(
provider_name="osm",
display_name="OpenStreetMap",
authority_level="contextual",
supported_layers=["buildings", "roads", "water", "landuse"],
supported_geometry_types=["Polygon", "MultiPolygon", "LineString", "MultiLineString"],
supported_query_modes=["area"],
fetch_signature="POST /api/v1/external/osm/fetch",
limitation_message="OSM live Overpass/download integration is not configured in Sprint 7B.",
attribution="OpenStreetMap contributors",
license_note="OpenStreetMap data is available under ODbL; attribution is required.",
configured=False,
)
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from pydantic import BaseModel
from app.core.errors import AppError
from app.providers.base import ProviderCapability
from app.providers.fixture import FixtureProvider
from app.providers.grb import GRBProvider
from app.providers.manual import ManualProvider
from app.providers.osm import OSMProvider
class ProviderDatasetMapping(BaseModel):
provider_name: str
dataset_role: str
source_name: str
reference_required: bool
write_path: str = "DatasetService"
class ProviderImportResult(BaseModel):
provider_name: str
status: str
message: str
requested_layers: list[str]
dataset_id: str | None = None
dataset_role: str | None = None
source_name: str | None = None
class ExternalProviderRegistry:
def __init__(self) -> None:
self.providers = {
"grb": GRBProvider(),
"osm": OSMProvider(),
"manual": ManualProvider(),
"fixture": FixtureProvider(),
}
def list_capabilities(self) -> list[ProviderCapability]:
return [provider.capability for provider in self.providers.values()]
def get(self, provider_name: str):
normalized = provider_name.strip().lower()
if normalized not in self.providers:
raise AppError(code="PROVIDER_NOT_FOUND", message="Provider not found", status_code=404)
return self.providers[normalized]
def fetch(self, provider_name: str, project_id: str, area_id: str | None, layers: list[str]) -> dict:
provider = self.get(provider_name)
return provider.fetch(project_id=project_id, area_id=area_id, layers=layers)
def dataset_mapping(self, provider_name: str, requested_dataset_role: str | None = None) -> ProviderDatasetMapping:
provider = self.get(provider_name)
if provider.provider_name == "osm":
dataset_role = "reference" if requested_dataset_role == "reference" else "source"
return ProviderDatasetMapping(
provider_name="osm",
dataset_role=dataset_role,
source_name="osm",
reference_required=requested_dataset_role == "reference",
)
return ProviderDatasetMapping(
provider_name=provider.provider_name,
dataset_role="reference",
source_name=provider.provider_name,
reference_required=True,
)
def import_contract(
self,
provider_name: str,
project_id: str,
area_id: str | None,
layers: list[str],
requested_dataset_role: str | None = None,
) -> ProviderImportResult:
del project_id, area_id
provider = self.get(provider_name)
mapping = self.dataset_mapping(provider.provider_name, requested_dataset_role=requested_dataset_role)
if provider.provider_name == "grb":
return ProviderImportResult(
provider_name="grb",
status="bounded_request_required",
message=(
"Use the governed project GRB acquisition endpoint with an EPSG:4326 bounding box "
"and one supported layer."
),
requested_layers=layers,
dataset_role=mapping.dataset_role,
source_name=mapping.source_name,
)
if provider.provider_name == "osm":
return ProviderImportResult(
provider_name=provider.provider_name,
status="not_configured",
message=f"No live {provider.display_name} import is configured.",
requested_layers=layers,
dataset_role=mapping.dataset_role,
source_name=mapping.source_name,
)
if provider.provider_name == "manual":
return ProviderImportResult(
provider_name="manual",
status="upload_flow_required",
message="Manual provider data must use the existing dataset upload/reference flow.",
requested_layers=layers,
dataset_role=mapping.dataset_role,
source_name=mapping.source_name,
)
return ProviderImportResult(
provider_name="fixture",
status="fixture_flow_required",
message="Fixture provider data must use checked-in demo/test fixture flows.",
requested_layers=layers,
dataset_role=mapping.dataset_role,
source_name=mapping.source_name,
)
_registry = ExternalProviderRegistry()
def list_provider_capabilities() -> list[ProviderCapability]:
return _registry.list_capabilities()
def get_provider(provider_name: str):
return _registry.get(provider_name)
def fetch_provider_data(provider_name: str, project_id: str, area_id: str | None, layers: list[str]) -> dict:
return _registry.fetch(provider_name, project_id, area_id, layers)
def get_provider_dataset_mapping(provider_name: str, requested_dataset_role: str | None = None) -> ProviderDatasetMapping:
return _registry.dataset_mapping(provider_name, requested_dataset_role=requested_dataset_role)
def import_provider_dataset(
provider_name: str,
project_id: str,
area_id: str | None,
layers: list[str],
requested_dataset_role: str | None = None,
) -> ProviderImportResult:
return _registry.import_contract(
provider_name=provider_name,
project_id=project_id,
area_id=area_id,
layers=layers,
requested_dataset_role=requested_dataset_role,
)
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
from .common import (
ApiErrorEnvelope,
ApiErrorItem,
Envelope,
GeoJsonFeature,
GeoJsonFeatureCollection,
ItemList,
PaginationEnvelope,
)
from .coverage import (
CoverageBBox,
CoverageCatalogResponse,
CoverageResolutionItem,
CoverageResolveRequest,
CoverageResolveResponse,
CoverageSourceContract,
)
from .project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
from .area import AreaCreate, AreaList, AreaRead, AreaUpdate
from .analysis import ChangeDetectionRequest, ChangeDetectionSummary
from .dataset import DatasetCreateResponse, DatasetList
from .source_freshness import (
SourceFreshnessItem,
SourceFreshnessReport,
SourceFreshnessSummary,
SourceIntegritySummary,
)
from .source_catalog import (
SourceCatalogProbeItem,
SourceCatalogProbeReport,
SourceCatalogProbeSummary,
)
from .source_registry import (
DatasetProvenanceRead,
DatasetLineageEdgeRead,
DatasetQuarantineRead,
SourceRegistryDetailRead,
SourceRegistryRead,
SourceSnapshotRead,
)
from .grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary
from .grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead
from .official_vector import (
OfficialVectorAcquireRequest,
OfficialVectorAcquisitionResult,
OfficialVectorProductRead,
)
from .detection import (
DetectionListResponse,
DetectionModelCapability,
DetectionModelsResponse,
DetectionQaRequest,
DetectionRead,
DetectionRunListResponse,
DetectionRunRead,
DetectionRunRequest,
DetectionComparisonRequest,
DetectionComparisonResponse,
DetectionRunResponse,
ModelAssetListResponse,
ModelAssetRead,
YoloPreflightResponse,
)
from .detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewSummary, DetectionReviewUpsert
from .segmentation import (
SegmentationListResponse,
SegmentationModelCapability,
SegmentationModelsResponse,
SegmentationQaRequest,
SegmentationRead,
SegmentationRunListResponse,
SegmentationRunRead,
SegmentationRunRequest,
SegmentationRunResponse,
)
from .health import HealthResponse, SystemCapabilities
from .job import JobCreate, JobList, JobRead, JobStatus
from .orthophoto import OrthophotoAcquireRequest, OrthophotoAcquisitionResult, OrthophotoProductRead
from .dhmv import (
DhmvAcquireRequest,
DhmvAcquisitionResult,
DhmvProductRead,
TerrainMetric,
TerrainPartitionSelectionRequest,
TerrainSelectionRequest,
TerrainSelectionResponse,
TerrainSelectionSummary,
)
from .spw_terrain import (
SpwTerrainAcquireRequest,
SpwTerrainAcquisitionResult,
SpwTerrainProductRead,
)
from .flood_hazard import (
FloodHazardAcquireRequest,
FloodHazardAcquisitionResult,
FloodHazardMetric,
FloodHazardPartitionSelectionRequest,
FloodHazardProductRead,
FloodHazardSelectionRequest,
FloodHazardSelectionResponse,
FloodHazardSelectionSummary,
)
from .bathymetry import (
BathymetryPartitionFinalizeRequest,
BathymetryPartitionFinalizationResult,
BathymetryProfileAcquireRequest,
BathymetryProfileAcquisitionResult,
BathymetryRasterMetric,
BathymetryRasterSelectionRequest,
BathymetryRasterSelectionResponse,
BathymetryRasterSelectionSummary,
BathymetrySourceProbeRead,
BathymetrySourceRead,
MdkBathymetryAcquireRequest,
MdkBathymetryAcquisitionResult,
)
from .thematic_raster import (
ThematicRasterAcquireRequest,
ThematicRasterAcquisitionResult,
ThematicRasterMetric,
ThematicRasterProductRead,
ThematicRasterSelectionRequest,
ThematicRasterSelectionResponse,
ThematicRasterSelectionSummary,
)
from .external import (
ExternalFetchRequest,
ExternalFetchResponse,
ProviderCapabilitiesResponse,
ProviderCapabilityResponse,
ProviderImportRequest,
ProviderImportResponse,
ProviderLayersResponse,
ProviderStatusResponse,
)
from .export import (
ExportContentResponse,
ExportCreateResponse,
ExportListResponse,
ExportRead,
GeoJsonExportRequest,
MetadataExportRequest,
ReportExportRequest,
)
from .qa import (
AnalysisQaResponse,
QaProviderComparisonRequest,
QaProviderComparisonResult,
QualityEvidenceResponse,
)
from .operations import (
RasterClipRequest,
RasterIndexBaseRequest,
RasterMetadataResponse,
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
RasterOperationResult,
RasterPreviewResponse,
RasterReprojectRequest,
RasterReprojectResponse,
RasterStatsResponse,
RasterTileManifest,
RasterTileManifestTile,
RasterTileRequest,
RasterTileResponse,
VectorBBoxResponse,
VectorBufferRequest,
VectorClipRequest,
VectorIntersectRequest,
VectorOperationRequest,
VectorOperationResult,
VectorSelectionBBox,
VectorSelectionDeriveRequest,
VectorSelectionRequest,
VectorSelectionResponse,
VectorSelectionMetric,
VectorSelectionSummary,
VectorStatsRequest,
VectorStatsResponse,
)
__all__ = [
"Envelope",
"ItemList",
"GeoJsonFeature",
"GeoJsonFeatureCollection",
"ApiErrorEnvelope",
"ApiErrorItem",
"PaginationEnvelope",
"CoverageBBox",
"CoverageCatalogResponse",
"CoverageResolutionItem",
"CoverageResolveRequest",
"CoverageResolveResponse",
"CoverageSourceContract",
"ProjectCreate",
"ProjectRead",
"ProjectUpdate",
"ProjectList",
"ProjectDeleteResult",
"AreaCreate",
"AreaRead",
"AreaUpdate",
"AreaList",
"ChangeDetectionRequest",
"ChangeDetectionSummary",
"DatasetCreateResponse",
"DatasetList",
"SourceFreshnessItem",
"SourceFreshnessReport",
"SourceFreshnessSummary",
"SourceIntegritySummary",
"SourceCatalogProbeItem",
"SourceCatalogProbeReport",
"SourceCatalogProbeSummary",
"SourceRegistryRead",
"SourceRegistryDetailRead",
"SourceSnapshotRead",
"DatasetLineageEdgeRead",
"DatasetQuarantineRead",
"DatasetProvenanceRead",
"GrbRefreshLayerPlan",
"GrbRefreshPlan",
"GrbRefreshPlanSummary",
"GrbAcquireRequest",
"GrbAcquisitionResult",
"GrbProductRead",
"OfficialVectorAcquireRequest",
"OfficialVectorAcquisitionResult",
"OfficialVectorProductRead",
"DetectionListResponse",
"DetectionModelCapability",
"DetectionModelsResponse",
"DetectionQaRequest",
"DetectionRead",
"DetectionRunListResponse",
"DetectionRunRead",
"DetectionRunRequest",
"DetectionComparisonRequest",
"DetectionComparisonResponse",
"DetectionRunResponse",
"ModelAssetListResponse",
"ModelAssetRead",
"YoloPreflightResponse",
"DetectionReviewList",
"DetectionReviewRead",
"DetectionReviewSummary",
"DetectionReviewUpsert",
"SegmentationListResponse",
"SegmentationModelCapability",
"SegmentationModelsResponse",
"SegmentationQaRequest",
"SegmentationRead",
"SegmentationRunListResponse",
"SegmentationRunRead",
"SegmentationRunRequest",
"SegmentationRunResponse",
"HealthResponse",
"SystemCapabilities",
"JobCreate",
"JobList",
"JobRead",
"JobStatus",
"OrthophotoAcquireRequest",
"OrthophotoAcquisitionResult",
"OrthophotoProductRead",
"DhmvAcquireRequest",
"DhmvAcquisitionResult",
"DhmvProductRead",
"SpwTerrainAcquireRequest",
"SpwTerrainAcquisitionResult",
"SpwTerrainProductRead",
"TerrainMetric",
"TerrainPartitionSelectionRequest",
"TerrainSelectionRequest",
"TerrainSelectionResponse",
"TerrainSelectionSummary",
"FloodHazardAcquireRequest",
"FloodHazardAcquisitionResult",
"FloodHazardMetric",
"FloodHazardPartitionSelectionRequest",
"FloodHazardProductRead",
"FloodHazardSelectionRequest",
"FloodHazardSelectionResponse",
"FloodHazardSelectionSummary",
"BathymetryProfileAcquireRequest",
"BathymetryProfileAcquisitionResult",
"BathymetryRasterMetric",
"BathymetryRasterSelectionRequest",
"BathymetryRasterSelectionResponse",
"BathymetryRasterSelectionSummary",
"BathymetryPartitionFinalizeRequest",
"BathymetryPartitionFinalizationResult",
"BathymetrySourceProbeRead",
"BathymetrySourceRead",
"MdkBathymetryAcquireRequest",
"MdkBathymetryAcquisitionResult",
"ThematicRasterAcquireRequest",
"ThematicRasterAcquisitionResult",
"ThematicRasterMetric",
"ThematicRasterProductRead",
"ThematicRasterSelectionRequest",
"ThematicRasterSelectionResponse",
"ThematicRasterSelectionSummary",
"VectorBBoxResponse",
"VectorClipRequest",
"VectorBufferRequest",
"VectorIntersectRequest",
"VectorOperationRequest",
"VectorOperationResult",
"VectorSelectionBBox",
"VectorSelectionDeriveRequest",
"VectorSelectionRequest",
"VectorSelectionResponse",
"VectorSelectionMetric",
"VectorSelectionSummary",
"RasterClipRequest",
"RasterStatsResponse",
"RasterReprojectRequest",
"RasterReprojectResponse",
"RasterTileRequest",
"RasterMetadataResponse",
"RasterOperationResult",
"RasterPreviewResponse",
"RasterTileManifestTile",
"RasterTileManifest",
"RasterTileResponse",
"RasterIndexBaseRequest",
"RasterNdviRequest",
"RasterNdwiRequest",
"RasterNdbiRequest",
"VectorStatsRequest",
"VectorStatsResponse",
"ExternalFetchRequest",
"ExternalFetchResponse",
"ProviderCapabilitiesResponse",
"ProviderCapabilityResponse",
"ProviderImportRequest",
"ProviderImportResponse",
"ProviderLayersResponse",
"ProviderStatusResponse",
"GeoJsonExportRequest",
"MetadataExportRequest",
"ReportExportRequest",
"ExportRead",
"ExportCreateResponse",
"ExportListResponse",
"ExportContentResponse",
"QaProviderComparisonRequest",
"QaProviderComparisonResult",
"AnalysisQaResponse",
"QualityEvidenceResponse",
]
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.schemas.operations import VectorSelectionBBox
class ChangeDetectionRequest(BaseModel):
source_dataset_id: UUID
target_dataset_id: UUID
iou_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
# Below this the two footprints are separate objects rather than one that
# was redrawn; between the two thresholds the change class is "modified".
modified_threshold: float = Field(default=0.3, ge=0.0, le=1.0)
include_unchanged: bool = True
# Without a selection the comparison covers both datasets in full, which is
# rarely the question and never a response a map can draw.
bbox: VectorSelectionBBox | None = None
area_id: UUID | None = None
preview_limit: int = Field(default=2_000, ge=1, le=20_000)
class ChangeDetectionSummary(BaseModel):
source_dataset_id: UUID
target_dataset_id: UUID
source_feature_count: int
target_feature_count: int
added_count: int
removed_count: int
# A footprint that was redrawn rather than demolished and rebuilt. Without
# this class it appeared as one removal plus one addition.
modified_count: int = 0
unchanged_count: int
iou_threshold: float
modified_iou_threshold: float | None = None
selection_area_id: UUID | None = None
# Counts describe the whole selection; the GeoJSON is capped so a regional
# comparison does not return both datasets in one response.
preview_limit: int | None = None
preview_truncated: bool = False
warnings: list[str] = Field(default_factory=list)
generated_at: datetime
geojson: dict
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.schemas.operations import VectorSelectionBBox
class AoiOperationCreate(BaseModel):
area_id: UUID | None = None
bbox: VectorSelectionBBox | None = None
operation_type: str = Field(min_length=1, max_length=128)
provider_key: str = Field(min_length=1, max_length=120)
product_key: str = Field(min_length=1, max_length=120)
coverage_zone: str | None = Field(default=None, max_length=64)
max_partition_side_m: float | None = Field(default=None, gt=0, le=60_000)
max_attempts: int = Field(default=3, ge=1, le=10)
parameters_json: dict = Field(default_factory=dict)
class AoiPartitionRead(BaseModel):
id: UUID
partition_key: str
provider_key: str
product_key: str
ordinal: int
status: str
attempt_count: int
max_attempts: int
checkpoint_json: dict | None = None
result_json: dict | None = None
error_message: str | None = None
model_config = {"from_attributes": True}
class AoiOperationRead(BaseModel):
id: UUID
project_id: UUID
area_id: UUID | None = None
parent_job_id: UUID | None = None
operation_type: str
status: str
request_json: dict
plan_json: dict
result_json: dict | None = None
error_message: str | None = None
progress: float
partition_counts: dict[str, int]
partitions: list[AoiPartitionRead] = Field(default_factory=list)
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class AoiOperationList(BaseModel):
items: list[AoiOperationRead]
total: int
class AoiPartitionCheckpoint(BaseModel):
checkpoint_json: dict = Field(default_factory=dict)
class AoiPartitionComplete(BaseModel):
result_json: dict = Field(default_factory=dict)
skipped: bool = False
class AoiPartitionFail(BaseModel):
error_message: str = Field(min_length=1, max_length=4000)
retryable: bool = True
details: dict = Field(default_factory=dict)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class AreaCreate(BaseModel):
name: str
geometry: dict
crs: str | None = "EPSG:4326"
class AreaUpdate(BaseModel):
name: str | None = None
geometry: dict | None = None
crs: str | None = None
class AreaRead(BaseModel):
id: UUID
project_id: UUID
name: str
original_crs: str | None
area_m2: float | None
created_at: datetime | None = None
geometry_type: str | None = None
geometry: dict | None = None
model_config = {"from_attributes": True}
class AreaListItem(AreaRead):
pass
class AreaList(BaseModel):
items: list[AreaRead]
total: int
limit: int
offset: int
class MunicipalitySearchItem(BaseModel):
niscode: str
name: str
name_nl: str | None = None
name_fr: str | None = None
name_de: str | None = None
class MunicipalitySearchList(BaseModel):
items: list[MunicipalitySearchItem]
total: int
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.schemas.operations import VectorSelectionBBox
class AssistantChatMessage(BaseModel):
role: Literal["user", "assistant"]
content: str = Field(min_length=1, max_length=4_000)
class AssistantQueryRequest(BaseModel):
question: str = Field(min_length=2, max_length=2_000)
model: str | None = Field(default=None, max_length=255)
bbox: VectorSelectionBBox | None = None
area_id: UUID | None = None
history: list[AssistantChatMessage] = Field(default_factory=list, max_length=8)
class AssistantModelRead(BaseModel):
name: str
size_bytes: int | None = None
parameter_size: str | None = None
quantization_level: str | None = None
capabilities: list[str] = Field(default_factory=list)
class AssistantModelList(BaseModel):
items: list[AssistantModelRead]
total: int
default_model: str | None = None
class AssistantStatus(BaseModel):
enabled: bool
reachable: bool
status: str
base_url: str
default_model: str | None = None
model_count: int = 0
limitation_message: str
class AssistantContextMetric(BaseModel):
theme: str
label: str
value: float
unit: str
source: str
dataset_id: UUID
observed_at: datetime | None = None
is_estimate: bool = False
class AssistantTemporalSeries(BaseModel):
temporal_series_key: str
label: str
source: str
first_year: int
last_year: int
observation_count: int
class AssistantEstimateDisclosure(BaseModel):
"""A value in the answer that the source itself calls an estimate.
Derived from metric metadata rather than from the generated sentences, so
the disclosure is present whatever wording the model chose.
"""
theme: str
label: str
unit: str
source: str
dataset_id: UUID
reason: str
class AssistantQueryResponse(BaseModel):
answer: str
model: str
scope_label: str
context_metrics: list[AssistantContextMetric]
temporal_series: list[AssistantTemporalSeries]
estimate_disclosures: list[AssistantEstimateDisclosure] = Field(default_factory=list)
source_dataset_ids: list[UUID]
warnings: list[str]
generated_at: datetime
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.schemas.common import Envelope
class AuthLoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
class AuthSession(BaseModel):
authentication_required: bool
authenticated: bool
username: str | None = None
expires_at: datetime | None = None
role: Literal["operator", "guest"] | None = None
guest_access_enabled: bool = False
authentik_enabled: bool = False
guest_project_id: UUID | None = None
class AuthSessionEnvelope(Envelope[AuthSession]):
pass
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
from .operations import VectorSelectionBBox
class BathymetryProfileAcquireRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
force_refresh: bool = False
class BathymetrySourceRead(BaseModel):
key: str
display_name: str
owner: str
authority_level: Literal["authoritative", "contextual"]
geographic_coverage: str
data_kind: str
query_modes: list[str]
vertical_reference: str
horizontal_crs: str
native_resolution: str | None = None
integration_status: Literal["operational", "probe_only", "available_not_integrated", "catalog_only"]
acquisition_supported: bool
configured: bool
service_url: str | None = None
catalog_url: str
attribution: str
license_note: str
limitation_message: str
class BathymetryProfileAcquisitionResult(BaseModel):
output_dataset_id: UUID
reused: bool
provider: str
profile_count: int = Field(ge=0)
document_count: int = Field(ge=0)
structured_depth_count: int = Field(ge=0)
structured_width_count: int = Field(ge=0)
watercourse_count: int = Field(ge=0)
bbox_epsg4326: list[float]
clipped_to_area_id: UUID | None = None
measurement_date_min: str | None = None
measurement_date_max: str | None = None
attribution: str
limitation_message: str
class BathymetryPartitionFinalizeRequest(BaseModel):
partition_scope_key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9][a-z0-9_-]*$")
expected_area_ids: list[UUID] = Field(min_length=1, max_length=500)
dataset_ids: list[UUID] = Field(default_factory=list, max_length=500)
no_profile_area_ids: list[UUID] = Field(default_factory=list, max_length=500)
manifest_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
observed_at: datetime
@field_validator("expected_area_ids", "dataset_ids", "no_profile_area_ids")
@classmethod
def require_unique_ids(cls, value: list[UUID]) -> list[UUID]:
if len(value) != len(set(value)):
raise ValueError("Partition identifiers must be unique")
return value
class BathymetryPartitionFinalizationResult(BaseModel):
partition_scope_key: str
regional_partitions_complete: bool
partition_count: int = Field(ge=1)
data_partition_count: int = Field(ge=0)
no_profile_partition_count: int = Field(ge=0)
profile_count: int = Field(ge=0)
document_count: int = Field(ge=0)
structured_depth_count: int = Field(ge=0)
measurement_date_min: str | None = None
measurement_date_max: str | None = None
dataset_ids: list[UUID]
manifest_sha256: str
observed_at: datetime
limitation_message: str
class BathymetrySourceProbeRead(BaseModel):
source_key: str
status: Literal[
"disabled",
"invalid_configuration",
"tls_error",
"endpoint_unavailable",
"invalid_capabilities",
"reachable",
]
configured_url: str
capabilities_url: str | None = None
tls_verified: bool
capabilities_reachable: bool
acquisition_supported: bool = False
wcs_version: str | None = None
coverage_identifiers: list[str] = Field(default_factory=list)
advertised_formats: list[str] = Field(default_factory=list)
advertised_crs: list[str] = Field(default_factory=list)
response_sha256: str | None = None
checked_at: datetime
message: str
limitation_message: str
class MdkBathymetryAcquireRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
force_refresh: bool = False
class MdkBathymetryAcquisitionResult(BaseModel):
output_dataset_id: UUID
reused: bool
provider: str
coverage_id: str
bbox_epsg4326: list[float]
vertical_reference: str
resolution_m: float = Field(gt=0)
attribution: str
limitation_message: str
class BathymetryRasterSelectionRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
class BathymetryRasterMetric(BaseModel):
metric_key: str
metric_label: str
metric_value: float
metric_unit: str
aggregation_method: str
is_estimate: bool = False
class BathymetryRasterSelectionSummary(BaseModel):
metric_label: str
metric_value: float
metric_unit: str
aggregation_method: str
primary_metric_key: str
metrics: list[BathymetryRasterMetric]
class BathymetryRasterSelectionResponse(BaseModel):
dataset_id: UUID
product_key: str
selection_bbox: VectorSelectionBBox
selection_area_id: UUID | None = None
selected_cell_count: int = Field(ge=1)
valid_cell_count: int = Field(ge=1)
coverage_ratio: float = Field(ge=0, le=1)
# Set when the drawn selection is smaller than one source cell and the
# analysis was widened to the cells it touches, so the value covers more
# ground than was requested.
cell_selection_warning: str | None = None
resolution_m: float = Field(gt=0)
vertical_reference: str
survey_period: str
summary: BathymetryRasterSelectionSummary
unsupported_metrics: list[str]
limitation_message: str
generated_at: str
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, Field
DataT = TypeVar("DataT")
class Envelope(BaseModel, Generic[DataT]):
data: DataT
class ItemList(BaseModel, Generic[DataT]):
items: list[DataT]
total: int
class PaginatedEnvelope(ItemList[DataT], Generic[DataT]):
limit: int
offset: int
class PaginationEnvelope(BaseModel):
items: list
total: int
limit: int = Field(default=50)
offset: int = Field(default=0)
class ApiErrorItem(BaseModel):
code: str
message: str
details: dict = Field(default_factory=dict)
class ApiErrorEnvelope(BaseModel):
error: str
message: str
details: dict | list = Field(default_factory=dict)
request_id: str | None = None
class GeoJsonFeature(BaseModel):
type: Literal["Feature"]
id: str | int | None = None
geometry: dict[str, Any] | None
properties: dict[str, Any] = Field(default_factory=dict)
class GeoJsonFeatureCollection(BaseModel):
type: Literal["FeatureCollection"]
features: list[GeoJsonFeature]
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
CoverageStatus = Literal["operational", "partial", "not_configured", "unsupported"]
CoverageAuthority = Literal["authoritative", "official_context", "contextual"]
CoverageAcquisitionMode = Literal[
"operator_archive",
"operator_wfs",
"bounded_api",
"bounded_raster",
"catalog_only",
]
class CoverageBBox(BaseModel):
minx: float = Field(ge=-180, le=180)
miny: float = Field(ge=-90, le=90)
maxx: float = Field(ge=-180, le=180)
maxy: float = Field(ge=-90, le=90)
@model_validator(mode="after")
def validate_extent(self) -> "CoverageBBox":
if self.maxx <= self.minx or self.maxy <= self.miny:
raise ValueError("bbox max values must be greater than min values")
return self
class CoverageSourceContract(BaseModel):
source_name: str
display_name: str
authority_level: CoverageAuthority
coverage_zones: list[str]
themes: list[str]
native_layers: list[str]
supported_geometry_types: list[str]
acquisition_mode: CoverageAcquisitionMode
integration_status: CoverageStatus
source_url: str
attribution: str
license_note: str
limitation_message: str
class CoverageCatalogResponse(BaseModel):
themes: list[str]
zones: list[str]
statuses: list[CoverageStatus]
sources: list[CoverageSourceContract]
class CoverageResolveRequest(BaseModel):
project_id: UUID
bbox: CoverageBBox
themes: list[str] = Field(default_factory=list, max_length=32)
class CoverageResolutionItem(BaseModel):
zone: str
theme: str
status: CoverageStatus
source_names: list[str]
materialized_dataset_ids: list[UUID]
evidence: list["CoverageEvidenceItem"] = Field(default_factory=list)
limitation_message: str
class CoverageEvidenceItem(BaseModel):
dataset_id: UUID
source_name: str
authority_level: CoverageAuthority
source_version: str | None = None
observed_at: str | None = None
published_at: str | None = None
crs: str | None = None
resolution: dict | None = None
coverage_bbox_epsg4326: list[float] | None = None
attribution: str | None = None
license_note: str | None = None
checksum_sha256: str | None = None
class CoverageResolveResponse(BaseModel):
project_id: UUID
bbox: CoverageBBox
requested_themes: list[str]
intersected_zones: list[str]
outside_supported_scope: bool
items: list[CoverageResolutionItem]
warnings: list[str]
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class DatasetStorageResponse(BaseModel):
original_filename: str | None = None
stored_filename: str | None = None
content_type: str | None = None
size_bytes: int | None = None
checksum_sha256: str | None = None
class DatasetVectorSummary(BaseModel):
feature_count: int | None = None
geometry_types: list[str] | None = None
bounds_json: dict | None = None
approximate_area_m2: float | None = None
crs: str | None = None
feature_geometry_count: int | None = None
invalid_features: int | None = None
crs_assumed: bool | None = None
class DatasetCreateResponse(BaseModel):
id: UUID
name: str
dataset_type: str
source: str
dataset_role: str = "source"
source_name: str | None = None
reference_layer_name: str | None = None
source_metadata: dict | None = None
provenance_metadata: dict | None = None
ingest_key: str | None = None
source_registry_id: UUID | None = None
source_snapshot_id: UUID | None = None
data_contract_key: str | None = None
data_contract_version: str | None = None
validation_status: str | None = None
validation_report_json: dict | None = None
provenance_status: str | None = None
lineage_status: str | None = None
quarantine_status: str | None = None
imported_at: datetime | None = None
temporal_series_key: str | None = None
observed_at: datetime | None = None
valid_from: datetime | None = None
valid_to: datetime | None = None
temporal_granularity: str | None = None
source_version: str | None = None
project_id: UUID
area_id: UUID | None = None
storage_path: str | None = None
original_filename: str | None = None
stored_filename: str | None = None
content_type: str | None = None
size_bytes: int | None = None
checksum_sha256: str | None = None
crs: str | None = None
bounds_json: dict | None = None
metadata_json: dict | None = None
vector_summary: DatasetVectorSummary | None = None
status: str
derived_from_dataset_id: UUID | None = None
created_at: datetime | None = None
feature_count: int | None = None
model_config = {"from_attributes": True}
class DatasetList(BaseModel):
items: list[DatasetCreateResponse]
total: int
limit: int
offset: int
class DatasetMetadataRefresh(BaseModel):
feature_count: int | None = None
geometry_types: list[str] | None = None
bounds_json: dict | None = None
crs: str | None = None
class DatasetTemporalUpdate(BaseModel):
temporal_series_key: str
observed_at: datetime
valid_from: datetime | None = None
valid_to: datetime | None = None
temporal_granularity: str = "snapshot"
source_version: str | None = None
class DatasetVersionRead(BaseModel):
id: UUID
dataset_id: UUID
version: int
storage_path: str | None = None
source_version: str | None = None
observed_at: datetime | None = None
valid_from: datetime | None = None
valid_to: datetime | None = None
checksum_sha256: str | None = None
source_metadata: dict | None = None
provenance_metadata: dict | None = None
ingest_key: str | None = None
source_registry_id: UUID | None = None
source_snapshot_id: UUID | None = None
data_contract_key: str | None = None
data_contract_version: str | None = None
validation_status: str | None = None
validation_report_json: dict | None = None
provenance_status: str | None = None
lineage_status: str | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
class ExportRequest(BaseModel):
dataset_id: UUID
name: str | None = None
class ExportRead(BaseModel):
export_id: UUID
path: str
status: str
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel
class DemoWorkflowResponse(BaseModel):
project_id: UUID
area_id: UUID
reference_dataset_id: UUID
candidate_dataset_id: UUID
raster_dataset_id: UUID | None = None
quality_check_id: UUID
metric_count: int
status: str
message: str
created: bool
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class DetectionModelCapability(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_id: str
display_name: str
framework: str
task_type: str
supported_classes: list[str]
configured: bool
status: str
limitation_message: str
version: str | None = None
training_scope: str | None = None
validation_scope: str | None = None
validated_regions: list[str] = Field(default_factory=list)
nationally_validated: bool = False
operator_review_required: bool = True
class DetectionModelsResponse(BaseModel):
models: list[DetectionModelCapability]
class ModelAssetRead(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_asset_id: str
filename: str
display_name: str
model_path: str
suffix: str
framework: str
task_type: str
size_bytes: int
sha256: str
active: bool
runtime_available: bool
runtime_status: str
governed_validation_status: str
promotion_status: str
status: str
limitation_message: str
will_download_models: bool = False
class ModelAssetListResponse(BaseModel):
model_config = ConfigDict(protected_namespaces=())
items: list[ModelAssetRead]
total: int
model_directory: str
class DetectionRunRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
project_id: UUID
dataset_id: UUID
model_id: str
model_asset_id: str | None = None
confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
class_filter: list[str] | None = None
tile_manifest_path: str | None = None
parameters_json: dict = Field(default_factory=dict)
class DetectionQaRequest(BaseModel):
reference_dataset_id: UUID
iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
class_name: str | None = None
min_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
# Confidence cuts to report alongside the run's own operating point. They
# are read off the one matching pass, so a sweep costs no extra inference.
calibration_thresholds: list[float] = Field(default_factory=list, max_length=32)
class DetectionComparisonRequest(BaseModel):
"""Place several runs side by side against one reference."""
analysis_run_ids: list[UUID] = Field(min_length=2, max_length=12)
reference_dataset_id: UUID
iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
class DetectionComparisonResponse(BaseModel):
reference_dataset_id: UUID
iou_threshold: float
# Whether these runs answer the same question at all, and why not if they
# do not. Numbers from incomparable runs are reported but never ranked as
# if they were alternatives.
comparability: dict
ranking_metric: str
rows: list[dict]
class DetectionRunResponse(BaseModel):
model_config = ConfigDict(protected_namespaces=())
analysis_run_id: UUID
job_id: UUID
project_id: UUID
dataset_id: UUID
model_id: str
status: str
detection_count: int
error_code: str | None = None
message: str
class DetectionRunRead(BaseModel):
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: UUID
project_id: UUID
dataset_id: UUID | None = None
job_id: UUID | None = None
analysis_type: str
status: str
model_name: str | None = None
model_version: str | None = None
parameters_json: dict
result_json: dict | None = None
error_message: str | None = None
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class DetectionRunListResponse(BaseModel):
items: list[DetectionRunRead]
# ``total`` counts every run; ``items`` is the most recent page of them.
total: int
limit: int | None = None
offset: int = 0
truncated: bool = False
class DetectionRead(BaseModel):
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: UUID
project_id: UUID
dataset_id: UUID | None = None
analysis_run_id: UUID | None = None
job_id: UUID | None = None
model_name: str
model_version: str | None = None
class_name: str
confidence: float
bbox_json: dict | None = None
source_tile_path: str | None = None
properties_json: dict | None = None
created_at: datetime | None = None
class DetectionListResponse(BaseModel):
items: list[DetectionRead]
# ``total`` is the complete population; ``items`` is one page of it.
total: int
limit: int | None = None
offset: int = 0
truncated: bool = False
class YoloPreflightChecks(BaseModel):
model_config = ConfigDict(protected_namespaces=())
enabled: bool
dependencies_available: bool | None = None
accelerator_ready: bool | None = None
model_path_set: bool | None = None
model_file_exists: bool | None = None
model_provenance_manifest_path: str | None = None
model_provenance_valid: bool | None = None
model_load_requested: bool
model_load_ok: bool | None = None
manifest_path_set: bool | None = None
manifest_valid: bool | None = None
tile_paths_exist: bool | None = None
tile_limit_ok: bool | None = None
class YoloRuntimeDetails(BaseModel):
model_config = ConfigDict(protected_namespaces=())
dependencies_assumed: bool
model_directory: str | None = None
yolo_config_dir: str | None = None
torch_version: str | None = None
ultralytics_version: str | None = None
cuda_available: bool | None = None
configured_device: str
cuda_required: bool
class YoloPreflightResponse(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_id: str
model_asset_id: str | None = None
model_path: str | None = None
tile_manifest_path: str | None = None
status: str
message: str
checks: YoloPreflightChecks
tile_count: int
max_tiles: int
will_download_models: bool
will_run_inference: bool
runtime: YoloRuntimeDetails
error_code: str | None = None
details: dict | None = None
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
DetectionEvidenceRole = Literal["false_positive", "false_negative"]
DetectionReviewDecision = Literal[
"confirmed_model_false_positive",
"confirmed_model_false_negative",
"reference_gap_or_change",
"qa_alignment_mismatch",
"imagery_obscured_or_uncertain",
"uncertain",
"unreviewed",
]
class DetectionReviewUpsert(BaseModel):
evidence_role: DetectionEvidenceRole
evidence_feature_id: str = Field(min_length=1, max_length=255)
decision: DetectionReviewDecision
notes: str | None = Field(default=None, max_length=2000)
reviewed_by: str = Field(default="operator", min_length=1, max_length=120)
class DetectionReviewRead(BaseModel):
id: UUID | None = None
project_id: UUID
quality_check_id: UUID
analysis_run_id: UUID | None = None
evidence_role: DetectionEvidenceRole
evidence_feature_id: str
detection_id: UUID | None = None
reference_feature_id: UUID | None = None
decision: DetectionReviewDecision = "unreviewed"
notes: str | None = None
reviewed_by: str | None = None
confidence: float | None = None
class_name: str | None = None
source_tile_path: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class DetectionReviewSummary(BaseModel):
total: int
reviewed: int
remaining: int
false_positive_total: int
false_negative_total: int
decision_counts: dict[str, int]
# The score with the operator's verdicts applied, next to the raw one. A
# finding adjudicated as a reference gap is not the model's error, and an
# interval covers what the unreviewed remainder could still turn out to be.
reviewed_metrics: dict | None = None
class DetectionReviewList(BaseModel):
items: list[DetectionReviewRead]
total: int
limit: int
offset: int
summary: DetectionReviewSummary
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, Field
from .operations import VectorSelectionBBox
class DhmvAcquireRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
product_key: str = "dtm_1m"
resolution_m: float | None = Field(default=None, ge=1.0, le=10.0)
force_refresh: bool = False
class DhmvProductRead(BaseModel):
key: str
display_name: str
surface_model: str
coverage_id: str
native_resolution_m: float
source_crs: str
vertical_reference: str
acquisition_period: str
catalog_url: str
attribution: str
limitation_message: str
class DhmvAcquisitionResult(BaseModel):
output_dataset_id: UUID
reused: bool
provider: str
product_key: str
display_name: str
surface_model: str
coverage_id: str
native_resolution_m: float
resolution_m: float
width: int
height: int
valid_pixel_count: int
nodata_value: float
bbox_epsg4326: list[float]
bbox_epsg31370: list[float]
vertical_reference: str
acquisition_period: str
attribution: str
limitation_message: str
class TerrainSelectionRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
class TerrainPartitionSelectionRequest(TerrainSelectionRequest):
product_key: str = "dtm_1m"
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=4096)
class TerrainMetric(BaseModel):
metric_key: str
metric_label: str
metric_value: float
metric_unit: str
aggregation_method: str
derived: bool = True
class TerrainSelectionSummary(BaseModel):
metric_label: str
metric_value: float
metric_unit: str
aggregation_method: str
primary_metric_key: str
metrics: list[TerrainMetric]
class TerrainSelectionResponse(BaseModel):
dataset_id: UUID
dataset_ids: list[UUID] = Field(default_factory=list)
partition_count: int = Field(default=1, ge=1)
product_key: str
surface_model: str
selection_bbox: VectorSelectionBBox
selection_area_id: UUID | None = None
sample_count: int
slope_sample_count: int
coverage_ratio: float
# Set when the drawn selection is smaller than one source cell and the
# analysis was widened to the cells it touches, so the value covers more
# ground than was requested.
cell_selection_warning: str | None = None
resolution_m: float
vertical_reference: str
summary: TerrainSelectionSummary
unsupported_metrics: list[str]
limitation_message: str
generated_at: str
Loaded 100 of 764 files, more files were not shown because too many files have changed in this diff. Show more