Merge pull request 'Full platform remediation' (#7) from codex/full-platform-remediation into main
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 2m25s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 28s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Failing after 2m41s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Skipped
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 2m25s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 28s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Failing after 2m41s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Skipped
This commit was merged in pull request #7.
This commit is contained in:
@@ -4,6 +4,7 @@ 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
|
||||
@@ -14,6 +15,11 @@ 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=true
|
||||
GEOINTEL_GUEST_DISPLAY_NAME=Gast
|
||||
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
|
||||
|
||||
@@ -20,12 +20,24 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
full:
|
||||
name: full
|
||||
# Gitea Actions does not consistently evaluate the GitHub-style `||`
|
||||
# expression for pull-request runs without workflow inputs.
|
||||
name: Managed repository validation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Validate repository with a bounded profile
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Validate the requested profile against the real projects
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||
@@ -42,77 +54,34 @@ jobs:
|
||||
echo "Unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
python scripts/verify_repository_layout.py
|
||||
|
||||
if [[ -f pyproject.toml || -f requirements.txt ]]; then
|
||||
# Compile only tracked Python sources. Running compileall after a
|
||||
# Node install would otherwise traverse node_modules and turn a
|
||||
# lightweight baseline into a large runner workload.
|
||||
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
|
||||
if [[ -f uv.lock ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-uv"
|
||||
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
|
||||
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
|
||||
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
|
||||
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
|
||||
"${managed_uv}" sync --locked
|
||||
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m pytest
|
||||
fi
|
||||
fi
|
||||
if [[ "${profile}" == lint || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m ruff check .
|
||||
fi
|
||||
fi
|
||||
elif [[ -f requirements.txt ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-python"
|
||||
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
|
||||
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
|
||||
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_python}" -m pytest
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-ci.lock
|
||||
python -m pip install --disable-pip-version-check --no-deps -e backend
|
||||
(cd frontend && npm ci)
|
||||
|
||||
# Prepare Python before invoking Node scripts. Polyglot repositories
|
||||
# commonly delegate their test script to Python and need the managed
|
||||
# virtual environment to be active first.
|
||||
if [[ -f package.json ]]; then
|
||||
corepack enable
|
||||
if [[ -f pnpm-lock.yaml ]]; then
|
||||
pnpm install --frozen-lockfile
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
|
||||
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
|
||||
elif [[ -f package-lock.json ]]; then
|
||||
npm ci
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
|
||||
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
|
||||
npm run --if-present typecheck
|
||||
fi
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f go.mod ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
go test ./...
|
||||
fi
|
||||
fi
|
||||
if [[ -f Cargo.toml ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
cargo test --locked
|
||||
fi
|
||||
fi
|
||||
if compgen -G '*.sln' >/dev/null; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
dotnet test --configuration Release
|
||||
fi
|
||||
fi
|
||||
case "${profile}" in
|
||||
test)
|
||||
(cd backend && python -m pytest -W error::DeprecationWarning)
|
||||
(cd frontend && npm run test:unit)
|
||||
;;
|
||||
lint)
|
||||
python -m ruff check backend scripts tests
|
||||
(cd frontend && npm run lint --if-present)
|
||||
;;
|
||||
typecheck)
|
||||
(cd frontend && npm run typecheck)
|
||||
;;
|
||||
build)
|
||||
python -m compileall backend/app
|
||||
(cd frontend && npm run build)
|
||||
;;
|
||||
security)
|
||||
python -m pip install --disable-pip-version-check pip-audit==2.10.1
|
||||
bash scripts/audit_python_dependencies.sh
|
||||
(cd frontend && npm audit --audit-level=high)
|
||||
;;
|
||||
full)
|
||||
PYTHON_BIN=python bash scripts/run_readiness_check.sh
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: GeoIntel release gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
@@ -10,27 +11,30 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: geointel-release-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
# A cancelled HTTP caller does not terminate the allowlisted controller
|
||||
# process that already owns the production lock. Queue a newer revision
|
||||
# instead of orphaning an in-flight backup or deploy.
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Compile, test, contracts and builds
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Secret scan
|
||||
run: >-
|
||||
docker run --rm
|
||||
--volume "$PWD:/repo:ro"
|
||||
trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3
|
||||
filesystem /repo --only-verified --no-update
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
@@ -57,7 +61,9 @@ jobs:
|
||||
docker compose config > artifacts/docker-compose.resolved.yml
|
||||
- name: Publish quality evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
# Gitea Actions currently exposes the GHES-compatible artifact API;
|
||||
# upload-artifact v4 deliberately refuses that API.
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: quality-evidence
|
||||
path: |
|
||||
@@ -71,13 +77,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
@@ -94,7 +100,7 @@ jobs:
|
||||
npm audit --audit-level=high --json > ../artifacts/npm-audit.json
|
||||
- name: Publish dependency evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: dependency-audits
|
||||
path: |
|
||||
@@ -105,41 +111,81 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
container:
|
||||
name: GIS image, SBOM and container scan
|
||||
name: Production AI image, SBOM and container scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build non-AI release image
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Build production AI release image
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
APP_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||
docker build \
|
||||
-f deploy/unraid/Dockerfile.all-in-one \
|
||||
--build-arg GEOINTEL_INSTALL_AI=false \
|
||||
--build-arg GEOINTEL_INSTALL_AI=true \
|
||||
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
|
||||
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
|
||||
-t "geointel-ci:$RELEASE_SHA-gis" \
|
||||
--build-arg GEOINTEL_APP_VERSION="$APP_VERSION" \
|
||||
-t "geointel-ci:$RELEASE_SHA-ai" \
|
||||
.
|
||||
docker image inspect "geointel-ci:$RELEASE_SHA-gis" > artifacts/image-inspect.json
|
||||
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")"
|
||||
printf '%s\n' "$IMAGE_ID" > artifacts/image-id.txt
|
||||
docker image inspect "$IMAGE_ID" > artifacts/image-inspect.json
|
||||
- name: Generate SPDX SBOM
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/generate_container_sbom.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||
SYFT_PARALLELISM: "1"
|
||||
run: |
|
||||
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||
bash scripts/generate_container_sbom.sh "$IMAGE_ID"
|
||||
- name: Enforce container vulnerability policy
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/scan_container_image.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||
run: |
|
||||
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||
bash scripts/scan_container_image.sh "$IMAGE_ID"
|
||||
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||
- name: Remove temporary image archive
|
||||
if: always()
|
||||
run: >-
|
||||
rm -f -- artifacts/geointel-image.tar
|
||||
artifacts/geointel-image.tar.image-id
|
||||
artifacts/geointel-image.tar.partial.*
|
||||
- name: Publish container evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: container-evidence
|
||||
path: |
|
||||
artifacts/image-inspect.json
|
||||
artifacts/image-id.txt
|
||||
artifacts/geointel-sbom.spdx.json
|
||||
artifacts/geointel-container-vulnerabilities.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
deploy:
|
||||
name: Deploy exact gated revision to Unraid
|
||||
needs: [quality, dependency-audit, container]
|
||||
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
|
||||
runs-on: unraid-deploy
|
||||
# The first byte-complete storage snapshot can exceed 100 GiB. Keep the
|
||||
# gated caller attached for the full conservative backup/build window;
|
||||
# the controller and deploy script still serialize every mutation.
|
||||
timeout-minutes: 720
|
||||
steps:
|
||||
- name: Deploy only after every release gate is green
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker exec gitea-deploy-control \
|
||||
/opt/gitea-deploy/deploy.py deploy \
|
||||
"${{ gitea.repository }}" "${{ gitea.sha }}"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Unraid autoredeploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- ".gitea/**"
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: unraid-production-geointel
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy geointel
|
||||
runs-on: unraid-deploy
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Deploy exact Gitea revision
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker exec gitea-deploy-control \
|
||||
/opt/gitea-deploy/deploy.py deploy \
|
||||
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
|
||||
|
||||
@@ -20,19 +20,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Secret scan
|
||||
run: >-
|
||||
docker run --rm
|
||||
--volume "$PWD:/repo:ro"
|
||||
trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3
|
||||
filesystem /repo --only-verified --no-update
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
docker compose config > artifacts/docker-compose.resolved.yml
|
||||
- name: Publish quality evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: quality-evidence
|
||||
path: |
|
||||
@@ -73,13 +73,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.lock
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
npm audit --audit-level=high --json > ../artifacts/npm-audit.json
|
||||
- name: Publish dependency evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dependency-audits
|
||||
path: |
|
||||
@@ -107,40 +107,63 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
container:
|
||||
name: GIS image, SBOM and container scan
|
||||
name: Production AI image, SBOM and container scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build non-AI release image
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Build production AI release image
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
APP_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||
docker build \
|
||||
-f deploy/unraid/Dockerfile.all-in-one \
|
||||
--build-arg GEOINTEL_INSTALL_AI=false \
|
||||
--build-arg GEOINTEL_INSTALL_AI=true \
|
||||
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
|
||||
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
|
||||
-t "geointel-ci:$RELEASE_SHA-gis" \
|
||||
--build-arg GEOINTEL_APP_VERSION="$APP_VERSION" \
|
||||
-t "geointel-ci:$RELEASE_SHA-ai" \
|
||||
.
|
||||
docker image inspect "geointel-ci:$RELEASE_SHA-gis" > artifacts/image-inspect.json
|
||||
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")"
|
||||
printf '%s\n' "$IMAGE_ID" > artifacts/image-id.txt
|
||||
docker image inspect "$IMAGE_ID" > artifacts/image-inspect.json
|
||||
- name: Generate SPDX SBOM
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: bash scripts/generate_container_sbom.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||
SYFT_PARALLELISM: "1"
|
||||
run: |
|
||||
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||
bash scripts/generate_container_sbom.sh "$IMAGE_ID"
|
||||
- name: Enforce container vulnerability policy
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: bash scripts/scan_container_image.sh "geointel-ci:$RELEASE_SHA-gis"
|
||||
GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
|
||||
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
|
||||
run: |
|
||||
IMAGE_ID="$(cat artifacts/image-id.txt)"
|
||||
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||
bash scripts/scan_container_image.sh "$IMAGE_ID"
|
||||
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
|
||||
- name: Remove temporary image archive
|
||||
if: always()
|
||||
run: >-
|
||||
rm -f -- artifacts/geointel-image.tar
|
||||
artifacts/geointel-image.tar.image-id
|
||||
artifacts/geointel-image.tar.partial.*
|
||||
- name: Publish container evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: container-evidence
|
||||
path: |
|
||||
artifacts/image-inspect.json
|
||||
artifacts/image-id.txt
|
||||
artifacts/geointel-sbom.spdx.json
|
||||
artifacts/geointel-container-vulnerabilities.json
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -4,6 +4,8 @@ __pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -35,6 +37,9 @@ build/
|
||||
!/artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/**
|
||||
!/artifacts/evidence/accuracy/P2/
|
||||
!/artifacts/evidence/accuracy/P2/**
|
||||
!/artifacts/evidence/accuracy/model-training/
|
||||
/artifacts/evidence/accuracy/model-training/*
|
||||
!/artifacts/evidence/accuracy/model-training/20260830-independent-ai-visual-review.json
|
||||
/.cache/
|
||||
/datasets/raw/*
|
||||
/datasets/processed/*
|
||||
|
||||
@@ -48,8 +48,10 @@ België plus de juridisch benoemde Belgische maritieme zones.
|
||||
## Product in beeld
|
||||
|
||||
De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele
|
||||
Unraid-productieomgeving. De gastmodus toont een beperkte, alleen-lezen
|
||||
demowerkruimte; operatorfuncties en nieuwe analyses blijven afgeschermd.
|
||||
Unraid-productieomgeving. De gastmodus toont een projectgebonden demowerkruimte
|
||||
met dezelfde kaart-, bron-, model-, analyse-, QA- en exportflow als een
|
||||
operator. Alleen beheer, instellingen, uploads, bronconfiguratie,
|
||||
projectbeheer en evidence-review blijven afgeschermd.
|
||||
|
||||
### Interactieve projectketen
|
||||
|
||||
@@ -68,6 +70,11 @@ resultaten zijn vanuit dezelfde ruimtelijke context bereikbaar.
|
||||
|
||||

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

|
||||
|
||||
### Kwaliteit vóór resultaat
|
||||
|
||||
QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan
|
||||
@@ -147,9 +154,16 @@ Open daarna `http://localhost:1202`.
|
||||
### Gastdemo
|
||||
|
||||
Met `GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een
|
||||
kortlevende, alleen-lezen demosessie. Die toont uitsluitend het ingestelde
|
||||
demoproject en bewaard kwaliteitsbewijs; dit is geen multi-user- of
|
||||
tenantisolatie.
|
||||
kortlevende, projectgebonden demosessie. De gast heeft binnen het ingestelde
|
||||
demoproject dezelfde kaart-, bronselectie-, modelselectie-, analyse-, QA- en
|
||||
exportmogelijkheden als een operator. Alleen beheerfuncties zoals instellingen,
|
||||
uploads, bronconfiguratie, projectbeheer en evidence-review blijven geblokkeerd;
|
||||
dit is geen multi-user- of tenantisolatie.
|
||||
|
||||
Een optionele Authentik OIDC-login kan naast de lokale operatorlogin worden
|
||||
ingeschakeld. GeoIntel gebruikt daarbij PKCE, state, nonce, issuer-/audience-
|
||||
controle en één expliciet toegelaten, geverifieerd e-mailadres. De lokale
|
||||
operatorlogin blijft altijd het herstelpad.
|
||||
|
||||
## NVIDIA/CUDA
|
||||
|
||||
@@ -176,11 +190,23 @@ GeoIntel behandelt officiële gebouwgrondvlakken niet automatisch als perfecte
|
||||
daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage,
|
||||
bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd.
|
||||
Een directe polygon-overlay maakt bovendien zichtbaar of de officiële geometrie
|
||||
op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen.
|
||||
op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen. Voor
|
||||
productresultaten blijft de taakgeschikte officiële bron doorslaggevend; een
|
||||
AI-detectie is controleerbaar voorstelbewijs zolang een taakgebonden releasegate
|
||||
niet aantoonbaar anders beslist.
|
||||
|
||||
De huidige V66 Vlaamse uitbreiding blijft kandidaatdata. Automatische SAM2- en
|
||||
edge-alignmentproeven zijn afgewezen omdat ze nog vegetatie, wegen of schaduwen
|
||||
als dak konden selecteren. Die resultaten zijn dus niet getraind of gepromoveerd.
|
||||
De onafhankelijke AI-visuele hercontrole van 30 augustus 2026 vond nog
|
||||
blokkerende referentiegeometrie, dense/nested labels, tile-edge-onduidelijkheid
|
||||
en onvoldoende onafhankelijke pure-background/hard-negative dekking. Deze
|
||||
controle is uitdrukkelijk geen menselijke acceptatie. Nieuwe training en
|
||||
modelpromotie blijven daarom geblokkeerd; er wordt geen 100%-accuratieclaim
|
||||
gemaakt.
|
||||
|
||||
Modelactivatie vereist naast het oudere diagnostische promotierapport ook een
|
||||
geslaagd, governed Phase-4/5 release-gaterapport dat exact dezelfde candidate
|
||||
key, model-SHA-256 en benchmarkmanifest-SHA-256 bindt. Zie
|
||||
[de actuele accuracy-status](docs/accuracy-program/status.json) en het
|
||||
[visuele reviewbewijs](artifacts/evidence/accuracy/model-training/20260830-independent-ai-visual-review.json).
|
||||
|
||||
## Portfolio case study
|
||||
|
||||
@@ -258,6 +284,7 @@ Actuele rasterassets:
|
||||
| `geointel-landing-hero.png` | Desktop hero en projectintroductie |
|
||||
| `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering |
|
||||
| `geointel-workbench-map.png` | Kaartgerichte gastwerkruimte |
|
||||
| `geointel-workbench-wide.png` | Brede kaartwerkruimte voor desktopportfolio's |
|
||||
| `geointel-workbench-quality.png` | QA/QC, metrics en objectbewijs |
|
||||
| `geointel-landing-mobile.png` | Mobiele landing |
|
||||
| `geointel-workbench-mobile.png` | Mobiele kaartworkflow |
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"audited_repository_head": "0e3c1b20e931941c346da08f6fa8847e674bdadb",
|
||||
"claim_boundary": "This manifest proves retained-file identity and completeness. It does not establish model accuracy, human label acceptance, split independence or release readiness.",
|
||||
"evidence_file_count": 29,
|
||||
"evidence_files": [
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/alembic-heads.txt",
|
||||
"role": "execution_log",
|
||||
"sha256": "da4521233c6718fc7a5865c53904e73685fbdce65a1449b19cd0dc2e40d037ed",
|
||||
"size_bytes": 116
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/alembic-offline-upgrade.sql",
|
||||
"role": "migration_evidence",
|
||||
"sha256": "e8905b881890cf95885a7515e3d9dcf2a7a0a24c4edbc57e98a363edcb22dd0e",
|
||||
"size_bytes": 20199
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/backend-ci-entrypoint.txt",
|
||||
"role": "execution_log",
|
||||
"sha256": "0ac774ec19b4ff0a15142aab5f1db68c2592a230401d794ae7d040320e3ac0c0",
|
||||
"size_bytes": 1601
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/backend-full-suite.junit.xml",
|
||||
"role": "test_report",
|
||||
"sha256": "a01d0b5b6974d7ed184f46e8e35ac3b06c9a5db565e45e140b5f799819396b08",
|
||||
"size_bytes": 207086
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/backend-full-suite.txt",
|
||||
"role": "execution_log",
|
||||
"sha256": "8521ed48b17b382752418750b3ea374831958fb063e83ef87048212e0fd5ea69",
|
||||
"size_bytes": 23972
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/commands-and-results.md",
|
||||
"role": "execution_log",
|
||||
"sha256": "451d4cfc2d6d67937411b23192d9418ef6721af678aed1b0d5cb82113d82efc5",
|
||||
"size_bytes": 8427
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/evidence-manifest.json",
|
||||
"role": "structured_inventory",
|
||||
"sha256": "1145e43ace4ed326f3138571189d580e416b280ded4354b148904b20603e1913",
|
||||
"size_bytes": 10713
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/forensic-reproductions.json",
|
||||
"role": "defect_reproduction",
|
||||
"sha256": "f6349199a15ae789092d3d65c17a39a9b32ea3ed557571c0c228c4be3cf7235e",
|
||||
"size_bytes": 3533
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/frontend-build.txt",
|
||||
"role": "execution_log",
|
||||
"sha256": "c3b8e10ef177ac1c2dc045bd710df1caeb46f8922a721291be431b304abbc079",
|
||||
"size_bytes": 2356
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/frontend-lint.txt",
|
||||
"role": "lint_evidence",
|
||||
"sha256": "1115b013515c753e2dfb73abdad9024aec7b4c2337d117c7e1181341fef15c7f",
|
||||
"size_bytes": 418
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/frontend-typecheck.txt",
|
||||
"role": "execution_log",
|
||||
"sha256": "3891c85c77b5ff50a1eb6d27a2a65d40c2c05423768734efd9d980f3784d68fa",
|
||||
"size_bytes": 163
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/frontend-vitest-unit.txt",
|
||||
"role": "test_evidence",
|
||||
"sha256": "6341bfa51ca3f4fe5ec7d4af7239c3c5e1a29e6bfe8bdfae85e824a2a6482ad0",
|
||||
"size_bytes": 2774
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/frontend-vitest.txt",
|
||||
"role": "test_evidence",
|
||||
"sha256": "8a84afa48cc59e473725ed105a3f88da24f001cdcb794d942ede2ab8f0d48e48",
|
||||
"size_bytes": 333
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/golden-qa-reproducibility.json",
|
||||
"role": "test_evidence",
|
||||
"sha256": "576e5667a989c34086db3a2bc57003115a8a61b14e9f49f5407ee380baf0829d",
|
||||
"size_bytes": 505
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/golden-qa-run-1.json",
|
||||
"role": "test_evidence",
|
||||
"sha256": "ec96862b024987b59e56579920361582e718126b07885732335ac1b4d51b61bc",
|
||||
"size_bytes": 5242
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/golden-qa-run-2.json",
|
||||
"role": "test_evidence",
|
||||
"sha256": "4f900711a706dd56dd16b368e261761051f6d52a38bfd421e7d1c5f66ee51e49",
|
||||
"size_bytes": 5242
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/local-artifact-inventory.json",
|
||||
"role": "structured_inventory",
|
||||
"sha256": "0e187dbf3e91ee8e35567d6c8cd6e1cadfa6275d5908b829253491f3804adf7d",
|
||||
"size_bytes": 75535
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/openapi-contract-audit.txt",
|
||||
"role": "execution_log",
|
||||
"sha256": "5af3d8f00be3f57fc309bc198fa3995d1eae7270a5f210b3e94d1aeeb653119d",
|
||||
"size_bytes": 212
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/phase1-baseline-summary.json",
|
||||
"role": "structured_inventory",
|
||||
"sha256": "7d5960df64bdd4fb9b9d9d7a93de5f9cfd770f4dcf21454899345afe3ab12342",
|
||||
"size_bytes": 1143
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/phase1-tooling-tests.txt",
|
||||
"role": "test_evidence",
|
||||
"sha256": "a9bf261e1a811ad3e8bfa8edc439a11f00b46bc157f9ab6fc970033a87f45748",
|
||||
"size_bytes": 250
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/repository-inventory.json",
|
||||
"role": "structured_inventory",
|
||||
"sha256": "07fa9356f298f9fe360e2e362ce9f3d558a9a66544fed3a5567cd9a5666451c7",
|
||||
"size_bytes": 27695
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/repository-ruff-baseline.json",
|
||||
"role": "lint_evidence",
|
||||
"sha256": "de6617f030e49550e714c49b6e14bf291bf85016fd58086e9ca38b33a52252e9",
|
||||
"size_bytes": 59359
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/repository-ruff-baseline.txt",
|
||||
"role": "lint_evidence",
|
||||
"sha256": "cd654cea65710167beff8307cb71533fb645f25247ef46e7adc421bd520c9191",
|
||||
"size_bytes": 57870
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/static-risk-signals.json",
|
||||
"role": "structured_inventory",
|
||||
"sha256": "729f0a5d3eece61be8e26d44b5dfa29c6cbf21eb16b8694a2d4dc45c94720757",
|
||||
"size_bytes": 22467
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/tower-gpu-inference-smoke.json",
|
||||
"role": "runtime_evidence",
|
||||
"sha256": "692a9fa193d589123b042110d7e80755f0c6634854f3134c291fc6083adb7b77",
|
||||
"size_bytes": 5675
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/tower-key-artifact-hashes.json",
|
||||
"role": "structured_inventory",
|
||||
"sha256": "4d88d350604b377682da4d928c37ffb00ea1a32d2690d9b2bd54117363930414",
|
||||
"size_bytes": 949
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/tower-ml-data-lineage-snapshot.json",
|
||||
"role": "lineage_evidence",
|
||||
"sha256": "d80275e8198ce63366d2a2d44eb8fba1f27c29d85aaaaedb94991d8c56febfb6",
|
||||
"size_bytes": 71659
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot-detailed.json",
|
||||
"role": "runtime_evidence",
|
||||
"sha256": "744389b6c384a9fb3a9e16e56f1477f3b752e11a5b98e1fad7df47b4703a9ca1",
|
||||
"size_bytes": 14946
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot.json",
|
||||
"role": "runtime_evidence",
|
||||
"sha256": "aa7681d4a19ca20b7b442c509b3f56cb97bf239b2b5cc6a1fb1ca140d5320e9e",
|
||||
"size_bytes": 10127
|
||||
}
|
||||
],
|
||||
"evidence_root": "artifacts/evidence/accuracy/P1",
|
||||
"evidence_total_bytes": 640567,
|
||||
"generated_at": "2026-08-30T04:01:05.890308+00:00",
|
||||
"program_file_count": 16,
|
||||
"program_files": [
|
||||
{
|
||||
"path": "docs/accuracy-program/00-execution-contract.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "d6c71dbccbb1e1f3bda2835cf9f53c3a5a92c173e9136d5cfccf007ec2c28684",
|
||||
"size_bytes": 8720
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/01-system-inventory.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "2b27c28ca06ab8e8a36f12cccc8df23d5e83894e39b8e33406e6a6c05092cf22",
|
||||
"size_bytes": 15708
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/02-data-lineage.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "8af25f30d43863438678b3e867c9bf0a318cdac78e12c891254fb2570e2d9d1d",
|
||||
"size_bytes": 20409
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/03-baseline-and-gaps.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "ad3c2aae2944443d0f5d09b5c415b6202109071a6f194bb8b09b7df30332e3b3",
|
||||
"size_bytes": 21253
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/04-risk-register.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "f3c0d35b95fd180a391950c2a6c9339aa3e4ace0e8512affbd242d159ca1e574",
|
||||
"size_bytes": 23851
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/05-metric-framework.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "18abfc21964b7d98848d8ae513afce242184980c9517c9184d40efd9165ea00d",
|
||||
"size_bytes": 28053
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/06-implementation-roadmap.md",
|
||||
"role": "phase1_program",
|
||||
"sha256": "c76ffebee4a23796ff221e6d4c5b5138f99d55a8b1422e897058b12a78a29ad9",
|
||||
"size_bytes": 23695
|
||||
},
|
||||
{
|
||||
"path": "docs/accuracy-program/status.json",
|
||||
"role": "phase1_program",
|
||||
"sha256": "01a468bd9e796b23ee58dbadebb7800b5318d99e37497a694bb24835802f088d",
|
||||
"size_bytes": 17224
|
||||
},
|
||||
{
|
||||
"path": "scripts/build_accuracy_phase1_evidence_manifest.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "882a935b29f787fc11a9f7b528392c1727463be24adcb0cac47a09fdb69a8382",
|
||||
"size_bytes": 5523
|
||||
},
|
||||
{
|
||||
"path": "scripts/collect_accuracy_phase1_inference_smoke.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "6d81541b7aa83f3c03ba5320a8075892eee658905ee981ba727e59f3b1dc792a",
|
||||
"size_bytes": 6756
|
||||
},
|
||||
{
|
||||
"path": "scripts/collect_accuracy_phase1_ml_lineage.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "3b1d3e45d45f1d4ca1211d1b2c70d9d93e15abc3b3ecfbd2e8302f645d6db7e1",
|
||||
"size_bytes": 14080
|
||||
},
|
||||
{
|
||||
"path": "scripts/collect_accuracy_phase1_runtime.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "a907779306076dccdf7fcf45f554e4778f5fab0e59d6af55673322775a1f5b4f",
|
||||
"size_bytes": 13387
|
||||
},
|
||||
{
|
||||
"path": "scripts/reproduce_accuracy_phase1_findings.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "c0f34e6bcaf73dcdada6e5b529a2e8e2177b1e97ee039c586d6ae51bb743015f",
|
||||
"size_bytes": 9447
|
||||
},
|
||||
{
|
||||
"path": "scripts/run_accuracy_phase1_baseline.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "fcbd280245ccc598cab7ec5ca6448fbb350f7fa59aecc20177fdb0069c416643",
|
||||
"size_bytes": 14498
|
||||
},
|
||||
{
|
||||
"path": "scripts/verify_accuracy_phase1_evidence.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "fc72c315d26b8b7542fe01666aef56a8538d09090f2c0c877c4dd8a33c93e556",
|
||||
"size_bytes": 4925
|
||||
},
|
||||
{
|
||||
"path": "tests/test_accuracy_phase1_baseline.py",
|
||||
"role": "phase1_program",
|
||||
"sha256": "868f433b7de61e9ab3cd7aa9ad5bb2d29e8286afdcd68274ed290909ec605c7f",
|
||||
"size_bytes": 3009
|
||||
}
|
||||
],
|
||||
"schema_version": 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"anomaly_category_counts": {
|
||||
"freshness.missing": 9,
|
||||
"lineage.missing": 58,
|
||||
"freshness.missing": 29,
|
||||
"lineage.missing": 577,
|
||||
"manifest.zero_bbox": 6,
|
||||
"raster.unreadable": 96,
|
||||
"scope.unreachable": 3
|
||||
},
|
||||
"datasets": {
|
||||
"geointel.artifact.generic": {
|
||||
"anomaly_count": 57,
|
||||
"item_count": 106,
|
||||
"anomaly_count": 491,
|
||||
"item_count": 620,
|
||||
"paths": [
|
||||
"artifacts/codex-review/v31/contact_sheet_001.png",
|
||||
"artifacts/codex-review/v31/qa.json",
|
||||
@@ -73,6 +73,510 @@
|
||||
"artifacts/detection-calibration-smoke/20260801T214300Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260801T214300Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260801T214300Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence_requests.tsv",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence_review.html",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence_summary.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/threshold_0p15_qc-low_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/threshold_0p35_qc-high_evidence_response.json",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/mock-bin/curl",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/summary/detection-calibration-summary.json",
|
||||
"artifacts/evidence/accuracy/P1/alembic-heads.txt",
|
||||
"artifacts/evidence/accuracy/P1/alembic-offline-upgrade.sql",
|
||||
"artifacts/evidence/accuracy/P1/backend-ci-entrypoint.txt",
|
||||
@@ -92,6 +596,16 @@
|
||||
"artifacts/evidence/accuracy/P1/repository-ruff-baseline.json",
|
||||
"artifacts/evidence/accuracy/P1/repository-ruff-baseline.txt",
|
||||
"artifacts/evidence/accuracy/P1/static-risk-signals.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/reference-implementation-baseline.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-implementation-baseline.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/reference-implementation-baseline.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/reference-implementation-baseline.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/internal-spatial-leakage-audit.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/visual-review-summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/internal-spatial-leakage-audit.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/operator_yolo_label_qa_summary_r2.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-source-expanded-summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-source-summary.json",
|
||||
"artifacts/model-review/building-be-v1-candidate/corpus-spec.json",
|
||||
"artifacts/model-review/building-be-v1-pilot/corpus-spec.json",
|
||||
"artifacts/v42-review/contact_sheet_001.png",
|
||||
@@ -119,7 +633,7 @@
|
||||
"storage/uploads/.gitkeep"
|
||||
],
|
||||
"severity_counts": {
|
||||
"major": 56,
|
||||
"major": 490,
|
||||
"minor": 1
|
||||
}
|
||||
},
|
||||
@@ -170,8 +684,8 @@
|
||||
}
|
||||
},
|
||||
"geointel.manifest.json": {
|
||||
"anomaly_count": 3,
|
||||
"item_count": 13,
|
||||
"anomaly_count": 35,
|
||||
"item_count": 141,
|
||||
"paths": [
|
||||
"artifacts/evidence/accuracy/P1/evidence-manifest.json",
|
||||
"artifacts/evidence/accuracy/P1/forensic-reproductions.json",
|
||||
@@ -185,16 +699,149 @@
|
||||
"artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot.json",
|
||||
"artifacts/evidence/accuracy/P2/evidence-manifest.json",
|
||||
"artifacts/evidence/accuracy/P2/postgres-migration-guards.json",
|
||||
"artifacts/evidence/accuracy/P2/source-contract-inventory.json"
|
||||
"artifacts/evidence/accuracy/P2/source-contract-inventory.json",
|
||||
"artifacts/evidence/accuracy/P4/baseline-raw-predictions.json",
|
||||
"artifacts/evidence/accuracy/P4/benchmark-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/development-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/evidence-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/failure-gallery.json",
|
||||
"artifacts/evidence/accuracy/P4/leakage-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/metric-report.json",
|
||||
"artifacts/evidence/accuracy/P4/protected-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/acceptance-gates.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/aoi-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/baseline-raw-predictions.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/benchmark-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/calibration-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/candidate-vs-incumbent.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/development-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/error-taxonomy.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/evaluation-contract.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/evidence-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/failure-gallery.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/generation-status.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/human-review-summary.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/input-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/latency-and-reliability.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/leakage-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/metric-report.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/object-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/protected-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/release-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/split-and-leakage-audit.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/stratified-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/tile-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/reference-harness-v2/workflow-summary.json",
|
||||
"artifacts/evidence/accuracy/P4/release-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/acceptance-gates.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/aoi-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/baseline-raw-predictions.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/benchmark-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/calibration-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/candidate-vs-incumbent.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/development-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/error-taxonomy.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/evaluation-contract.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/evidence-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/failure-gallery.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/generation-status.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/human-review-summary.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/input-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/latency-and-reliability.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/leakage-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/metric-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/object-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/protected-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/release-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/split-and-leakage-audit.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/stratified-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/tile-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/workflow-summary.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/acceptance-gates.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/aoi-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/baseline-raw-predictions.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/benchmark-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/calibration-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/candidate-vs-incumbent.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/development-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/error-taxonomy.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/evaluation-contract.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/evidence-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/failure-gallery.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/generation-status.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/human-review-summary.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/input-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/latency-and-reliability.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/leakage-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/metric-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/object-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/protected-split-manifest.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/release-gate-report.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/split-and-leakage-audit.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/stratified-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/tile-metrics.json",
|
||||
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/workflow-summary.json",
|
||||
"artifacts/evidence/accuracy/P4/workflow-summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-ai-assisted-visual-review.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-reviewedexp6-ai-assisted-review-ledger.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-reviewedexp6-minpx4-derived-corpus.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-reviewedexp6-provenance-migration-audit.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v67-training-decision.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v68-checkpoint-and-threshold-review.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v69-nonoverlap-checkpoint-review.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v70-evaluation-independence-gate.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v71-full-lineage-independence-gate.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v71-model-lineage-receipt.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration-summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/active-stratified.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/active-threshold-extension.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/challenger-stratified.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/checkpoint-matrix.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/corpus-NO_TRAINING.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/corpus-freeze.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/operator_samples_manifest.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/provisioning-spec.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/spatial-independence.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/yolo-NO_TRAINING.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/yolo_tile_dataset_summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/EXPERIMENTAL_ONLY.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/aggressive-training-summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/aggressive-v72-evaluation-matrix.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/controlled-v72-evaluation-matrix.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/corpus-freeze.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/experimental_dataset_summary.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/operator_samples_manifest.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/outcome.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/provisioning-spec.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/v72-v73-spatial-independence.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/visual-review.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/active-error-report.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/decision.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.10.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.15.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.20.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.30.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.50.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/proposal-filter.experimental.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-auto-optimizer.experimental.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-auto-v72-matrix.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-low-lr-v72-matrix.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-low-lr.experimental.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-v74.experimental.json"
|
||||
],
|
||||
"severity_counts": {
|
||||
"minor": 3
|
||||
"major": 12,
|
||||
"minor": 23
|
||||
}
|
||||
},
|
||||
"geointel.model.pytorch": {
|
||||
"anomaly_count": 0,
|
||||
"item_count": 6,
|
||||
"anomaly_count": 1,
|
||||
"item_count": 10,
|
||||
"paths": [
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/contact_sheet_001.png",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_001.png",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_002.png",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/active-error-contact-sheet.png",
|
||||
"artifacts/model-review/building-be-v1-pilot/building-be-v2-background.png",
|
||||
"artifacts/model-review/building-be-v1-pilot/building-be-v2-cal.png",
|
||||
"artifacts/model-review/building-be-v1-pilot/building-be-v2-contact.png",
|
||||
@@ -202,7 +849,9 @@
|
||||
"artifacts/model-review/building-be-v1-pilot/contact_sheet_001.png",
|
||||
"models/.gitkeep"
|
||||
],
|
||||
"severity_counts": {}
|
||||
"severity_counts": {
|
||||
"major": 1
|
||||
}
|
||||
},
|
||||
"geointel.raster.geotiff": {
|
||||
"anomaly_count": 101,
|
||||
@@ -350,8 +999,8 @@
|
||||
}
|
||||
},
|
||||
"geointel.vector.geojson": {
|
||||
"anomaly_count": 8,
|
||||
"item_count": 8,
|
||||
"anomaly_count": 80,
|
||||
"item_count": 80,
|
||||
"paths": [
|
||||
"artifacts/detection-calibration-smoke/20260801T130251Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260801T154642Z/evidence/calibration_evidence.geojson",
|
||||
@@ -360,13 +1009,85 @@
|
||||
"artifacts/detection-calibration-smoke/20260801T210556Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260801T213357Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260801T213707Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260801T214300Z/evidence/calibration_evidence.geojson"
|
||||
"artifacts/detection-calibration-smoke/20260801T214300Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence.geojson",
|
||||
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence.geojson"
|
||||
],
|
||||
"severity_counts": {
|
||||
"major": 8
|
||||
"major": 80
|
||||
}
|
||||
}
|
||||
},
|
||||
"scan_id": "p3-46ee3f8d3a2dc52b",
|
||||
"scan_id": "p3-eb67185e61107cc7",
|
||||
"schema_version": 1
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,12 @@
|
||||
"split_independence_proven": false
|
||||
},
|
||||
"same_checksum_across_splits": {},
|
||||
"scan_id": "p3-46ee3f8d3a2dc52b",
|
||||
"scan_id": "p3-eb67185e61107cc7",
|
||||
"schema_version": 1,
|
||||
"source_spatial_audit_files": [],
|
||||
"source_spatial_audit_files": [
|
||||
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/internal-spatial-leakage-audit.json",
|
||||
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/internal-spatial-leakage-audit.json"
|
||||
],
|
||||
"spatial_overlap_checks": "not_proven_without_AOI_split_geometry",
|
||||
"status": "attention"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"items": {
|
||||
"DHMV|unknown": 5,
|
||||
"GRB|unknown": 4,
|
||||
"DHMV|unknown": 11,
|
||||
"GRB|unknown": 18,
|
||||
"Orthophoto|unknown": 34,
|
||||
"unknown|declared": 6,
|
||||
"unknown|declared": 7,
|
||||
"unknown|unavailable": 3,
|
||||
"unknown|unknown": 246
|
||||
"unknown|unknown": 943
|
||||
},
|
||||
"scan_id": "p3-46ee3f8d3a2dc52b",
|
||||
"scan_id": "p3-eb67185e61107cc7",
|
||||
"schema_version": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"review_id": "20260830-independent-ai-visual-review",
|
||||
"reviewed_at": "2026-08-30T00:00:00+02:00",
|
||||
"reviewer_kind": "independent_ai_visual_inspection",
|
||||
"human_reviewer": false,
|
||||
"claim_boundary": "Visual inspection evidence only. This is not a human acceptance ledger, protected-test result, accuracy measurement or model-promotion approval.",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/contact_sheet_001.png",
|
||||
"sha256": "f7734c5a9db8837adeda7f8e0f1a2942148330abfcb588d41dc245017ec2435a"
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_001.png",
|
||||
"sha256": "4e092afac6bbff820908f73acd2a29b6d327d7476c3b2ae5001302a0bde4a24f"
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_002.png",
|
||||
"sha256": "ef4e4a9663b4ac1920bee76bf0ca40c69d6232ddeb40d75c6963a17540b8d211"
|
||||
},
|
||||
{
|
||||
"path": "artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/active-error-contact-sheet.png",
|
||||
"sha256": "bb5e4809802643cc8b673a86f3027ed06aa0430fbe79b1d33718c21d5feb269d"
|
||||
},
|
||||
{
|
||||
"path": "storage/operator-data/model-review/reviewedexp6-corpus/full-ai-review-r1/contact_sheet_001.png",
|
||||
"sha256": "688b97a6e82e6b906b7966ed3e2b43685786e89d50549188bfdcc3837d042eb2"
|
||||
},
|
||||
{
|
||||
"path": "storage/operator-data/model-review/reviewedexp6-corpus/nested-pairs-review-r2/contact_sheet_001.png",
|
||||
"sha256": "2a6bdd542b8ac49bcd5ac4d9c84999982461463ddd3c2fe0dc7332ceeb7a5e60"
|
||||
},
|
||||
{
|
||||
"path": "storage/operator-data/model-review/reviewedexp6-corpus/tile-edge-review-r1/contact_sheet_001.png",
|
||||
"sha256": "5d96c5a0a732077ed16b22d33bed998b37bd33b5536b1491e778999a2a6638dd"
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"severity": "blocker",
|
||||
"category": "reference_geometry_semantics",
|
||||
"observation": "Many yellow reference rectangles are coarse axis-aligned extents rather than roof or building footprints; several include vegetation, roads, fields or multiple structures.",
|
||||
"impact": "A detector trained against these boxes is penalized for geometrically correct roof localization and rewarded for oversized detections."
|
||||
},
|
||||
{
|
||||
"severity": "blocker",
|
||||
"category": "dense_and_nested_labels",
|
||||
"observation": "Dense urban and industrial samples contain strongly overlapping, nested and grouped boxes without an unambiguous single-object labelling rule.",
|
||||
"impact": "Label conflict and inconsistent object granularity make precision, recall and NMS behaviour unreliable."
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "tile_edge_and_visibility",
|
||||
"observation": "Multiple labelled structures are clipped by tile boundaries or only partly visible, while edge inclusion rules are not consistently evident.",
|
||||
"impact": "This creates avoidable false-negative and duplicate-detection pressure at inference tile seams."
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "hard_negative_coverage",
|
||||
"observation": "Industrial rails, paved surfaces, tree canopy and tree-shadow remain recurrent false-positive contexts in the active-error sheet; representative pure-background coverage remains insufficient, especially for Brussels.",
|
||||
"impact": "The candidate has no defensible low-false-positive operating point across Belgium."
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "model_error_balance",
|
||||
"observation": "The active-error review shows simultaneous false positives and false negatives across industrial, vegetated and sparse-rural contexts.",
|
||||
"impact": "Threshold tuning alone cannot repair the observed error pattern."
|
||||
}
|
||||
],
|
||||
"decision": {
|
||||
"corpus_accepted": false,
|
||||
"training_gate": "blocked",
|
||||
"promotion_allowed": false,
|
||||
"human_review_satisfied": false,
|
||||
"required_before_next_governed_training": [
|
||||
"re-extract building labels from task-appropriate authoritative geometry with an explicit object-granularity rule",
|
||||
"resolve dense and nested labels and publish a checksum-bound review ledger",
|
||||
"apply deterministic tile-edge visibility and ownership rules",
|
||||
"add independent pure-background and hard-negative strata for Flanders, Wallonia and Brussels",
|
||||
"prove AOI and derived-image independence across train, validation, test and challenge splits",
|
||||
"obtain representative human acceptance before opening protected evaluation"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@ storage
|
||||
dist
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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
|
||||
@@ -18,12 +19,15 @@ 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,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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
|
||||
@@ -10,14 +12,22 @@ 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")
|
||||
|
||||
|
||||
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession:
|
||||
def _session_from_principal(
|
||||
principal: AuthPrincipal,
|
||||
*,
|
||||
guest_access_enabled: bool,
|
||||
authentik_enabled: bool,
|
||||
) -> AuthSession:
|
||||
return AuthSession(
|
||||
authentication_required=True,
|
||||
authenticated=True,
|
||||
@@ -25,6 +35,7 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -32,11 +43,13 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
|
||||
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:
|
||||
@@ -44,10 +57,12 @@ def _session_payload(request: Request) -> 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,10 +135,83 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
|
||||
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,
|
||||
@@ -163,6 +251,7 @@ def guest_login(
|
||||
data=_session_from_principal(
|
||||
principal,
|
||||
guest_access_enabled=True,
|
||||
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -176,5 +265,6 @@ def logout(response: Response) -> AuthSessionEnvelope:
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,13 +5,13 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import Area, Project
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.models import Area, Project
|
||||
from app.schemas import (
|
||||
BathymetryPartitionFinalizationResult,
|
||||
BathymetrySourceProbeRead,
|
||||
@@ -1172,11 +1172,14 @@ def raster_tile_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
payload: RasterTileRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||
if dataset.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
principal = getattr(request.state, "auth_principal", None)
|
||||
guest_max_tiles = get_settings().yolo_max_tiles if getattr(principal, "role", None) == "guest" else None
|
||||
job = _run_job_sync(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
@@ -1189,6 +1192,7 @@ def raster_tile_dataset(
|
||||
tile_size=payload.tile_size,
|
||||
overlap=payload.overlap,
|
||||
output_name=payload.output_name,
|
||||
max_tiles=guest_max_tiles,
|
||||
),
|
||||
)
|
||||
return envelope(job)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -22,6 +24,14 @@ class Settings(BaseSettings):
|
||||
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,
|
||||
@@ -54,7 +64,18 @@ class Settings(BaseSettings):
|
||||
allow_external_artifact_paths: bool = Field(
|
||||
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
|
||||
)
|
||||
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB")
|
||||
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",
|
||||
)
|
||||
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
||||
orthophoto_wms_url: str = Field(
|
||||
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
||||
@@ -470,6 +491,47 @@ class Settings(BaseSettings):
|
||||
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():
|
||||
|
||||
+8
-2
@@ -26,7 +26,7 @@ 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]")
|
||||
UNSAFE_HOST = re.compile(r"[/\\@?#\s\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _to_error_payload(
|
||||
@@ -157,6 +157,8 @@ def create_app() -> FastAPI:
|
||||
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
|
||||
@@ -261,6 +263,7 @@ def create_app() -> FastAPI:
|
||||
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",
|
||||
@@ -274,7 +277,10 @@ def create_app() -> FastAPI:
|
||||
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",
|
||||
@@ -313,7 +319,7 @@ def create_app() -> FastAPI:
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_READ_ONLY",
|
||||
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.",
|
||||
"Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
from app.models import *
|
||||
from app.models import * # noqa: F403 - legacy compatibility shim re-exports the package API
|
||||
|
||||
@@ -14,6 +14,7 @@ class AreaCreate(BaseModel):
|
||||
|
||||
class AreaUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
geometry: dict | None = None
|
||||
crs: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class AuthSession(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ class ModelAssetRead(BaseModel):
|
||||
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
|
||||
|
||||
@@ -12,7 +12,10 @@ from app.schemas.spw_terrain import SpwTerrainAcquireRequest
|
||||
from app.schemas.official_vector import OfficialVectorAcquireRequest
|
||||
from app.schemas.flood_hazard import FloodHazardAcquireRequest
|
||||
from app.schemas.thematic_raster import ThematicRasterAcquireRequest
|
||||
from app.schemas.bathymetry import BathymetryProfileAcquireRequest, MdkBathymetryAcquireRequest
|
||||
from app.schemas.bathymetry import (
|
||||
BathymetryProfileAcquireRequest,
|
||||
MdkBathymetryAcquireRequest,
|
||||
)
|
||||
from app.schemas.job import JobCreate
|
||||
from app.schemas.operations import VectorSelectionBBox
|
||||
from app.schemas.orthophoto import OrthophotoAcquireRequest
|
||||
@@ -20,12 +23,20 @@ from app.services.aoi_operation_service import AoiOperationService
|
||||
from app.services.grb_acquisition_service import GrbAcquisitionService
|
||||
from app.services.dhmv_acquisition_service import DhmvAcquisitionService
|
||||
from app.services.spw_terrain_service import SpwTerrainService
|
||||
from app.services.official_vector_acquisition_service import OfficialVectorAcquisitionService
|
||||
from app.services.official_vector_acquisition_service import (
|
||||
OfficialVectorAcquisitionService,
|
||||
)
|
||||
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
|
||||
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
|
||||
from app.services.thematic_raster_acquisition_service import (
|
||||
ThematicRasterAcquisitionService,
|
||||
)
|
||||
from app.services.walous_land_cover_service import WalousLandCoverService
|
||||
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
||||
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
|
||||
from app.services.bathymetry_profile_acquisition_service import (
|
||||
BathymetryProfileAcquisitionService,
|
||||
)
|
||||
from app.services.mdk_bathymetry_acquisition_service import (
|
||||
MdkBathymetryAcquisitionService,
|
||||
)
|
||||
from app.services.job_service import JobService
|
||||
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
|
||||
|
||||
@@ -40,65 +51,202 @@ class AoiOperationExecutor:
|
||||
AoiOperationService._refresh_parent(db, operation_id)
|
||||
return AoiOperationService.read(db, project_id, operation_id)
|
||||
operation = db.get(AoiOperation, operation_id)
|
||||
child = JobService.create_job(db, JobCreate(
|
||||
job_type=f"aoi.{operation.operation_type}.partition",
|
||||
project_id=project_id,
|
||||
parameters_json={
|
||||
"aoi_operation_id": str(operation_id),
|
||||
"partition_id": str(partition.id),
|
||||
"partition_key": partition.partition_key,
|
||||
"provider_key": partition.provider_key,
|
||||
"product_key": partition.product_key,
|
||||
},
|
||||
))
|
||||
child = JobService.create_job(
|
||||
db,
|
||||
JobCreate(
|
||||
job_type=f"aoi.{operation.operation_type}.partition",
|
||||
project_id=project_id,
|
||||
parameters_json={
|
||||
"aoi_operation_id": str(operation_id),
|
||||
"partition_id": str(partition.id),
|
||||
"partition_key": partition.partition_key,
|
||||
"provider_key": partition.provider_key,
|
||||
"product_key": partition.product_key,
|
||||
},
|
||||
),
|
||||
)
|
||||
partition = db.get(AoiOperationPartition, partition.id)
|
||||
partition.child_job_id = child.id
|
||||
db.add(partition); db.commit()
|
||||
db.add(partition)
|
||||
db.commit()
|
||||
JobService.mark_running(db, child.id)
|
||||
try:
|
||||
result = AoiOperationExecutor._dispatch(db, project_id, operation, partition)
|
||||
output_id = result.get("output_dataset_id") if isinstance(result, dict) else None
|
||||
JobService.mark_success(db, child.id, result=result, output_dataset_id=UUID(str(output_id)) if output_id else None)
|
||||
return AoiOperationService.complete(db, project_id, operation_id, partition.id, result)
|
||||
result = AoiOperationExecutor._dispatch(
|
||||
db, project_id, operation, partition
|
||||
)
|
||||
output_id = (
|
||||
result.get("output_dataset_id") if isinstance(result, dict) else None
|
||||
)
|
||||
JobService.mark_success(
|
||||
db,
|
||||
child.id,
|
||||
result=result,
|
||||
output_dataset_id=UUID(str(output_id)) if output_id else None,
|
||||
)
|
||||
return AoiOperationService.complete(
|
||||
db, project_id, operation_id, partition.id, result
|
||||
)
|
||||
except AppError as exc:
|
||||
JobService.mark_failed(db, child.id, exc.message, {"code": exc.code, "details": exc.details})
|
||||
return AoiOperationService.fail(db, project_id, operation_id, partition.id, exc.message, AoiOperationExecutor._retryable(exc), {"code": exc.code, "details": exc.details})
|
||||
JobService.mark_failed(
|
||||
db, child.id, exc.message, {"code": exc.code, "details": exc.details}
|
||||
)
|
||||
return AoiOperationService.fail(
|
||||
db,
|
||||
project_id,
|
||||
operation_id,
|
||||
partition.id,
|
||||
exc.message,
|
||||
AoiOperationExecutor._retryable(exc),
|
||||
{"code": exc.code, "details": exc.details},
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
db.rollback()
|
||||
JobService.mark_failed(db, child.id, "Unexpected partition execution error", {"code": "AOI_PARTITION_INTERNAL_ERROR"})
|
||||
JobService.mark_failed(
|
||||
db,
|
||||
child.id,
|
||||
"Unexpected partition execution error",
|
||||
{"code": "AOI_PARTITION_INTERNAL_ERROR"},
|
||||
)
|
||||
finally:
|
||||
AoiOperationService.fail(db, project_id, operation_id, partition.id, "Unexpected partition execution error", True, {"code": "AOI_PARTITION_INTERNAL_ERROR"})
|
||||
AoiOperationService.fail(
|
||||
db,
|
||||
project_id,
|
||||
operation_id,
|
||||
partition.id,
|
||||
"Unexpected partition execution error",
|
||||
True,
|
||||
{"code": "AOI_PARTITION_INTERNAL_ERROR"},
|
||||
)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _dispatch(db, project_id: UUID, operation: AoiOperation, partition: AoiOperationPartition) -> dict:
|
||||
def _dispatch(
|
||||
db, project_id: UUID, operation: AoiOperation, partition: AoiOperationPartition
|
||||
) -> dict:
|
||||
geometry = to_shape(partition.geometry)
|
||||
min_x, min_y, max_x, max_y = geometry.bounds
|
||||
bbox = VectorSelectionBBox(min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y, crs="EPSG:4326")
|
||||
force_refresh = bool((operation.request_json or {}).get("parameters_json", {}).get("force_refresh", False))
|
||||
bbox = VectorSelectionBBox(
|
||||
min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y, crs="EPSG:4326"
|
||||
)
|
||||
force_refresh = bool(
|
||||
(operation.request_json or {})
|
||||
.get("parameters_json", {})
|
||||
.get("force_refresh", False)
|
||||
)
|
||||
if partition.provider_key == "grb":
|
||||
return GrbAcquisitionService.acquire(db, project_id, GrbAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return GrbAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
GrbAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "orthophoto":
|
||||
return OrthophotoAcquisitionService.acquire(db, project_id, OrthophotoAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return OrthophotoAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
OrthophotoAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "dhmv":
|
||||
return DhmvAcquisitionService.acquire(db, project_id, DhmvAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return DhmvAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
DhmvAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "spw_terrain":
|
||||
return SpwTerrainService.acquire(db, project_id, SpwTerrainAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return SpwTerrainService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
SpwTerrainAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "official_vector":
|
||||
return OfficialVectorAcquisitionService.acquire(db, project_id, OfficialVectorAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return OfficialVectorAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
OfficialVectorAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "flood_hazard":
|
||||
return FloodHazardAcquisitionService.acquire(db, project_id, FloodHazardAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return FloodHazardAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
FloodHazardAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "thematic_raster":
|
||||
return ThematicRasterAcquisitionService.acquire(db, project_id, ThematicRasterAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return ThematicRasterAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
ThematicRasterAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "walous":
|
||||
return WalousLandCoverService.acquire(db, project_id, ThematicRasterAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh))
|
||||
return WalousLandCoverService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
ThematicRasterAcquireRequest(
|
||||
bbox=bbox,
|
||||
area_id=operation.area_id,
|
||||
product_key=partition.product_key,
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "bathymetry_profiles":
|
||||
return BathymetryProfileAcquisitionService.acquire(db, project_id, BathymetryProfileAcquireRequest(bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh))
|
||||
return BathymetryProfileAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
BathymetryProfileAcquireRequest(
|
||||
bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh
|
||||
),
|
||||
)
|
||||
if partition.provider_key == "mdk_bathymetry":
|
||||
return MdkBathymetryAcquisitionService.acquire(db, project_id, MdkBathymetryAcquireRequest(bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh))
|
||||
raise AppError(code="AOI_PROVIDER_UNSUPPORTED", message="No governed AOI executor is registered for this provider", details={"provider_key": partition.provider_key}, status_code=422)
|
||||
return MdkBathymetryAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
MdkBathymetryAcquireRequest(
|
||||
bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh
|
||||
),
|
||||
)
|
||||
raise AppError(
|
||||
code="AOI_PROVIDER_UNSUPPORTED",
|
||||
message="No governed AOI executor is registered for this provider",
|
||||
details={"provider_key": partition.provider_key},
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retryable(error: AppError) -> bool:
|
||||
return error.status_code >= 500 or error.code.endswith(("TIMEOUT", "UNAVAILABLE", "TLS_ERROR"))
|
||||
return error.status_code >= 500 or error.code.endswith(
|
||||
("TIMEOUT", "UNAVAILABLE", "TLS_ERROR")
|
||||
)
|
||||
|
||||
@@ -22,8 +22,11 @@ class AoiOperationService:
|
||||
_to_metric = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
|
||||
_to_wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
|
||||
SCOPE_AREA_NAMES = {
|
||||
"belgium": "Belgium land", "flanders": "Flanders", "wallonia": "Wallonia",
|
||||
"brussels": "Brussels-Capital Region", "belgian_north_sea": "Belgian part of the North Sea",
|
||||
"belgium": "Belgium land",
|
||||
"flanders": "Flanders",
|
||||
"wallonia": "Wallonia",
|
||||
"brussels": "Brussels-Capital Region",
|
||||
"belgian_north_sea": "Belgian part of the North Sea",
|
||||
"territorial_sea": "Belgian territorial sea (0-12 nautical miles)",
|
||||
"exclusive_economic_zone": "Belgian exclusive economic zone beyond territorial sea",
|
||||
"continental_shelf": "Belgian continental shelf beyond territorial sea",
|
||||
@@ -32,13 +35,19 @@ class AoiOperationService:
|
||||
@staticmethod
|
||||
def create(db, project_id: UUID, payload: AoiOperationCreate) -> dict:
|
||||
if db.get(Project, project_id) is None:
|
||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||
raise AppError(
|
||||
code="PROJECT_NOT_FOUND", message="Project not found", status_code=404
|
||||
)
|
||||
geometry = AoiOperationService._resolve_geometry(db, project_id, payload)
|
||||
if payload.coverage_zone:
|
||||
geometry = AoiOperationService._clip_to_zone(db, project_id, geometry, payload.coverage_zone)
|
||||
geometry = AoiOperationService._clip_to_zone(
|
||||
db, project_id, geometry, payload.coverage_zone
|
||||
)
|
||||
geometry = AoiOperationService._as_multipolygon(geometry)
|
||||
metric_geometry = transform(AoiOperationService._to_metric.transform, geometry)
|
||||
partition_side_m = AoiOperationService._partition_side(payload.provider_key, payload.max_partition_side_m)
|
||||
partition_side_m = AoiOperationService._partition_side(
|
||||
payload.provider_key, payload.max_partition_side_m
|
||||
)
|
||||
cells = AoiOperationService._partition(metric_geometry, partition_side_m)
|
||||
operation_id = uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -53,7 +62,9 @@ class AoiOperationService:
|
||||
plan_json={
|
||||
"partition_strategy": "epsg31370_square_grid_intersection_v1",
|
||||
"max_partition_side_m": partition_side_m,
|
||||
"budget_source": "governed_provider_registry" if payload.max_partition_side_m is None else "stricter_operator_override",
|
||||
"budget_source": "governed_provider_registry"
|
||||
if payload.max_partition_side_m is None
|
||||
else "stricter_operator_override",
|
||||
"partition_count": len(cells),
|
||||
"provider_key": payload.provider_key,
|
||||
"product_key": payload.product_key,
|
||||
@@ -65,13 +76,21 @@ class AoiOperationService:
|
||||
wgs84 = transform(AoiOperationService._to_wgs84.transform, cell)
|
||||
wgs84 = AoiOperationService._as_multipolygon(wgs84)
|
||||
digest = sha256(wgs84.wkb).hexdigest()[:20]
|
||||
db.add(AoiOperationPartition(
|
||||
id=uuid4(), operation_id=operation_id,
|
||||
partition_key=f"{payload.provider_key}:{payload.product_key}:{ordinal:05d}:{digest}",
|
||||
provider_key=payload.provider_key, product_key=payload.product_key,
|
||||
ordinal=ordinal, status="queued", geometry=from_shape(wgs84, srid=4326),
|
||||
attempt_count=0, max_attempts=payload.max_attempts, created_at=now,
|
||||
))
|
||||
db.add(
|
||||
AoiOperationPartition(
|
||||
id=uuid4(),
|
||||
operation_id=operation_id,
|
||||
partition_key=f"{payload.provider_key}:{payload.product_key}:{ordinal:05d}:{digest}",
|
||||
provider_key=payload.provider_key,
|
||||
product_key=payload.product_key,
|
||||
ordinal=ordinal,
|
||||
status="queued",
|
||||
geometry=from_shape(wgs84, srid=4326),
|
||||
attempt_count=0,
|
||||
max_attempts=payload.max_attempts,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return AoiOperationService.read(db, project_id, operation_id)
|
||||
|
||||
@@ -79,13 +98,32 @@ class AoiOperationService:
|
||||
def _clip_to_zone(db, project_id: UUID, geometry, zone: str):
|
||||
area_name = AoiOperationService.SCOPE_AREA_NAMES.get(zone)
|
||||
if area_name is None:
|
||||
raise AppError(code="AOI_COVERAGE_ZONE_UNSUPPORTED", message="Unknown governed coverage zone", details={"coverage_zone": zone}, status_code=422)
|
||||
scope = db.query(Area).filter(Area.project_id == project_id, Area.name == area_name).first()
|
||||
raise AppError(
|
||||
code="AOI_COVERAGE_ZONE_UNSUPPORTED",
|
||||
message="Unknown governed coverage zone",
|
||||
details={"coverage_zone": zone},
|
||||
status_code=422,
|
||||
)
|
||||
scope = (
|
||||
db.query(Area)
|
||||
.filter(Area.project_id == project_id, Area.name == area_name)
|
||||
.first()
|
||||
)
|
||||
if scope is None:
|
||||
raise AppError(code="AOI_COVERAGE_ZONE_NOT_MATERIALIZED", message="The governed coverage-zone geometry is not persisted in this project", details={"coverage_zone": zone}, status_code=409)
|
||||
raise AppError(
|
||||
code="AOI_COVERAGE_ZONE_NOT_MATERIALIZED",
|
||||
message="The governed coverage-zone geometry is not persisted in this project",
|
||||
details={"coverage_zone": zone},
|
||||
status_code=409,
|
||||
)
|
||||
clipped = geometry.intersection(to_shape(scope.geometry))
|
||||
if clipped.is_empty:
|
||||
raise AppError(code="AOI_OUTSIDE_PROVIDER_ZONE", message="The AOI does not intersect the provider coverage zone", details={"coverage_zone": zone}, status_code=422)
|
||||
raise AppError(
|
||||
code="AOI_OUTSIDE_PROVIDER_ZONE",
|
||||
message="The AOI does not intersect the provider coverage zone",
|
||||
details={"coverage_zone": zone},
|
||||
status_code=422,
|
||||
)
|
||||
return clipped
|
||||
|
||||
@staticmethod
|
||||
@@ -94,19 +132,30 @@ class AoiOperationService:
|
||||
return MultiPolygon([geometry])
|
||||
if isinstance(geometry, MultiPolygon):
|
||||
return geometry
|
||||
polygons = [part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)]
|
||||
polygons = [
|
||||
part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)
|
||||
]
|
||||
if not polygons:
|
||||
raise AppError(code="AOI_GEOMETRY_EMPTY", message="AOI contains no polygonal area after clipping", status_code=422)
|
||||
raise AppError(
|
||||
code="AOI_GEOMETRY_EMPTY",
|
||||
message="AOI contains no polygonal area after clipping",
|
||||
status_code=422,
|
||||
)
|
||||
return MultiPolygon(polygons)
|
||||
|
||||
@staticmethod
|
||||
def _partition_side(provider_key: str, requested: float | None) -> float:
|
||||
settings = get_settings()
|
||||
def raster_side(max_side_m: float, max_pixels: int, resolution_m: float) -> float:
|
||||
|
||||
def raster_side(
|
||||
max_side_m: float, max_pixels: int, resolution_m: float
|
||||
) -> float:
|
||||
# Keep every square grid cell within both the provider's spatial
|
||||
# extent limit and its decoded-pixel budget. The small safety
|
||||
# margin absorbs ceil/edge rounding in the acquisition services.
|
||||
pixel_limited_side = math.sqrt(float(max_pixels)) * float(resolution_m) * 0.99
|
||||
pixel_limited_side = (
|
||||
math.sqrt(float(max_pixels)) * float(resolution_m) * 0.99
|
||||
)
|
||||
return min(float(max_side_m), pixel_limited_side)
|
||||
|
||||
budgets = {
|
||||
@@ -142,22 +191,37 @@ class AoiOperationService:
|
||||
"mdk_bathymetry": 20_000.0,
|
||||
}
|
||||
if provider_key not in budgets:
|
||||
raise AppError(code="AOI_PROVIDER_UNSUPPORTED", message="No governed partition budget is registered for this provider", details={"provider_key": provider_key}, status_code=422)
|
||||
raise AppError(
|
||||
code="AOI_PROVIDER_UNSUPPORTED",
|
||||
message="No governed partition budget is registered for this provider",
|
||||
details={"provider_key": provider_key},
|
||||
status_code=422,
|
||||
)
|
||||
governed = budgets[provider_key]
|
||||
return min(governed, float(requested)) if requested is not None else governed
|
||||
|
||||
@staticmethod
|
||||
def _resolve_geometry(db, project_id: UUID, payload: AoiOperationCreate):
|
||||
if (payload.area_id is None) == (payload.bbox is None):
|
||||
raise AppError(code="AOI_SELECTION_REQUIRED", message="Provide exactly one area_id or bbox", status_code=422)
|
||||
raise AppError(
|
||||
code="AOI_SELECTION_REQUIRED",
|
||||
message="Provide exactly one area_id or bbox",
|
||||
status_code=422,
|
||||
)
|
||||
if payload.area_id is not None:
|
||||
area = db.get(Area, payload.area_id)
|
||||
if area is None or area.project_id != project_id:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
raise AppError(
|
||||
code="AREA_NOT_FOUND", message="Area not found", status_code=404
|
||||
)
|
||||
return to_shape(area.geometry)
|
||||
bbox = payload.bbox
|
||||
if bbox is None or bbox.crs != "EPSG:4326":
|
||||
raise AppError(code="INVALID_AOI_CRS", message="AOI bbox must use EPSG:4326", status_code=422)
|
||||
raise AppError(
|
||||
code="INVALID_AOI_CRS",
|
||||
message="AOI bbox must use EPSG:4326",
|
||||
status_code=422,
|
||||
)
|
||||
return box(bbox.min_x, bbox.min_y, bbox.max_x, bbox.max_y)
|
||||
|
||||
@staticmethod
|
||||
@@ -166,11 +230,26 @@ class AoiOperationService:
|
||||
columns = max(1, math.ceil((max_x - min_x) / side_m))
|
||||
rows = max(1, math.ceil((max_y - min_y) / side_m))
|
||||
if columns * rows > AoiOperationService.MAX_PARTITIONS:
|
||||
raise AppError(code="AOI_PARTITION_LIMIT_EXCEEDED", message="AOI requires too many bounded partitions", details={"candidate_count": columns * rows, "max_partitions": AoiOperationService.MAX_PARTITIONS}, status_code=422)
|
||||
raise AppError(
|
||||
code="AOI_PARTITION_LIMIT_EXCEEDED",
|
||||
message="AOI requires too many bounded partitions",
|
||||
details={
|
||||
"candidate_count": columns * rows,
|
||||
"max_partitions": AoiOperationService.MAX_PARTITIONS,
|
||||
},
|
||||
status_code=422,
|
||||
)
|
||||
partitions = []
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
clipped = geometry.intersection(box(min_x + column * side_m, min_y + row * side_m, min(min_x + (column + 1) * side_m, max_x), min(min_y + (row + 1) * side_m, max_y)))
|
||||
clipped = geometry.intersection(
|
||||
box(
|
||||
min_x + column * side_m,
|
||||
min_y + row * side_m,
|
||||
min(min_x + (column + 1) * side_m, max_x),
|
||||
min(min_y + (row + 1) * side_m, max_y),
|
||||
)
|
||||
)
|
||||
if not clipped.is_empty and clipped.area > 0:
|
||||
partitions.append(clipped)
|
||||
return partitions
|
||||
@@ -179,98 +258,224 @@ class AoiOperationService:
|
||||
def read(db, project_id: UUID, operation_id: UUID) -> dict:
|
||||
operation = db.get(AoiOperation, operation_id)
|
||||
if operation is None or operation.project_id != project_id:
|
||||
raise AppError(code="AOI_OPERATION_NOT_FOUND", message="AOI operation not found", status_code=404)
|
||||
partitions = db.query(AoiOperationPartition).filter(AoiOperationPartition.operation_id == operation_id).order_by(AoiOperationPartition.ordinal).all()
|
||||
raise AppError(
|
||||
code="AOI_OPERATION_NOT_FOUND",
|
||||
message="AOI operation not found",
|
||||
status_code=404,
|
||||
)
|
||||
partitions = (
|
||||
db.query(AoiOperationPartition)
|
||||
.filter(AoiOperationPartition.operation_id == operation_id)
|
||||
.order_by(AoiOperationPartition.ordinal)
|
||||
.all()
|
||||
)
|
||||
counts = Counter(partition.status for partition in partitions)
|
||||
complete = counts["success"] + counts["skipped"]
|
||||
return {
|
||||
"id": operation.id, "project_id": operation.project_id, "area_id": operation.area_id,
|
||||
"parent_job_id": operation.parent_job_id, "operation_type": operation.operation_type,
|
||||
"status": operation.status, "request_json": operation.request_json, "plan_json": operation.plan_json,
|
||||
"result_json": operation.result_json, "error_message": operation.error_message,
|
||||
"id": operation.id,
|
||||
"project_id": operation.project_id,
|
||||
"area_id": operation.area_id,
|
||||
"parent_job_id": operation.parent_job_id,
|
||||
"operation_type": operation.operation_type,
|
||||
"status": operation.status,
|
||||
"request_json": operation.request_json,
|
||||
"plan_json": operation.plan_json,
|
||||
"result_json": operation.result_json,
|
||||
"error_message": operation.error_message,
|
||||
"progress": round(complete / len(partitions), 6) if partitions else 0.0,
|
||||
"partition_counts": dict(counts), "partitions": partitions,
|
||||
"created_at": operation.created_at, "started_at": operation.started_at, "finished_at": operation.finished_at,
|
||||
"partition_counts": dict(counts),
|
||||
"partitions": partitions,
|
||||
"created_at": operation.created_at,
|
||||
"started_at": operation.started_at,
|
||||
"finished_at": operation.finished_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def list(db, project_id: UUID, limit: int = 50) -> dict:
|
||||
rows = db.query(AoiOperation).filter(AoiOperation.project_id == project_id).order_by(AoiOperation.created_at.desc()).limit(limit).all()
|
||||
return {"items": [AoiOperationService.read(db, project_id, row.id) for row in rows], "total": len(rows)}
|
||||
rows = (
|
||||
db.query(AoiOperation)
|
||||
.filter(AoiOperation.project_id == project_id)
|
||||
.order_by(AoiOperation.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"items": [AoiOperationService.read(db, project_id, row.id) for row in rows],
|
||||
"total": len(rows),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def claim_next(db, project_id: UUID, operation_id: UUID):
|
||||
operation = db.get(AoiOperation, operation_id)
|
||||
if operation is None or operation.project_id != project_id:
|
||||
raise AppError(code="AOI_OPERATION_NOT_FOUND", message="AOI operation not found", status_code=404)
|
||||
partition = db.query(AoiOperationPartition).filter(AoiOperationPartition.operation_id == operation_id, AoiOperationPartition.status == "queued").order_by(AoiOperationPartition.ordinal).with_for_update(skip_locked=True).first()
|
||||
raise AppError(
|
||||
code="AOI_OPERATION_NOT_FOUND",
|
||||
message="AOI operation not found",
|
||||
status_code=404,
|
||||
)
|
||||
partition = (
|
||||
db.query(AoiOperationPartition)
|
||||
.filter(
|
||||
AoiOperationPartition.operation_id == operation_id,
|
||||
AoiOperationPartition.status == "queued",
|
||||
)
|
||||
.order_by(AoiOperationPartition.ordinal)
|
||||
.with_for_update(skip_locked=True)
|
||||
.first()
|
||||
)
|
||||
if partition is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
partition.status = "running"; partition.started_at = now; partition.attempt_count += 1; partition.error_message = None
|
||||
operation.status = "running"; operation.started_at = operation.started_at or now
|
||||
db.add(partition); db.add(operation); db.commit(); db.refresh(partition)
|
||||
partition.status = "running"
|
||||
partition.started_at = now
|
||||
partition.attempt_count += 1
|
||||
partition.error_message = None
|
||||
operation.status = "running"
|
||||
operation.started_at = operation.started_at or now
|
||||
db.add(partition)
|
||||
db.add(operation)
|
||||
db.commit()
|
||||
db.refresh(partition)
|
||||
return partition
|
||||
|
||||
@staticmethod
|
||||
def checkpoint(db, project_id: UUID, operation_id: UUID, partition_id: UUID, checkpoint: dict):
|
||||
partition = AoiOperationService._partition_row(db, project_id, operation_id, partition_id)
|
||||
def checkpoint(
|
||||
db, project_id: UUID, operation_id: UUID, partition_id: UUID, checkpoint: dict
|
||||
):
|
||||
partition = AoiOperationService._partition_row(
|
||||
db, project_id, operation_id, partition_id
|
||||
)
|
||||
if partition.status != "running":
|
||||
raise AppError(code="AOI_PARTITION_NOT_RUNNING", message="Only a running partition can be checkpointed", status_code=409)
|
||||
partition.checkpoint_json = checkpoint; db.add(partition); db.commit(); db.refresh(partition)
|
||||
raise AppError(
|
||||
code="AOI_PARTITION_NOT_RUNNING",
|
||||
message="Only a running partition can be checkpointed",
|
||||
status_code=409,
|
||||
)
|
||||
partition.checkpoint_json = checkpoint
|
||||
db.add(partition)
|
||||
db.commit()
|
||||
db.refresh(partition)
|
||||
return partition
|
||||
|
||||
@staticmethod
|
||||
def complete(db, project_id: UUID, operation_id: UUID, partition_id: UUID, result: dict, skipped: bool = False):
|
||||
partition = AoiOperationService._partition_row(db, project_id, operation_id, partition_id)
|
||||
def complete(
|
||||
db,
|
||||
project_id: UUID,
|
||||
operation_id: UUID,
|
||||
partition_id: UUID,
|
||||
result: dict,
|
||||
skipped: bool = False,
|
||||
):
|
||||
partition = AoiOperationService._partition_row(
|
||||
db, project_id, operation_id, partition_id
|
||||
)
|
||||
if partition.status == "success" or partition.status == "skipped":
|
||||
return AoiOperationService.read(db, project_id, operation_id)
|
||||
if partition.status != "running":
|
||||
raise AppError(code="AOI_PARTITION_NOT_RUNNING", message="Only a running partition can complete", status_code=409)
|
||||
partition.status = "skipped" if skipped else "success"; partition.result_json = result; partition.finished_at = datetime.now(timezone.utc)
|
||||
db.add(partition); db.commit(); AoiOperationService._refresh_parent(db, operation_id)
|
||||
raise AppError(
|
||||
code="AOI_PARTITION_NOT_RUNNING",
|
||||
message="Only a running partition can complete",
|
||||
status_code=409,
|
||||
)
|
||||
partition.status = "skipped" if skipped else "success"
|
||||
partition.result_json = result
|
||||
partition.finished_at = datetime.now(timezone.utc)
|
||||
db.add(partition)
|
||||
db.commit()
|
||||
AoiOperationService._refresh_parent(db, operation_id)
|
||||
return AoiOperationService.read(db, project_id, operation_id)
|
||||
|
||||
@staticmethod
|
||||
def fail(db, project_id: UUID, operation_id: UUID, partition_id: UUID, message: str, retryable: bool, details: dict):
|
||||
partition = AoiOperationService._partition_row(db, project_id, operation_id, partition_id)
|
||||
partition.error_message = message; partition.result_json = {"details": details}
|
||||
partition.status = "queued" if retryable and partition.attempt_count < partition.max_attempts else "failed"
|
||||
partition.finished_at = None if partition.status == "queued" else datetime.now(timezone.utc)
|
||||
db.add(partition); db.commit(); AoiOperationService._refresh_parent(db, operation_id)
|
||||
def fail(
|
||||
db,
|
||||
project_id: UUID,
|
||||
operation_id: UUID,
|
||||
partition_id: UUID,
|
||||
message: str,
|
||||
retryable: bool,
|
||||
details: dict,
|
||||
):
|
||||
partition = AoiOperationService._partition_row(
|
||||
db, project_id, operation_id, partition_id
|
||||
)
|
||||
partition.error_message = message
|
||||
partition.result_json = {"details": details}
|
||||
partition.status = (
|
||||
"queued"
|
||||
if retryable and partition.attempt_count < partition.max_attempts
|
||||
else "failed"
|
||||
)
|
||||
partition.finished_at = (
|
||||
None if partition.status == "queued" else datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(partition)
|
||||
db.commit()
|
||||
AoiOperationService._refresh_parent(db, operation_id)
|
||||
return AoiOperationService.read(db, project_id, operation_id)
|
||||
|
||||
@staticmethod
|
||||
def _partition_row(db, project_id, operation_id, partition_id):
|
||||
operation = db.get(AoiOperation, operation_id); partition = db.get(AoiOperationPartition, partition_id)
|
||||
if operation is None or operation.project_id != project_id or partition is None or partition.operation_id != operation_id:
|
||||
raise AppError(code="AOI_PARTITION_NOT_FOUND", message="AOI partition not found", status_code=404)
|
||||
operation = db.get(AoiOperation, operation_id)
|
||||
partition = db.get(AoiOperationPartition, partition_id)
|
||||
if (
|
||||
operation is None
|
||||
or operation.project_id != project_id
|
||||
or partition is None
|
||||
or partition.operation_id != operation_id
|
||||
):
|
||||
raise AppError(
|
||||
code="AOI_PARTITION_NOT_FOUND",
|
||||
message="AOI partition not found",
|
||||
status_code=404,
|
||||
)
|
||||
return partition
|
||||
|
||||
@staticmethod
|
||||
def _refresh_parent(db, operation_id):
|
||||
operation = db.get(AoiOperation, operation_id)
|
||||
partitions = db.query(AoiOperationPartition).filter(AoiOperationPartition.operation_id == operation_id).order_by(AoiOperationPartition.ordinal).all()
|
||||
partitions = (
|
||||
db.query(AoiOperationPartition)
|
||||
.filter(AoiOperationPartition.operation_id == operation_id)
|
||||
.order_by(AoiOperationPartition.ordinal)
|
||||
.all()
|
||||
)
|
||||
statuses = [partition.status for partition in partitions]
|
||||
output_dataset_ids = []
|
||||
for partition in partitions:
|
||||
output_id = (partition.result_json or {}).get("output_dataset_id") if isinstance(partition.result_json, dict) else None
|
||||
output_id = (
|
||||
(partition.result_json or {}).get("output_dataset_id")
|
||||
if isinstance(partition.result_json, dict)
|
||||
else None
|
||||
)
|
||||
if output_id and str(output_id) not in output_dataset_ids:
|
||||
output_dataset_ids.append(str(output_id))
|
||||
operation.result_json = {
|
||||
"partition_count": len(partitions),
|
||||
"completed_partition_count": sum(status in {"success", "skipped"} for status in statuses),
|
||||
"completed_partition_count": sum(
|
||||
status in {"success", "skipped"} for status in statuses
|
||||
),
|
||||
"failed_partition_count": statuses.count("failed"),
|
||||
"output_dataset_ids": output_dataset_ids,
|
||||
"merge_contract": "source_aware_spatial_union",
|
||||
"vector_deduplication": "source_feature_id_then_geometry",
|
||||
"raster_deduplication": "governed_mosaic_grid",
|
||||
"complete_coverage": bool(statuses) and all(status in {"success", "skipped"} for status in statuses),
|
||||
"complete_coverage": bool(statuses)
|
||||
and all(status in {"success", "skipped"} for status in statuses),
|
||||
}
|
||||
now = datetime.now(timezone.utc)
|
||||
if statuses and all(status in {"success", "skipped"} for status in statuses):
|
||||
operation.status = "success"; operation.finished_at = now; operation.error_message = None
|
||||
elif "failed" in statuses and not any(status in {"queued", "running"} for status in statuses):
|
||||
operation.status = "partial" if any(status in {"success", "skipped"} for status in statuses) else "failed"; operation.finished_at = now
|
||||
operation.status = "success"
|
||||
operation.finished_at = now
|
||||
operation.error_message = None
|
||||
elif "failed" in statuses and not any(
|
||||
status in {"queued", "running"} for status in statuses
|
||||
):
|
||||
operation.status = (
|
||||
"partial"
|
||||
if any(status in {"success", "skipped"} for status in statuses)
|
||||
else "failed"
|
||||
)
|
||||
operation.finished_at = now
|
||||
operation.error_message = "One or more bounded source partitions failed; inspect partition evidence."
|
||||
db.add(operation); db.commit()
|
||||
db.add(operation)
|
||||
db.commit()
|
||||
|
||||
@@ -9,7 +9,7 @@ from shapely.geometry import mapping
|
||||
from app.core.errors import AppError
|
||||
from app.models import Area, Dataset, Project, VectorFeature
|
||||
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
|
||||
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon
|
||||
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_area_to_epsg4326
|
||||
|
||||
|
||||
class AreaService:
|
||||
@@ -126,7 +126,11 @@ class AreaService:
|
||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||
|
||||
try:
|
||||
multipolygon = normalize_to_multipolygon(payload.geometry)
|
||||
multipolygon, original_crs = normalize_area_to_epsg4326(
|
||||
payload.geometry,
|
||||
payload.crs or "EPSG:4326",
|
||||
)
|
||||
metric_area = area_m2(multipolygon)
|
||||
except ValueError as exc:
|
||||
raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc
|
||||
|
||||
@@ -134,8 +138,8 @@ class AreaService:
|
||||
project_id=project_id,
|
||||
name=payload.name.strip() or "Unnamed area",
|
||||
geometry=from_shape(multipolygon, srid=4326),
|
||||
original_crs=payload.crs or "EPSG:4326",
|
||||
area_m2=area_m2(multipolygon),
|
||||
original_crs=original_crs,
|
||||
area_m2=metric_area,
|
||||
bbox=from_shape(geometry_bbox_polygon(multipolygon), srid=4326),
|
||||
)
|
||||
db.add(area)
|
||||
@@ -157,11 +161,28 @@ class AreaService:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
|
||||
changed = False
|
||||
if payload.name:
|
||||
if payload.name is not None and payload.name.strip():
|
||||
area.name = payload.name.strip() or area.name
|
||||
changed = True
|
||||
if payload.crs:
|
||||
area.original_crs = payload.crs
|
||||
if payload.crs is not None and payload.geometry is None:
|
||||
raise AppError(
|
||||
code="INVALID_AREA_CRS_UPDATE",
|
||||
message="crs can only be supplied together with replacement geometry",
|
||||
status_code=422,
|
||||
)
|
||||
if payload.geometry is not None:
|
||||
try:
|
||||
multipolygon, original_crs = normalize_area_to_epsg4326(
|
||||
payload.geometry,
|
||||
payload.crs or "EPSG:4326",
|
||||
)
|
||||
metric_area = area_m2(multipolygon)
|
||||
except ValueError as exc:
|
||||
raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc
|
||||
area.geometry = from_shape(multipolygon, srid=4326)
|
||||
area.original_crs = original_crs
|
||||
area.area_m2 = metric_area
|
||||
area.bbox = from_shape(geometry_bbox_polygon(multipolygon), srid=4326)
|
||||
changed = True
|
||||
if not changed:
|
||||
raise AppError(code="NO_CHANGES", message="No updatable fields provided", status_code=422)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
import jwt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
MAX_OIDC_JSON_BYTES = 1_048_576
|
||||
|
||||
|
||||
class _RejectRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201
|
||||
return None
|
||||
|
||||
|
||||
class AuthentikOidcService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.issuer = (settings.authentik_issuer or "").rstrip("/")
|
||||
self.serializer = URLSafeTimedSerializer(
|
||||
settings.auth_session_secret or "",
|
||||
salt="geointel-authentik-v1",
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(
|
||||
self.issuer
|
||||
and self.settings.authentik_client_id
|
||||
and self.settings.authentik_client_secret
|
||||
and self.settings.authentik_allowed_email
|
||||
)
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return (
|
||||
f"{self.settings.public_base_url.rstrip('/')}"
|
||||
f"{self.settings.api_prefix}/auth/authentik/callback"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _origin(url: str) -> tuple[str, str, int]:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise ValueError("OIDC URLs must use absolute HTTPS URLs")
|
||||
return parsed.scheme, parsed.hostname.casefold(), parsed.port or 443
|
||||
|
||||
def _validate_endpoint(self, url: str) -> str:
|
||||
parsed = urlsplit(url)
|
||||
if (
|
||||
self._origin(url) != self._origin(self.issuer)
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("OIDC endpoint is outside the configured issuer origin")
|
||||
return url
|
||||
|
||||
def _fetch_json(
|
||||
self,
|
||||
url: str,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._validate_endpoint(url)
|
||||
encoded = urlencode(data).encode("utf-8") if data is not None else None
|
||||
headers = {"Accept": "application/json"}
|
||||
if encoded is not None:
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
request = Request(url, data=encoded, headers=headers)
|
||||
try:
|
||||
with build_opener(_RejectRedirects()).open(request, timeout=10) as response:
|
||||
declared_length = response.headers.get("Content-Length")
|
||||
if declared_length and int(declared_length) > MAX_OIDC_JSON_BYTES:
|
||||
raise ValueError("OIDC response exceeds the configured size limit")
|
||||
raw = response.read(MAX_OIDC_JSON_BYTES + 1)
|
||||
except HTTPError as exc:
|
||||
raise ValueError("OIDC endpoint returned an HTTP error or redirect") from exc
|
||||
if len(raw) > MAX_OIDC_JSON_BYTES:
|
||||
raise ValueError("OIDC response exceeds the configured size limit")
|
||||
payload = json.loads(raw)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("OIDC endpoint did not return a JSON object")
|
||||
return payload
|
||||
|
||||
def _discovery(self) -> dict[str, Any]:
|
||||
document = self._fetch_json(
|
||||
f"{self.issuer}/.well-known/openid-configuration"
|
||||
)
|
||||
if str(document.get("issuer", "")).rstrip("/") != self.issuer:
|
||||
raise ValueError("OIDC issuer mismatch")
|
||||
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
endpoint = document.get(key)
|
||||
if not isinstance(endpoint, str):
|
||||
raise ValueError(f"OIDC discovery is missing {key}")
|
||||
self._validate_endpoint(endpoint)
|
||||
return document
|
||||
|
||||
def start(self) -> tuple[str, str]:
|
||||
if not self.enabled:
|
||||
raise ValueError("Authentik is not configured")
|
||||
state = secrets.token_urlsafe(32)
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
flow = self.serializer.dumps(
|
||||
{"state": state, "nonce": nonce, "verifier": verifier}
|
||||
)
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
discovery = self._discovery()
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": self.settings.authentik_client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
return f"{discovery['authorization_endpoint']}?{query}", flow
|
||||
|
||||
def finish(self, *, code: str, state: str, flow_cookie: str) -> dict[str, Any]:
|
||||
if not self.enabled or not code:
|
||||
raise ValueError("OIDC flow is incomplete")
|
||||
try:
|
||||
flow = self.serializer.loads(flow_cookie, max_age=600)
|
||||
except (BadSignature, SignatureExpired) as exc:
|
||||
raise ValueError("Invalid OIDC flow") from exc
|
||||
if not isinstance(flow, dict):
|
||||
raise ValueError("Invalid OIDC flow payload")
|
||||
if not state or not secrets.compare_digest(state, str(flow.get("state", ""))):
|
||||
raise ValueError("OIDC state mismatch")
|
||||
verifier = str(flow.get("verifier", ""))
|
||||
nonce = str(flow.get("nonce", ""))
|
||||
if not verifier or not nonce:
|
||||
raise ValueError("OIDC flow payload is incomplete")
|
||||
|
||||
discovery = self._discovery()
|
||||
token_response = self._fetch_json(
|
||||
str(discovery["token_endpoint"]),
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.settings.authentik_client_id or "",
|
||||
"client_secret": self.settings.authentik_client_secret or "",
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
)
|
||||
token = str(token_response.get("id_token", ""))
|
||||
if not token:
|
||||
raise ValueError("OIDC token response has no ID token")
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||
raise ValueError("OIDC ID token uses an unsupported signing header")
|
||||
jwks = self._fetch_json(str(discovery["jwks_uri"]))
|
||||
matching_keys = [
|
||||
key
|
||||
for key in jwks.get("keys", [])
|
||||
if isinstance(key, dict) and key.get("kid") == header["kid"]
|
||||
]
|
||||
if len(matching_keys) != 1:
|
||||
raise ValueError("OIDC signing key is missing or ambiguous")
|
||||
signing_key = jwt.PyJWK.from_dict(matching_keys[0]).key
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.authentik_client_id,
|
||||
issuer=discovery["issuer"],
|
||||
options={
|
||||
"require": [
|
||||
"exp",
|
||||
"iat",
|
||||
"iss",
|
||||
"aud",
|
||||
"sub",
|
||||
"nonce",
|
||||
"email",
|
||||
"email_verified",
|
||||
]
|
||||
},
|
||||
)
|
||||
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
|
||||
raise ValueError("OIDC nonce mismatch")
|
||||
email = str(claims.get("email", "")).strip().casefold()
|
||||
allowed = str(self.settings.authentik_allowed_email or "").strip().casefold()
|
||||
if claims.get("email_verified") is not True or not secrets.compare_digest(
|
||||
email, allowed
|
||||
):
|
||||
raise ValueError("OIDC identity is not authorized")
|
||||
return claims
|
||||
@@ -7,7 +7,7 @@ import math
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
@@ -112,7 +112,6 @@ class BathymetryRasterAnalysisService:
|
||||
try:
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.features import geometry_mask
|
||||
from rasterio.mask import mask
|
||||
except ImportError as exc:
|
||||
raise AppError(
|
||||
|
||||
@@ -17,6 +17,7 @@ from shapely.geometry import MultiPoint, shape
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.config import get_settings
|
||||
from app.models import Area, Dataset, DatasetVersion, Project
|
||||
from app.services.data_contract_validation import (
|
||||
ContractKind,
|
||||
@@ -476,6 +477,29 @@ class DatasetService:
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _persist_vector_source_evidence_from_path(
|
||||
cls,
|
||||
*,
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
original_filename: str,
|
||||
source_path: str | Path,
|
||||
content_type: str | None,
|
||||
) -> dict[str, Any]:
|
||||
safe_filename = StorageService._safe_filename(original_filename)
|
||||
evidence_path = (
|
||||
StorageService.dataset_root(str(project_id), str(dataset_id), "vector")
|
||||
/ "provenance"
|
||||
/ f"{dataset_id}_source_{safe_filename}"
|
||||
)
|
||||
return StorageService.persist_file_from_path(
|
||||
str(evidence_path),
|
||||
source_path,
|
||||
original_filename=safe_filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _record_vector_source_evidence(
|
||||
cls,
|
||||
@@ -853,6 +877,52 @@ class DatasetService:
|
||||
raise AppError(code="INVALID_UPLOAD", message="Missing file name", status_code=400)
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
async def _stage_upload(
|
||||
*,
|
||||
project_id: UUID,
|
||||
dataset_id: uuid.UUID,
|
||||
dataset_type: str,
|
||||
filename: str,
|
||||
file: UploadFile,
|
||||
) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
max_upload_mb = int(settings.max_upload_mb)
|
||||
if DatasetService._canonical_dataset_type(dataset_type) == "vector":
|
||||
max_upload_mb = min(max_upload_mb, int(settings.max_in_memory_vector_mb))
|
||||
return await StorageService.persist_upload_file(
|
||||
project_id=str(project_id),
|
||||
dataset_id=str(dataset_id),
|
||||
dataset_type=dataset_type,
|
||||
original_filename=filename,
|
||||
upload=file,
|
||||
content_type=file.content_type,
|
||||
max_bytes=max_upload_mb * 1024 * 1024,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_staged_vector_bytes(storage_info: dict[str, Any]) -> bytes:
|
||||
settings = get_settings()
|
||||
max_bytes = min(
|
||||
int(settings.max_upload_mb),
|
||||
int(settings.max_in_memory_vector_mb),
|
||||
) * 1024 * 1024
|
||||
path = Path(str(storage_info["storage_path"]))
|
||||
with path.open("rb") as stream:
|
||||
content = stream.read(max_bytes + 1)
|
||||
if len(content) > max_bytes:
|
||||
StorageService.remove_dataset_file(str(path))
|
||||
raise AppError(
|
||||
code="UPLOAD_TOO_LARGE",
|
||||
message="Vector upload exceeds the bounded in-memory parsing limit.",
|
||||
details={
|
||||
"max_bytes": max_bytes,
|
||||
"max_in_memory_vector_mb": max_bytes // (1024 * 1024),
|
||||
},
|
||||
status_code=413,
|
||||
)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def list_datasets(db: Session, project_id: UUID, limit: int = 50, offset: int = 0) -> tuple[list[DatasetCreateResponse], int]:
|
||||
total = db.query(Dataset).filter(Dataset.project_id == project_id).count()
|
||||
@@ -1135,15 +1205,15 @@ class DatasetService:
|
||||
status_code=415,
|
||||
)
|
||||
|
||||
raw = await file.read()
|
||||
storage_info = StorageService.persist_dataset_file(
|
||||
project_id=str(project_id),
|
||||
dataset_id=str(dataset_id := uuid.uuid4()),
|
||||
dataset_id = uuid.uuid4()
|
||||
storage_info = await DatasetService._stage_upload(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
dataset_type=canonical_type,
|
||||
original_filename=filename,
|
||||
content=raw,
|
||||
content_type=file.content_type,
|
||||
filename=filename,
|
||||
file=file,
|
||||
)
|
||||
raw = DatasetService._read_staged_vector_bytes(storage_info) if canonical_type == "vector" else None
|
||||
|
||||
metadata: dict[str, Any] = {}
|
||||
vector_payload: dict[str, Any] | None = None
|
||||
@@ -1151,6 +1221,7 @@ class DatasetService:
|
||||
try:
|
||||
status = "validating"
|
||||
if canonical_type == "vector":
|
||||
assert raw is not None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
@@ -1319,8 +1390,15 @@ class DatasetService:
|
||||
temporal_granularity=temporal_granularity,
|
||||
source_version=source_version,
|
||||
)
|
||||
raw = await file.read()
|
||||
checksum_sha256 = StorageService.calculate_checksum_sha256(raw)
|
||||
dataset_id = uuid.uuid4()
|
||||
storage_info = await DatasetService._stage_upload(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
dataset_type=canonical_type,
|
||||
filename=filename,
|
||||
file=file,
|
||||
)
|
||||
checksum_sha256 = storage_info["checksum_sha256"]
|
||||
ingest_key = DatasetService._ingest_key(
|
||||
project_id=project_id,
|
||||
source_key="manual",
|
||||
@@ -1333,6 +1411,7 @@ class DatasetService:
|
||||
)
|
||||
existing = DatasetService._find_existing_ingest(db, project_id, ingest_key)
|
||||
if existing is not None:
|
||||
StorageService.remove_dataset_file(storage_info["storage_path"])
|
||||
return DatasetService._to_response(existing)
|
||||
|
||||
raw_source_metadata = dict(source_metadata or {})
|
||||
@@ -1361,8 +1440,7 @@ class DatasetService:
|
||||
}
|
||||
)
|
||||
|
||||
dataset_id = uuid.uuid4()
|
||||
storage_info: dict[str, Any] | None = None
|
||||
raw = DatasetService._read_staged_vector_bytes(storage_info) if canonical_type == "vector" else None
|
||||
storage_content = raw
|
||||
source_evidence: dict[str, Any] | None = None
|
||||
imported_at = datetime.now(timezone.utc)
|
||||
@@ -1372,6 +1450,7 @@ class DatasetService:
|
||||
parser_error: tuple[str, str] | None = None
|
||||
try:
|
||||
if canonical_type == "vector":
|
||||
assert raw is not None
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except UnicodeDecodeError as exc:
|
||||
@@ -1394,22 +1473,22 @@ class DatasetService:
|
||||
)
|
||||
if DatasetService._vector_storage_requires_canonicalization(source_crs):
|
||||
storage_content = DatasetService._canonical_vector_storage_bytes(canonical_vector_payload)
|
||||
source_evidence = DatasetService._persist_vector_source_evidence(
|
||||
source_evidence = DatasetService._persist_vector_source_evidence_from_path(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
original_filename=filename,
|
||||
content=raw,
|
||||
source_path=storage_info["storage_path"],
|
||||
content_type=file.content_type,
|
||||
)
|
||||
storage_info = StorageService.persist_dataset_file(
|
||||
project_id=str(project_id),
|
||||
dataset_id=str(dataset_id),
|
||||
dataset_type=canonical_type,
|
||||
original_filename=filename,
|
||||
content=storage_content,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
else:
|
||||
storage_info = StorageService.persist_dataset_file(
|
||||
project_id=str(project_id),
|
||||
dataset_id=str(dataset_id),
|
||||
dataset_type=canonical_type,
|
||||
original_filename=filename,
|
||||
content=raw,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
metadata = extract_raster_metadata(storage_info["storage_path"])
|
||||
metadata["dataset_type"] = "raster"
|
||||
source_crs = metadata.get("crs")
|
||||
@@ -1422,16 +1501,7 @@ class DatasetService:
|
||||
"processing_code": code,
|
||||
}
|
||||
|
||||
if storage_info is None:
|
||||
storage_info = StorageService.persist_dataset_file(
|
||||
project_id=str(project_id),
|
||||
dataset_id=str(dataset_id),
|
||||
dataset_type=canonical_type,
|
||||
original_filename=filename,
|
||||
content=storage_content,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
computed_storage_checksum_sha256 = StorageService.calculate_checksum_sha256(storage_content)
|
||||
computed_storage_checksum_sha256 = storage_info["checksum_sha256"]
|
||||
if source_evidence is not None:
|
||||
resolved_source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS
|
||||
DatasetService._record_vector_source_evidence(
|
||||
@@ -1502,7 +1572,7 @@ class DatasetService:
|
||||
feature_collection=canonical_vector_payload or {"type": "FeatureCollection", "features": []},
|
||||
checksum_sha256=storage_info["checksum_sha256"],
|
||||
computed_checksum_sha256=computed_storage_checksum_sha256,
|
||||
content=storage_content,
|
||||
content=None,
|
||||
source_registry_id=str(source_registry.id),
|
||||
source_snapshot_id=str(source_snapshot.id),
|
||||
imported_at=imported_at,
|
||||
@@ -1541,8 +1611,8 @@ class DatasetService:
|
||||
bounds=DatasetService._extract_raster_bounds_json(metadata),
|
||||
resolution=resolution,
|
||||
checksum_sha256=storage_info["checksum_sha256"],
|
||||
computed_checksum_sha256=checksum_sha256,
|
||||
content=raw,
|
||||
computed_checksum_sha256=storage_info["checksum_sha256"],
|
||||
content=None,
|
||||
source_registry_id=str(source_registry.id),
|
||||
source_snapshot_id=str(source_snapshot.id),
|
||||
imported_at=imported_at,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.services.storage_service import StorageService
|
||||
from app.services.quality_service import QualityService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
||||
from app.services.temporal_compatibility_service import TemporalCompatibilityService
|
||||
from app.services.tile_manifest_service import TileManifestService
|
||||
from app.services.yolo_adapter import YoloDetectionAdapter
|
||||
|
||||
|
||||
@@ -968,6 +969,18 @@ class DetectionService:
|
||||
yolo_adapter_class: Type[YoloDetectionAdapter],
|
||||
) -> tuple[list[Detection], dict[str, Any]]:
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
|
||||
dataset = db.get(Dataset, dataset_id)
|
||||
if dataset is None:
|
||||
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||
manifest_binding = TileManifestService.validate_for_inference(
|
||||
db,
|
||||
dataset,
|
||||
manifest,
|
||||
manifest_path=tile_manifest_path or "",
|
||||
settings=settings,
|
||||
error_prefix="DETECTION",
|
||||
)
|
||||
DetectionService._attach_tile_manifest_binding(analysis_run, job, manifest_binding)
|
||||
model_path = Path(settings.yolo_model_path or "").expanduser()
|
||||
runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime(
|
||||
db=db,
|
||||
@@ -1075,9 +1088,23 @@ class DetectionService:
|
||||
"tile_edge_truncated_count": len(candidates) - len(edge_filtered_candidates),
|
||||
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
|
||||
"containment_suppression_threshold": float(settings.yolo_containment_nms_threshold),
|
||||
"tile_manifest_binding": manifest_binding,
|
||||
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _attach_tile_manifest_binding(
|
||||
analysis_run: AnalysisRun,
|
||||
job: Job,
|
||||
binding: dict[str, Any],
|
||||
) -> None:
|
||||
analysis_parameters = dict(analysis_run.parameters_json or {})
|
||||
analysis_parameters["tile_manifest_binding"] = dict(binding)
|
||||
analysis_run.parameters_json = analysis_parameters
|
||||
job_parameters = dict(job.parameters_json or {})
|
||||
job_parameters["tile_manifest_binding"] = dict(binding)
|
||||
job.parameters_json = job_parameters
|
||||
|
||||
@staticmethod
|
||||
def _attach_runtime_model_provenance(
|
||||
analysis_run: AnalysisRun,
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
@@ -232,7 +232,6 @@ class FloodHazardAnalysisService:
|
||||
try:
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.features import geometry_mask
|
||||
from rasterio.mask import mask
|
||||
except ImportError as exc:
|
||||
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for flood-hazard analysis", status_code=503) from exc
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
@@ -6,11 +6,12 @@ import ssl
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.schemas.bathymetry import BathymetrySourceProbeRead
|
||||
from app.services.outbound_request_guard import guarded_opener
|
||||
|
||||
|
||||
class MdkBathymetryProbeService:
|
||||
|
||||
@@ -79,11 +79,15 @@ class ModelAssetCatalogService:
|
||||
size_bytes=path.stat().st_size,
|
||||
sha256=ModelAssetCatalogService._sha256(path),
|
||||
active=active_model_path == resolved_path,
|
||||
status="approved" if active_model_path == resolved_path else "available",
|
||||
runtime_available=True,
|
||||
runtime_status="active" if active_model_path == resolved_path else "available",
|
||||
governed_validation_status="not_verified_by_catalog",
|
||||
promotion_status="not_verified_by_catalog",
|
||||
status="runtime_active" if active_model_path == resolved_path else "runtime_available",
|
||||
limitation_message=(
|
||||
"Approved local runtime model asset. GeoIntel will not download or mutate model weights."
|
||||
"Active local runtime model asset. Runtime selection is not evidence of governed validation or promotion."
|
||||
if active_model_path == resolved_path
|
||||
else "Local development model asset. Configure it explicitly before production use."
|
||||
else "Local runtime model asset. Governed validation and promotion are not established by this catalog."
|
||||
),
|
||||
will_download_models=False,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models import Area, Dataset, DatasetVersion
|
||||
from app.services.derived_dataset_governance_service import DerivedDatasetGovernanceService
|
||||
from app.services.raster_service import extract_raster_metadata
|
||||
from app.services.storage_service import StorageService
|
||||
from app.services.tile_manifest_service import TileManifestService, canonical_manifest_json
|
||||
|
||||
|
||||
def _import_rasterio():
|
||||
@@ -70,6 +71,39 @@ class RasterOperationsService:
|
||||
raise AppError(code="DATASET_FILE_MISSING", message="Stored raster file missing", status_code=404)
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def _validate_storage_checksum(dataset: Dataset) -> None:
|
||||
"""Refuse tiling pixels that no longer match the governed Dataset row."""
|
||||
|
||||
expected_checksum = str(dataset.checksum_sha256 or "").strip().lower()
|
||||
governed_artifact = bool(getattr(dataset, "data_contract_key", None))
|
||||
valid_checksum = len(expected_checksum) == 64 and all(character in "0123456789abcdef" for character in expected_checksum)
|
||||
if not valid_checksum:
|
||||
if expected_checksum or governed_artifact:
|
||||
raise AppError(
|
||||
code="DATASET_STORAGE_CHECKSUM_UNVERIFIABLE",
|
||||
message="Governed raster storage requires a valid SHA-256 checksum before tiling.",
|
||||
details={"checksum_sha256": dataset.checksum_sha256},
|
||||
status_code=409,
|
||||
)
|
||||
return
|
||||
|
||||
digest = sha256()
|
||||
with Path(str(dataset.storage_path)).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
actual_checksum = digest.hexdigest()
|
||||
if actual_checksum != expected_checksum:
|
||||
raise AppError(
|
||||
code="DATASET_STORAGE_CHECKSUM_MISMATCH",
|
||||
message="Raster dataset storage no longer matches its validated checksum.",
|
||||
details={
|
||||
"expected_checksum_sha256": expected_checksum,
|
||||
"actual_checksum_sha256": actual_checksum,
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _raster_dependencies() -> tuple[Any, Any]:
|
||||
try:
|
||||
@@ -970,14 +1004,17 @@ class RasterOperationsService:
|
||||
tile_size: int = 512,
|
||||
overlap: int = 64,
|
||||
output_name: str | None = None,
|
||||
max_tiles: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
RasterOperationsService._validate_tile_request(tile_size=tile_size, overlap=overlap)
|
||||
if max_tiles is not None and max_tiles <= 0:
|
||||
raise AppError(code="INVALID_PARAMETERS", message="max_tiles must be positive", status_code=400)
|
||||
dataset = RasterOperationsService._load_dataset(db, dataset_id)
|
||||
RasterOperationsService._validate_storage_checksum(dataset)
|
||||
rasterio, _ = RasterOperationsService._raster_dependencies()
|
||||
|
||||
tile_set_id = str(uuid.uuid4())
|
||||
tile_root = StorageService.raster_tiles_root(str(dataset.project_id), str(dataset.id), tile_set_id)
|
||||
tile_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest_tiles: list[dict[str, Any]] = []
|
||||
tile_paths: list[str] = []
|
||||
@@ -997,9 +1034,23 @@ class RasterOperationsService:
|
||||
source_width = int(source.width)
|
||||
source_height = int(source.height)
|
||||
step = max(1, tile_size - overlap)
|
||||
x_offsets = RasterOperationsService._tile_offsets(source_width, tile_size, step)
|
||||
y_offsets = RasterOperationsService._tile_offsets(source_height, tile_size, step)
|
||||
expected_tile_count = len(x_offsets) * len(y_offsets)
|
||||
if max_tiles is not None and expected_tile_count > max_tiles:
|
||||
raise AppError(
|
||||
code="RASTER_TILE_LIMIT_EXCEEDED",
|
||||
message="Raster tile generation exceeds the guest analysis limit.",
|
||||
details={
|
||||
"expected_tile_count": expected_tile_count,
|
||||
"max_tiles": max_tiles,
|
||||
},
|
||||
status_code=422,
|
||||
)
|
||||
tile_root.mkdir(parents=True, exist_ok=True)
|
||||
tile_index = 0
|
||||
for yoff in range(0, source_height, step):
|
||||
for xoff in range(0, source_width, step):
|
||||
for yoff in y_offsets:
|
||||
for xoff in x_offsets:
|
||||
tile_width = min(tile_size, source_width - xoff)
|
||||
tile_height = min(tile_size, source_height - yoff)
|
||||
if tile_width <= 0 or tile_height <= 0:
|
||||
@@ -1022,6 +1073,7 @@ class RasterOperationsService:
|
||||
tile_dest.write(tile_data)
|
||||
|
||||
tile_paths.append(str(tile_path))
|
||||
tile_integrity = TileManifestService.tile_integrity(tile_path)
|
||||
manifest_tiles.append(
|
||||
{
|
||||
"path": str(tile_path),
|
||||
@@ -1030,6 +1082,7 @@ class RasterOperationsService:
|
||||
"transform": [float(item) for item in transform.to_gdal()],
|
||||
"crs": source_crs,
|
||||
"index": tile_index,
|
||||
**tile_integrity,
|
||||
},
|
||||
)
|
||||
tile_index += 1
|
||||
@@ -1044,9 +1097,8 @@ class RasterOperationsService:
|
||||
bounds = source_metadata.get("bounds", [0.0, 0.0, 0.0, 0.0])
|
||||
manifest_crs = source_crs or source_metadata.get("crs") or dataset.crs
|
||||
manifest_payload = {
|
||||
**TileManifestService.dataset_binding(db, dataset),
|
||||
"tile_set_id": tile_set_id,
|
||||
"source_dataset_id": str(dataset.id),
|
||||
"source_raster_id": str(dataset.id),
|
||||
"crs": manifest_crs,
|
||||
"source_crs": manifest_crs,
|
||||
"dataset_crs": dataset.crs,
|
||||
@@ -1066,7 +1118,7 @@ class RasterOperationsService:
|
||||
"tile_server": None,
|
||||
}
|
||||
manifest_path = tile_root / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest_payload), encoding="utf-8")
|
||||
manifest_path.write_text(canonical_manifest_json(manifest_payload), encoding="utf-8")
|
||||
|
||||
return {
|
||||
"dataset_id": str(dataset.id),
|
||||
@@ -1079,3 +1131,17 @@ class RasterOperationsService:
|
||||
"count": len(manifest_tiles),
|
||||
"manifest": manifest_payload,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _tile_offsets(dimension: int, tile_size: int, step: int) -> list[int]:
|
||||
"""Return full-tile starts plus one unique edge-aligned final start."""
|
||||
|
||||
if dimension <= 0 or tile_size <= 0 or step <= 0:
|
||||
raise AppError(code="INVALID_PARAMETERS", message="Raster tile dimensions must be positive", status_code=400)
|
||||
if dimension <= tile_size:
|
||||
return [0]
|
||||
final_start = dimension - tile_size
|
||||
offsets = list(range(0, final_start + 1, step))
|
||||
if offsets[-1] != final_start:
|
||||
offsets.append(final_start)
|
||||
return offsets
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import mapping
|
||||
from shapely.ops import transform as shapely_transform
|
||||
|
||||
from app.core.errors import AppError
|
||||
@@ -116,7 +115,6 @@ class RasterPartitionAnalysisService:
|
||||
try:
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.features import geometry_mask
|
||||
from rasterio.merge import merge
|
||||
except ImportError as exc:
|
||||
raise AppError(
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.services.segmentation_adapter import (
|
||||
SamSegmentationAdapter,
|
||||
YoloSegmentationAdapter,
|
||||
)
|
||||
from app.services.tile_manifest_service import TileManifestService
|
||||
|
||||
|
||||
class SegmentationService:
|
||||
@@ -749,6 +750,18 @@ class SegmentationService:
|
||||
sam_adapter_class: type[SamSegmentationAdapter],
|
||||
) -> tuple[list[Segmentation], dict[str, Any]]:
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
|
||||
dataset = db.get(Dataset, dataset_id)
|
||||
if dataset is None:
|
||||
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||
manifest_binding = TileManifestService.validate_for_inference(
|
||||
db,
|
||||
dataset,
|
||||
manifest,
|
||||
manifest_path=tile_manifest_path or "",
|
||||
settings=settings,
|
||||
error_prefix="SEGMENTATION",
|
||||
)
|
||||
DetectionService._attach_tile_manifest_binding(analysis_run, job, manifest_binding)
|
||||
if model_name == settings.sam_model_id:
|
||||
model_path = Path(settings.sam_model_path or "").expanduser()
|
||||
allowed_frameworks = ("ultralytics/sam", "sam", "ultralytics", "pytorch")
|
||||
@@ -861,6 +874,7 @@ class SegmentationService:
|
||||
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
|
||||
"containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold),
|
||||
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
|
||||
"tile_manifest_binding": manifest_binding,
|
||||
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from threading import Lock
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from app.core.errors import AppError
|
||||
|
||||
|
||||
class StorageService:
|
||||
UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024
|
||||
|
||||
@staticmethod
|
||||
def _base_dir() -> Path:
|
||||
return Path(get_settings().storage_root).resolve()
|
||||
@@ -143,6 +145,75 @@ class StorageService:
|
||||
}
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
async def persist_upload_file(
|
||||
*,
|
||||
project_id: str,
|
||||
dataset_id: str,
|
||||
dataset_type: str,
|
||||
original_filename: str,
|
||||
upload: Any,
|
||||
content_type: str | None,
|
||||
max_bytes: int,
|
||||
chunk_size: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stream an UploadFile to governed storage with a hard byte limit.
|
||||
|
||||
The reverse proxy limit is defense in depth. This backend boundary is
|
||||
authoritative as direct/loopback requests can bypass that proxy.
|
||||
"""
|
||||
|
||||
if max_bytes <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
resolved_chunk_size = chunk_size or StorageService.UPLOAD_CHUNK_SIZE
|
||||
if resolved_chunk_size <= 0:
|
||||
raise ValueError("chunk_size must be positive")
|
||||
normalized_type = StorageService.normalize_dataset_type(dataset_type)
|
||||
file_path = Path(
|
||||
StorageService.dataset_file_path(
|
||||
project_id,
|
||||
dataset_id,
|
||||
normalized_type,
|
||||
original_filename,
|
||||
)
|
||||
)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
size_bytes = 0
|
||||
try:
|
||||
with file_path.open("wb") as stream:
|
||||
while True:
|
||||
chunk = await upload.read(resolved_chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
size_bytes += len(chunk)
|
||||
if size_bytes > max_bytes:
|
||||
raise AppError(
|
||||
code="UPLOAD_TOO_LARGE",
|
||||
message="Upload exceeds the configured backend size limit.",
|
||||
details={
|
||||
"max_bytes": max_bytes,
|
||||
"max_upload_mb": max_bytes // (1024 * 1024),
|
||||
},
|
||||
status_code=413,
|
||||
)
|
||||
stream.write(chunk)
|
||||
digest.update(chunk)
|
||||
except Exception:
|
||||
file_path.unlink(missing_ok=True)
|
||||
parent = file_path.parent
|
||||
if parent.exists() and parent.is_dir() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
raise
|
||||
return {
|
||||
"original_filename": StorageService._safe_filename(original_filename),
|
||||
"stored_filename": file_path.name,
|
||||
"content_type": content_type or "application/octet-stream",
|
||||
"size_bytes": size_bytes,
|
||||
"checksum_sha256": digest.hexdigest(),
|
||||
"storage_path": str(file_path),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def persist_dataset_file_from_path(
|
||||
project_id: str,
|
||||
@@ -198,6 +269,34 @@ class StorageService:
|
||||
}
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def persist_file_from_path(
|
||||
storage_path: str,
|
||||
source_path: str | Path,
|
||||
original_filename: str,
|
||||
content_type: str | None,
|
||||
) -> dict[str, Any]:
|
||||
source = Path(source_path).resolve()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"Source artifact does not exist: {source}")
|
||||
target = Path(storage_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
size_bytes = 0
|
||||
with source.open("rb") as input_stream, target.open("wb") as output_stream:
|
||||
for chunk in iter(lambda: input_stream.read(StorageService.UPLOAD_CHUNK_SIZE), b""):
|
||||
output_stream.write(chunk)
|
||||
digest.update(chunk)
|
||||
size_bytes += len(chunk)
|
||||
return {
|
||||
"original_filename": StorageService._safe_filename(original_filename),
|
||||
"stored_filename": target.name,
|
||||
"content_type": content_type or "application/octet-stream",
|
||||
"size_bytes": size_bytes,
|
||||
"checksum_sha256": digest.hexdigest(),
|
||||
"storage_path": str(target),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def remove_dataset_file(path: str) -> None:
|
||||
target = Path(path)
|
||||
|
||||
@@ -111,7 +111,6 @@ class TerrainAnalysisService:
|
||||
try:
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.features import geometry_mask
|
||||
from rasterio.mask import mask
|
||||
except ImportError as exc:
|
||||
raise AppError(
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import UUID
|
||||
|
||||
from geoalchemy2.shape import to_shape
|
||||
@@ -407,7 +407,6 @@ class ThematicRasterAcquisitionService:
|
||||
if len(coverages) == 1:
|
||||
return coverages[0]
|
||||
try:
|
||||
import rasterio
|
||||
from rasterio.io import MemoryFile
|
||||
from rasterio.merge import merge
|
||||
except ImportError as exc:
|
||||
|
||||
@@ -96,7 +96,6 @@ class ThematicRasterAnalysisService:
|
||||
try:
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.features import geometry_mask
|
||||
from rasterio.mask import mask
|
||||
except ImportError as exc:
|
||||
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for thematic raster analysis", status_code=503) from exc
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from geoalchemy2.shape import to_shape
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import box
|
||||
from shapely.ops import transform as shapely_transform
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import Area, Dataset, DatasetVersion
|
||||
from app.services.storage_service import StorageService
|
||||
|
||||
|
||||
class TileManifestService:
|
||||
"""Versioned provenance and integrity contract for inference tile sets."""
|
||||
|
||||
CONTRACT_KEY = "geointel.raster.tile-manifest"
|
||||
CONTRACT_VERSION = "2.0.0"
|
||||
_BINDING_FIELDS = (
|
||||
"source_dataset_id",
|
||||
"source_dataset_checksum_sha256",
|
||||
"source_dataset_size_bytes",
|
||||
"source_registry_id",
|
||||
"source_snapshot_id",
|
||||
"source_snapshot_checksum_sha256",
|
||||
"data_contract_key",
|
||||
"data_contract_version",
|
||||
"source_version",
|
||||
"dataset_version_id",
|
||||
"dataset_version",
|
||||
"dataset_version_checksum_sha256",
|
||||
"source_area_id",
|
||||
"source_area_geometry_sha256",
|
||||
)
|
||||
_REQUIRED_INFERENCE_BINDING_FIELDS = (
|
||||
"source_dataset_checksum_sha256",
|
||||
"source_registry_id",
|
||||
"source_snapshot_id",
|
||||
"source_snapshot_checksum_sha256",
|
||||
"data_contract_key",
|
||||
"data_contract_version",
|
||||
"dataset_version_id",
|
||||
"dataset_version",
|
||||
"dataset_version_checksum_sha256",
|
||||
)
|
||||
_CHECKSUM_FIELDS = (
|
||||
"source_dataset_checksum_sha256",
|
||||
"source_snapshot_checksum_sha256",
|
||||
"dataset_version_checksum_sha256",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def file_sha256(path: str | Path) -> str:
|
||||
digest = sha256()
|
||||
with Path(path).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _latest_dataset_version(dataset: Dataset) -> DatasetVersion | None:
|
||||
versions = list(dataset.versions or [])
|
||||
if not versions:
|
||||
return None
|
||||
return max(versions, key=lambda item: (int(item.version or 0), str(item.id or "")))
|
||||
|
||||
@staticmethod
|
||||
def _source_snapshot_checksum(dataset: Dataset) -> str | None:
|
||||
snapshot = dataset.source_snapshot
|
||||
checksum = getattr(snapshot, "checksum_sha256", None) if snapshot is not None else None
|
||||
return str(checksum).lower() if checksum else None
|
||||
|
||||
@staticmethod
|
||||
def _area_geometry_binding(db, dataset: Dataset) -> tuple[str | None, str | None]:
|
||||
if dataset.area_id is None:
|
||||
return None, None
|
||||
area = db.get(Area, dataset.area_id)
|
||||
if area is None or area.geometry is None:
|
||||
return str(dataset.area_id), None
|
||||
geometry = to_shape(area.geometry)
|
||||
return str(dataset.area_id), sha256(geometry.wkb).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def dataset_binding(cls, db, dataset: Dataset) -> dict[str, Any]:
|
||||
version = cls._latest_dataset_version(dataset)
|
||||
area_id, area_geometry_sha256 = cls._area_geometry_binding(db, dataset)
|
||||
return {
|
||||
"manifest_contract_key": cls.CONTRACT_KEY,
|
||||
"manifest_contract_version": cls.CONTRACT_VERSION,
|
||||
"source_dataset_id": str(dataset.id),
|
||||
"source_raster_id": str(dataset.id),
|
||||
"source_dataset_checksum_sha256": (
|
||||
str(dataset.checksum_sha256).lower() if dataset.checksum_sha256 else None
|
||||
),
|
||||
"source_dataset_size_bytes": dataset.size_bytes,
|
||||
"source_registry_id": str(dataset.source_registry_id) if dataset.source_registry_id else None,
|
||||
"source_snapshot_id": str(dataset.source_snapshot_id) if dataset.source_snapshot_id else None,
|
||||
"source_snapshot_checksum_sha256": cls._source_snapshot_checksum(dataset),
|
||||
"data_contract_key": dataset.data_contract_key,
|
||||
"data_contract_version": dataset.data_contract_version,
|
||||
"source_version": dataset.source_version,
|
||||
"dataset_version_id": str(version.id) if version is not None and version.id else None,
|
||||
"dataset_version": int(version.version) if version is not None and version.version is not None else None,
|
||||
"dataset_version_checksum_sha256": (
|
||||
str(version.checksum_sha256).lower()
|
||||
if version is not None and version.checksum_sha256
|
||||
else None
|
||||
),
|
||||
"source_area_id": area_id,
|
||||
"source_area_geometry_sha256": area_geometry_sha256,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def tile_integrity(path: str | Path) -> dict[str, Any]:
|
||||
resolved = Path(path)
|
||||
return {
|
||||
"size_bytes": resolved.stat().st_size,
|
||||
"sha256": TileManifestService.file_sha256(resolved),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _error(
|
||||
error_prefix: str,
|
||||
suffix: str,
|
||||
message: str,
|
||||
*,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> AppError:
|
||||
return AppError(
|
||||
code=f"{error_prefix}_TILE_MANIFEST_{suffix}",
|
||||
message=message,
|
||||
details=details,
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bounds_values(value: Any) -> tuple[float, float, float, float] | None:
|
||||
if isinstance(value, dict):
|
||||
aliases = (
|
||||
("min_x", "min_y", "max_x", "max_y"),
|
||||
("minx", "miny", "maxx", "maxy"),
|
||||
("left", "bottom", "right", "top"),
|
||||
)
|
||||
selected = next(
|
||||
([value.get(key) for key in keys] for keys in aliases if all(key in value for key in keys)),
|
||||
None,
|
||||
)
|
||||
elif isinstance(value, (list, tuple)) and len(value) == 4:
|
||||
selected = list(value)
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
bounds = tuple(float(item) for item in selected) if selected is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if bounds is None or not all(isfinite(item) for item in bounds):
|
||||
return None
|
||||
if bounds[0] >= bounds[2] or bounds[1] >= bounds[3]:
|
||||
return None
|
||||
return bounds
|
||||
|
||||
@staticmethod
|
||||
def _to_epsg4326(bounds: tuple[float, float, float, float], raw_crs: Any):
|
||||
source_crs = CRS.from_user_input(raw_crs)
|
||||
geometry = box(*bounds)
|
||||
if not source_crs.equals(CRS.from_epsg(4326)):
|
||||
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
|
||||
geometry = shapely_transform(transformer.transform, geometry)
|
||||
if geometry.is_empty or not geometry.is_valid:
|
||||
raise ValueError("Bounds do not form a valid transformed geometry")
|
||||
if not all(isfinite(float(value)) for value in geometry.bounds):
|
||||
raise ValueError("Bounds transform to non-finite coordinates")
|
||||
return geometry
|
||||
|
||||
@classmethod
|
||||
def _manifest_coverage(cls, manifest: dict[str, Any], *, error_prefix: str):
|
||||
tiles = manifest.get("tiles")
|
||||
default_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs")
|
||||
parts = []
|
||||
for index, tile in enumerate(tiles if isinstance(tiles, list) else []):
|
||||
if not isinstance(tile, dict):
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Tile manifest entries must be objects with explicit spatial metadata.",
|
||||
details={"tile_index": index},
|
||||
)
|
||||
bounds = cls._bounds_values(tile.get("bounds"))
|
||||
raw_crs = tile.get("crs") or default_crs
|
||||
if bounds is None or not raw_crs:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Every inference tile requires finite bounds and an explicit CRS.",
|
||||
details={"tile_index": index},
|
||||
)
|
||||
try:
|
||||
parts.append(cls._to_epsg4326(bounds, raw_crs))
|
||||
except Exception as exc:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Inference tile bounds or CRS could not be normalized to EPSG:4326.",
|
||||
details={"tile_index": index, "reason": str(exc)},
|
||||
) from exc
|
||||
coverage = unary_union(parts)
|
||||
if coverage.is_empty or not coverage.is_valid:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Inference tile union is empty or invalid.",
|
||||
)
|
||||
min_x, min_y, max_x, max_y = coverage.bounds
|
||||
if min_x < -180 or min_y < -90 or max_x > 180 or max_y > 90:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Inference tile union falls outside EPSG:4326 bounds.",
|
||||
details={"bounds": list(coverage.bounds)},
|
||||
)
|
||||
return coverage
|
||||
|
||||
@classmethod
|
||||
def _validate_binding(cls, db, dataset: Dataset, manifest: dict[str, Any], *, error_prefix: str) -> dict[str, Any]:
|
||||
if (
|
||||
manifest.get("manifest_contract_key") != cls.CONTRACT_KEY
|
||||
or manifest.get("manifest_contract_version") != cls.CONTRACT_VERSION
|
||||
):
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"PROVENANCE_MISMATCH",
|
||||
"Inference requires a versioned GeoIntel tile-manifest contract.",
|
||||
details={
|
||||
"required_contract": f"{cls.CONTRACT_KEY}@{cls.CONTRACT_VERSION}",
|
||||
"manifest_contract": (
|
||||
f"{manifest.get('manifest_contract_key')}@{manifest.get('manifest_contract_version')}"
|
||||
),
|
||||
},
|
||||
)
|
||||
expected = cls.dataset_binding(db, dataset)
|
||||
missing = [
|
||||
field
|
||||
for field in cls._REQUIRED_INFERENCE_BINDING_FIELDS
|
||||
if expected.get(field) in {None, ""}
|
||||
]
|
||||
invalid_checksums = [
|
||||
field
|
||||
for field in cls._CHECKSUM_FIELDS
|
||||
if len(str(expected.get(field) or "")) != 64
|
||||
or any(character not in "0123456789abcdef" for character in str(expected.get(field) or "").lower())
|
||||
]
|
||||
if missing or invalid_checksums:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"PROVENANCE_MISMATCH",
|
||||
"The requested Dataset lacks complete immutable provenance for inference tiling.",
|
||||
details={
|
||||
"missing_fields": missing,
|
||||
"invalid_checksum_fields": invalid_checksums,
|
||||
},
|
||||
)
|
||||
manifest_dataset_id = manifest.get("source_dataset_id") or manifest.get("source_raster_id")
|
||||
if str(manifest_dataset_id or "") != expected["source_dataset_id"]:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"DATASET_MISMATCH",
|
||||
"Tile manifest belongs to a different raster Dataset.",
|
||||
details={
|
||||
"requested_dataset_id": expected["source_dataset_id"],
|
||||
"manifest_dataset_id": manifest_dataset_id,
|
||||
},
|
||||
)
|
||||
mismatches = {}
|
||||
for field in cls._BINDING_FIELDS:
|
||||
expected_value = expected.get(field)
|
||||
if expected_value is None or field == "source_dataset_id":
|
||||
continue
|
||||
observed_value = manifest.get(field)
|
||||
if str(observed_value) != str(expected_value):
|
||||
mismatches[field] = {"expected": expected_value, "observed": observed_value}
|
||||
if mismatches:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"PROVENANCE_MISMATCH",
|
||||
"Tile manifest provenance no longer matches the requested Dataset snapshot.",
|
||||
details={"mismatches": mismatches},
|
||||
)
|
||||
return expected
|
||||
|
||||
@classmethod
|
||||
def _validate_tile_files(
|
||||
cls,
|
||||
manifest: dict[str, Any],
|
||||
manifest_path: Path,
|
||||
*,
|
||||
settings,
|
||||
error_prefix: str,
|
||||
) -> list[str]:
|
||||
resolved_paths: list[str] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for index, tile in enumerate(manifest["tiles"]):
|
||||
raw_path = tile.get("path") if isinstance(tile, dict) else None
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"TILE_INTEGRITY_MISMATCH",
|
||||
"Every inference tile requires a path and immutable integrity evidence.",
|
||||
details={"tile_index": index},
|
||||
)
|
||||
candidate = Path(raw_path).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = manifest_path.parent / candidate
|
||||
candidate = StorageService.assert_within_storage_root(
|
||||
candidate,
|
||||
label="raster tile",
|
||||
settings=settings,
|
||||
)
|
||||
if not candidate.is_file():
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"TILE_INTEGRITY_MISMATCH",
|
||||
"An inference tile referenced by the manifest does not exist.",
|
||||
details={"tile_index": index, "tile_path": str(candidate)},
|
||||
)
|
||||
if candidate in seen_paths:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"TILE_INTEGRITY_MISMATCH",
|
||||
"A tile path occurs more than once in the inference manifest.",
|
||||
details={"tile_index": index, "tile_path": str(candidate)},
|
||||
)
|
||||
seen_paths.add(candidate)
|
||||
observed_size = candidate.stat().st_size
|
||||
expected_size = tile.get("size_bytes")
|
||||
expected_checksum = str(tile.get("sha256") or "").strip().lower()
|
||||
if expected_size != observed_size or len(expected_checksum) != 64:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"TILE_INTEGRITY_MISMATCH",
|
||||
"Tile size/checksum evidence is missing or no longer matches the staged file.",
|
||||
details={
|
||||
"tile_index": index,
|
||||
"expected_size_bytes": expected_size,
|
||||
"observed_size_bytes": observed_size,
|
||||
},
|
||||
)
|
||||
observed_checksum = cls.file_sha256(candidate)
|
||||
if observed_checksum != expected_checksum:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"TILE_INTEGRITY_MISMATCH",
|
||||
"Tile checksum no longer matches the immutable manifest evidence.",
|
||||
details={
|
||||
"tile_index": index,
|
||||
"expected_sha256": expected_checksum,
|
||||
"observed_sha256": observed_checksum,
|
||||
},
|
||||
)
|
||||
resolved_paths.append(str(candidate))
|
||||
declared_count = manifest.get("count")
|
||||
if declared_count != len(resolved_paths):
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"TILE_INTEGRITY_MISMATCH",
|
||||
"Tile manifest count does not match its tile records.",
|
||||
details={"declared_count": declared_count, "tile_count": len(resolved_paths)},
|
||||
)
|
||||
return resolved_paths
|
||||
|
||||
@classmethod
|
||||
def _validate_scope(
|
||||
cls,
|
||||
db,
|
||||
dataset: Dataset,
|
||||
manifest: dict[str, Any],
|
||||
coverage,
|
||||
*,
|
||||
error_prefix: str,
|
||||
) -> None:
|
||||
manifest_bounds = cls._bounds_values(manifest.get("bounds"))
|
||||
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs")
|
||||
dataset_bounds = cls._bounds_values(dataset.bounds_json)
|
||||
if dataset_bounds is None and isinstance(dataset.metadata_json, dict):
|
||||
dataset_bounds = cls._bounds_values(
|
||||
dataset.metadata_json.get("bounds_json") or dataset.metadata_json.get("bounds")
|
||||
)
|
||||
if manifest_bounds is None or not manifest_crs or dataset_bounds is None or not dataset.crs:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Dataset and tile manifest require explicit CRS and finite bounds for inference.",
|
||||
)
|
||||
try:
|
||||
manifest_extent = cls._to_epsg4326(manifest_bounds, manifest_crs)
|
||||
dataset_extent = cls._to_epsg4326(dataset_bounds, dataset.crs)
|
||||
except Exception as exc:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Dataset or manifest bounds could not be normalized to EPSG:4326.",
|
||||
details={"reason": str(exc)},
|
||||
) from exc
|
||||
tolerance = max(dataset_extent.bounds[2] - dataset_extent.bounds[0], dataset_extent.bounds[3] - dataset_extent.bounds[1]) * 1e-7 + 1e-10
|
||||
if not manifest_extent.buffer(tolerance).covers(coverage):
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Tile union exceeds the extent declared by its manifest.",
|
||||
details={"tile_union_bounds": list(coverage.bounds), "manifest_bounds": list(manifest_extent.bounds)},
|
||||
)
|
||||
if not dataset_extent.buffer(tolerance).covers(coverage):
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Tile union exceeds the persisted Dataset extent.",
|
||||
details={"tile_union_bounds": list(coverage.bounds), "dataset_bounds": list(dataset_extent.bounds)},
|
||||
)
|
||||
if dataset.area_id is not None:
|
||||
area = db.get(Area, dataset.area_id)
|
||||
if area is None or area.geometry is None:
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Dataset references an Area that is unavailable for inference-scope validation.",
|
||||
details={"area_id": str(dataset.area_id)},
|
||||
)
|
||||
area_geometry = to_shape(area.geometry)
|
||||
if area_geometry.is_empty or not area_geometry.is_valid or not coverage.intersects(area_geometry):
|
||||
raise cls._error(
|
||||
error_prefix,
|
||||
"SCOPE_MISMATCH",
|
||||
"Tile union does not overlap the persisted Dataset Area.",
|
||||
details={"area_id": str(dataset.area_id), "tile_union_bounds": list(coverage.bounds)},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_for_inference(
|
||||
cls,
|
||||
db,
|
||||
dataset: Dataset,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
manifest_path: str | Path,
|
||||
settings,
|
||||
error_prefix: str,
|
||||
) -> dict[str, Any]:
|
||||
resolved_manifest_path = StorageService.assert_within_storage_root(
|
||||
manifest_path,
|
||||
label="tile manifest",
|
||||
settings=settings,
|
||||
)
|
||||
expected = cls._validate_binding(db, dataset, manifest, error_prefix=error_prefix)
|
||||
resolved_paths = cls._validate_tile_files(
|
||||
manifest,
|
||||
resolved_manifest_path,
|
||||
settings=settings,
|
||||
error_prefix=error_prefix,
|
||||
)
|
||||
coverage = cls._manifest_coverage(manifest, error_prefix=error_prefix)
|
||||
cls._validate_scope(db, dataset, manifest, coverage, error_prefix=error_prefix)
|
||||
return {
|
||||
"manifest_contract_key": cls.CONTRACT_KEY,
|
||||
"manifest_contract_version": cls.CONTRACT_VERSION,
|
||||
"manifest_path": str(resolved_manifest_path),
|
||||
"manifest_sha256": cls.file_sha256(resolved_manifest_path),
|
||||
"source_dataset_id": expected["source_dataset_id"],
|
||||
"source_dataset_checksum_sha256": expected.get("source_dataset_checksum_sha256"),
|
||||
"source_snapshot_id": expected.get("source_snapshot_id"),
|
||||
"dataset_version_id": expected.get("dataset_version_id"),
|
||||
"source_area_id": expected.get("source_area_id"),
|
||||
"tile_count": len(resolved_paths),
|
||||
"tile_union_bounds_epsg4326": [float(value) for value in coverage.bounds],
|
||||
}
|
||||
|
||||
|
||||
def canonical_manifest_json(payload: dict[str, Any]) -> str:
|
||||
"""Stable serializer shared by the writer and manifest-hash tests."""
|
||||
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
@@ -1,43 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite
|
||||
from numbers import Real
|
||||
from typing import Any
|
||||
|
||||
from pyproj import Transformer
|
||||
from shapely import force_2d
|
||||
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon, box, shape
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely import force_2d, get_coordinates
|
||||
from shapely.geometry import MultiPolygon, box, shape
|
||||
from shapely.ops import transform
|
||||
from shapely.validation import make_valid
|
||||
|
||||
|
||||
# This is a deliberately broad guard envelope around Belgium and the Belgian
|
||||
# North Sea. Exact legal/regional clipping remains the responsibility of the
|
||||
# persisted coverage Areas; this boundary prevents an AOI with valid-looking
|
||||
# but globally misplaced coordinates from entering the workbench.
|
||||
BELGIUM_AND_NORTH_SEA_GUARD_BOUNDS = (1.5, 48.5, 7.5, 52.5)
|
||||
|
||||
|
||||
def _raw_coordinates_are_finite(value: Any) -> bool:
|
||||
if isinstance(value, (list, tuple)):
|
||||
return bool(value) and all(_raw_coordinates_are_finite(item) for item in value)
|
||||
return isinstance(value, Real) and not isinstance(value, bool) and isfinite(float(value))
|
||||
|
||||
|
||||
def normalize_to_multipolygon(raw_geometry: dict[str, Any]) -> MultiPolygon:
|
||||
geom = force_2d(shape(raw_geometry))
|
||||
if isinstance(raw_geometry, dict) and "coordinates" in raw_geometry:
|
||||
if not _raw_coordinates_are_finite(raw_geometry["coordinates"]):
|
||||
raise ValueError("Geometry coordinates must be finite numbers")
|
||||
try:
|
||||
geom = force_2d(shape(raw_geometry))
|
||||
except Exception as exc:
|
||||
raise ValueError("Geometry is not valid GeoJSON") from exc
|
||||
coordinates = get_coordinates(geom, include_z=False)
|
||||
if coordinates.size == 0 or not all(isfinite(float(value)) for row in coordinates for value in row):
|
||||
raise ValueError("Geometry coordinates must be finite numbers")
|
||||
if geom.is_empty:
|
||||
raise ValueError("Geometry is empty")
|
||||
|
||||
if not geom.is_valid:
|
||||
geom = make_valid(geom)
|
||||
|
||||
if not geom.is_valid:
|
||||
raise ValueError("Geometry is invalid and could not be repaired")
|
||||
raise ValueError("Geometry is invalid")
|
||||
|
||||
if geom.geom_type == "Polygon":
|
||||
return MultiPolygon([geom])
|
||||
if geom.geom_type == "MultiPolygon":
|
||||
return MultiPolygon(geom.geoms)
|
||||
if isinstance(geom, GeometryCollection):
|
||||
polygons = [g for g in geom.geoms if isinstance(g, Polygon)]
|
||||
multipolygons = [g for g in geom.geoms if g.geom_type == "MultiPolygon"]
|
||||
if not polygons and not multipolygons:
|
||||
raise ValueError("Only polygon geometries are supported for AOI")
|
||||
normalized = []
|
||||
normalized.extend(polygons)
|
||||
for mp in multipolygons:
|
||||
normalized.extend(mp.geoms)
|
||||
return MultiPolygon(normalized)
|
||||
|
||||
raise ValueError("Only Polygon or MultiPolygon geometries are accepted")
|
||||
|
||||
|
||||
def normalize_area_to_epsg4326(
|
||||
raw_geometry: dict[str, Any],
|
||||
source_crs: str,
|
||||
) -> tuple[MultiPolygon, str]:
|
||||
"""Validate an AOI and normalize its declared CRS to canonical WGS84.
|
||||
|
||||
The returned CRS string preserves the caller's declaration for provenance;
|
||||
the returned geometry is always finite, polygonal and stored as EPSG:4326.
|
||||
"""
|
||||
|
||||
declared_crs = str(source_crs or "").strip()
|
||||
if not declared_crs:
|
||||
raise ValueError("Area CRS is required")
|
||||
try:
|
||||
parsed_crs = CRS.from_user_input(declared_crs)
|
||||
except Exception as exc:
|
||||
raise ValueError("Area CRS is unknown or invalid") from exc
|
||||
if not (parsed_crs.is_geographic or parsed_crs.is_projected):
|
||||
raise ValueError("Area CRS must be a geographic or projected two-dimensional CRS")
|
||||
if len(parsed_crs.axis_info) != 2:
|
||||
raise ValueError("Area CRS must have exactly two spatial axes")
|
||||
|
||||
geometry = normalize_to_multipolygon(raw_geometry)
|
||||
target_crs = CRS.from_epsg(4326)
|
||||
if not parsed_crs.equals(target_crs):
|
||||
try:
|
||||
transformer = Transformer.from_crs(parsed_crs, target_crs, always_xy=True)
|
||||
geometry = normalize_to_multipolygon(
|
||||
transform(transformer.transform, geometry).__geo_interface__
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError("Area geometry could not be transformed to EPSG:4326") from exc
|
||||
|
||||
min_x, min_y, max_x, max_y = geometry.bounds
|
||||
if not all(isfinite(value) for value in (min_x, min_y, max_x, max_y)):
|
||||
raise ValueError("Transformed area geometry contains non-finite coordinates")
|
||||
world_bounds = (-180.0, -90.0, 180.0, 90.0)
|
||||
if min_x < world_bounds[0] or min_y < world_bounds[1] or max_x > world_bounds[2] or max_y > world_bounds[3]:
|
||||
raise ValueError("Transformed area geometry falls outside the EPSG:4326 coordinate domain")
|
||||
guard = box(*BELGIUM_AND_NORTH_SEA_GUARD_BOUNDS)
|
||||
if not guard.intersects(geometry):
|
||||
raise ValueError("Area geometry falls outside Belgium and the Belgian North Sea workbench domain")
|
||||
if not guard.covers(geometry):
|
||||
raise ValueError("Area geometry must remain within the Belgium and Belgian North Sea workbench domain")
|
||||
return geometry, declared_crs
|
||||
|
||||
|
||||
def area_bounds_multipolygon(geom: MultiPolygon):
|
||||
return {
|
||||
"min_x": float(geom.bounds[0]),
|
||||
@@ -52,7 +110,10 @@ def area_m2(geom: MultiPolygon) -> float:
|
||||
Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True).transform,
|
||||
geom,
|
||||
)
|
||||
return float(projected.area)
|
||||
result = float(projected.area)
|
||||
if not isfinite(result) or result <= 0:
|
||||
raise ValueError("Area geometry must have a finite positive surface")
|
||||
return result
|
||||
|
||||
|
||||
def geometry_bbox_polygon(geom: MultiPolygon):
|
||||
|
||||
@@ -6,7 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.112.0",
|
||||
"starlette>=0.46.0,<1.0.0",
|
||||
"starlette>=1.3.1,<2.0.0",
|
||||
"uvicorn[standard]>=0.30.6",
|
||||
"SQLAlchemy>=2.0.34",
|
||||
"psycopg[binary]>=3.2.1",
|
||||
@@ -16,6 +16,8 @@ dependencies = [
|
||||
"shapely>=2.0.4",
|
||||
"pyproj>=3.6.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"itsdangerous>=2.2.0",
|
||||
"PyJWT[crypto]>=2.10.1",
|
||||
"rdflib>=7.1,<8",
|
||||
"alembic>=1.13.2",
|
||||
]
|
||||
@@ -37,7 +39,7 @@ ai = [
|
||||
"ultralytics>=8.3,<9",
|
||||
"torch>=2.4",
|
||||
]
|
||||
dev = ["pytest>=8.3.2", "httpx>=0.27.0", "ruff>=0.6.9"]
|
||||
dev = ["pytest>=8.3.2", "httpx>=0.27.0", "httpx2>=2.0.0", "ruff>=0.6.9"]
|
||||
|
||||
[project.scripts]
|
||||
geointel-backend = "app.main:main"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --extra=dev --extra=gis --generate-hashes --output-file=requirements-ci.lock --strip-extras pyproject.toml
|
||||
# pip-compile --extra=dev --extra=gis --generate-hashes --no-index --output-file=requirements-ci.lock --strip-extras pyproject.toml
|
||||
#
|
||||
# geointel-input-sha256: 03c20efedd96474cbe62591b7b70cdad2681688b618bdd76731bd4cfaf85b3d4
|
||||
# geointel-input-sha256: e1dd11f5b30f4c8c902f33476282da48da386d36093cab68212edc69e75df8e3
|
||||
affine==2.4.0 \
|
||||
--hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \
|
||||
--hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea
|
||||
@@ -26,6 +26,7 @@ anyio==4.14.2 \
|
||||
--hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
|
||||
# via
|
||||
# httpx
|
||||
# httpx2
|
||||
# starlette
|
||||
# watchfiles
|
||||
attrs==26.1.0 \
|
||||
@@ -41,6 +42,108 @@ certifi==2026.6.17 \
|
||||
# pyogrio
|
||||
# pyproj
|
||||
# rasterio
|
||||
cffi==2.1.1 \
|
||||
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
|
||||
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
|
||||
--hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
|
||||
--hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
|
||||
--hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
|
||||
--hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
|
||||
--hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
|
||||
--hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
|
||||
--hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
|
||||
--hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
|
||||
--hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
|
||||
--hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
|
||||
--hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
|
||||
--hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
|
||||
--hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
|
||||
--hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
|
||||
--hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
|
||||
--hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
|
||||
--hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
|
||||
--hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
|
||||
--hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
|
||||
--hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
|
||||
--hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
|
||||
--hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
|
||||
--hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
|
||||
--hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
|
||||
--hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
|
||||
--hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
|
||||
--hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
|
||||
--hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
|
||||
--hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
|
||||
--hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
|
||||
--hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
|
||||
--hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
|
||||
--hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
|
||||
--hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
|
||||
--hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
|
||||
--hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
|
||||
--hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
|
||||
--hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
|
||||
--hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
|
||||
--hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
|
||||
--hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
|
||||
--hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
|
||||
--hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
|
||||
--hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
|
||||
--hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
|
||||
--hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
|
||||
--hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
|
||||
--hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
|
||||
--hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
|
||||
--hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
|
||||
--hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
|
||||
--hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
|
||||
--hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
|
||||
--hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
|
||||
--hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
|
||||
--hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
|
||||
--hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
|
||||
--hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
|
||||
--hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
|
||||
--hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
|
||||
--hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
|
||||
--hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
|
||||
--hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
|
||||
--hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
|
||||
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
|
||||
--hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
|
||||
--hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
|
||||
--hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
|
||||
--hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
|
||||
--hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
|
||||
--hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
|
||||
--hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
|
||||
--hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
|
||||
--hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
|
||||
--hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
|
||||
--hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
|
||||
--hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
|
||||
--hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
|
||||
--hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
|
||||
--hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
|
||||
--hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
|
||||
--hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
|
||||
--hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
|
||||
--hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
|
||||
--hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
|
||||
--hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
|
||||
--hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
|
||||
--hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
|
||||
--hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
|
||||
--hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
|
||||
--hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
|
||||
--hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
|
||||
--hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
|
||||
--hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
|
||||
--hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
|
||||
--hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
|
||||
--hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
|
||||
--hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
|
||||
# via cryptography
|
||||
click==8.4.2 \
|
||||
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
|
||||
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
||||
@@ -57,6 +160,54 @@ cligj==0.7.2 \
|
||||
--hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \
|
||||
--hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df
|
||||
# via rasterio
|
||||
cryptography==50.0.1 \
|
||||
--hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
|
||||
--hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
|
||||
--hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
|
||||
--hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
|
||||
--hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
|
||||
--hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
|
||||
--hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
|
||||
--hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
|
||||
--hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
|
||||
--hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
|
||||
--hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
|
||||
--hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
|
||||
--hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
|
||||
--hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
|
||||
--hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
|
||||
--hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
|
||||
--hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
|
||||
--hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
|
||||
--hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \
|
||||
--hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \
|
||||
--hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
|
||||
--hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
|
||||
--hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \
|
||||
--hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
|
||||
--hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
|
||||
--hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \
|
||||
--hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
|
||||
--hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
|
||||
--hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
|
||||
--hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
|
||||
--hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
|
||||
--hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
|
||||
--hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
|
||||
--hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
|
||||
--hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
|
||||
--hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
|
||||
--hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
|
||||
--hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \
|
||||
--hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
|
||||
--hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
|
||||
--hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
|
||||
--hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
|
||||
--hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \
|
||||
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
|
||||
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
|
||||
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
|
||||
# via pyjwt
|
||||
fastapi==0.139.2 \
|
||||
--hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \
|
||||
--hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c
|
||||
@@ -155,11 +306,16 @@ h11==0.16.0 \
|
||||
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
# via
|
||||
# httpcore
|
||||
# httpcore2
|
||||
# uvicorn
|
||||
httpcore==1.0.9 \
|
||||
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
|
||||
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
|
||||
# via httpx
|
||||
httpcore2==2.12.0 \
|
||||
--hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \
|
||||
--hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648
|
||||
# via httpx2
|
||||
httptools==0.8.0 \
|
||||
--hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \
|
||||
--hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \
|
||||
@@ -216,16 +372,25 @@ httpx==0.28.1 \
|
||||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via geointel-backend (pyproject.toml)
|
||||
httpx2==2.12.0 \
|
||||
--hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \
|
||||
--hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36
|
||||
# via geointel-backend (pyproject.toml)
|
||||
idna==3.18 \
|
||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
# httpx2
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
itsdangerous==2.2.0 \
|
||||
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||
# via geointel-backend (pyproject.toml)
|
||||
mako==1.3.12 \
|
||||
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
|
||||
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
|
||||
@@ -613,6 +778,10 @@ psycopg-binary==3.3.4 \
|
||||
--hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \
|
||||
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
|
||||
# via psycopg
|
||||
pycparser==3.0 \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via cffi
|
||||
pydantic==2.13.4 \
|
||||
--hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
|
||||
--hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
|
||||
@@ -750,6 +919,12 @@ pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pyjwt==2.13.0 \
|
||||
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
|
||||
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
|
||||
# via
|
||||
# geointel-backend (pyproject.toml)
|
||||
# pyjwt
|
||||
pyogrio==0.13.0 \
|
||||
--hash=sha256:1b91f6d6e6757a6ea84b9459d24f479dcb52bbf4ebcdb16baf39e49d2836a1cf \
|
||||
--hash=sha256:220a988ce2a26591d6db5c775b07289d4f54cabdf274cc048f0e17a0b9d5be14 \
|
||||
@@ -1127,12 +1302,18 @@ sqlalchemy==2.0.51 \
|
||||
# alembic
|
||||
# geoalchemy2
|
||||
# geointel-backend (pyproject.toml)
|
||||
starlette==0.52.1 \
|
||||
--hash=sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74 \
|
||||
--hash=sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933
|
||||
starlette==1.6.0 \
|
||||
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
|
||||
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
|
||||
# via
|
||||
# fastapi
|
||||
# geointel-backend (pyproject.toml)
|
||||
truststore==0.10.4 \
|
||||
--hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \
|
||||
--hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981
|
||||
# via
|
||||
# httpcore2
|
||||
# httpx2
|
||||
typing-extensions==4.16.0 \
|
||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
||||
@@ -1140,6 +1321,7 @@ typing-extensions==4.16.0 \
|
||||
# alembic
|
||||
# anyio
|
||||
# fastapi
|
||||
# httpx2
|
||||
# psycopg
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --extra=gis --generate-hashes --output-file=requirements-runtime.lock --strip-extras pyproject.toml
|
||||
# pip-compile --extra=gis --generate-hashes --no-index --output-file=requirements-runtime.lock --strip-extras pyproject.toml
|
||||
#
|
||||
# geointel-input-sha256: 0d0d2cdceb01da58354610f03b5ce244523130cf9fd29e3f8988dbe684838e8b
|
||||
# geointel-input-sha256: 30318170074aad9b91f570cccc574eaaad0eded5814a464d1207a94d2e1d3317
|
||||
affine==2.4.0 \
|
||||
--hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \
|
||||
--hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea
|
||||
@@ -38,6 +38,108 @@ certifi==2026.6.17 \
|
||||
# pyogrio
|
||||
# pyproj
|
||||
# rasterio
|
||||
cffi==2.1.1 \
|
||||
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
|
||||
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
|
||||
--hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
|
||||
--hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
|
||||
--hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
|
||||
--hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
|
||||
--hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
|
||||
--hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
|
||||
--hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
|
||||
--hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
|
||||
--hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
|
||||
--hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
|
||||
--hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
|
||||
--hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
|
||||
--hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
|
||||
--hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
|
||||
--hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
|
||||
--hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
|
||||
--hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
|
||||
--hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
|
||||
--hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
|
||||
--hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
|
||||
--hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
|
||||
--hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
|
||||
--hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
|
||||
--hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
|
||||
--hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
|
||||
--hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
|
||||
--hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
|
||||
--hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
|
||||
--hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
|
||||
--hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
|
||||
--hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
|
||||
--hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
|
||||
--hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
|
||||
--hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
|
||||
--hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
|
||||
--hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
|
||||
--hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
|
||||
--hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
|
||||
--hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
|
||||
--hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
|
||||
--hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
|
||||
--hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
|
||||
--hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
|
||||
--hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
|
||||
--hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
|
||||
--hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
|
||||
--hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
|
||||
--hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
|
||||
--hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
|
||||
--hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
|
||||
--hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
|
||||
--hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
|
||||
--hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
|
||||
--hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
|
||||
--hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
|
||||
--hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
|
||||
--hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
|
||||
--hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
|
||||
--hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
|
||||
--hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
|
||||
--hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
|
||||
--hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
|
||||
--hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
|
||||
--hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
|
||||
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
|
||||
--hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
|
||||
--hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
|
||||
--hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
|
||||
--hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
|
||||
--hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
|
||||
--hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
|
||||
--hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
|
||||
--hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
|
||||
--hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
|
||||
--hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
|
||||
--hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
|
||||
--hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
|
||||
--hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
|
||||
--hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
|
||||
--hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
|
||||
--hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
|
||||
--hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
|
||||
--hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
|
||||
--hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
|
||||
--hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
|
||||
--hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
|
||||
--hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
|
||||
--hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
|
||||
--hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
|
||||
--hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
|
||||
--hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
|
||||
--hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
|
||||
--hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
|
||||
--hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
|
||||
--hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
|
||||
--hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
|
||||
--hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
|
||||
--hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
|
||||
# via cryptography
|
||||
click==8.4.2 \
|
||||
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
|
||||
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
||||
@@ -54,6 +156,54 @@ cligj==0.7.2 \
|
||||
--hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \
|
||||
--hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df
|
||||
# via rasterio
|
||||
cryptography==50.0.1 \
|
||||
--hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
|
||||
--hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
|
||||
--hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
|
||||
--hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
|
||||
--hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
|
||||
--hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
|
||||
--hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
|
||||
--hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
|
||||
--hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
|
||||
--hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
|
||||
--hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
|
||||
--hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
|
||||
--hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
|
||||
--hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
|
||||
--hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
|
||||
--hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
|
||||
--hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
|
||||
--hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
|
||||
--hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \
|
||||
--hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \
|
||||
--hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
|
||||
--hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
|
||||
--hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \
|
||||
--hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
|
||||
--hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
|
||||
--hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \
|
||||
--hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
|
||||
--hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
|
||||
--hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
|
||||
--hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
|
||||
--hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
|
||||
--hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
|
||||
--hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
|
||||
--hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
|
||||
--hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
|
||||
--hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
|
||||
--hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
|
||||
--hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \
|
||||
--hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
|
||||
--hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
|
||||
--hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
|
||||
--hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
|
||||
--hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \
|
||||
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
|
||||
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
|
||||
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
|
||||
# via pyjwt
|
||||
fastapi==0.139.2 \
|
||||
--hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \
|
||||
--hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c
|
||||
@@ -207,6 +357,10 @@ idna==3.18 \
|
||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||
# via anyio
|
||||
itsdangerous==2.2.0 \
|
||||
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||
# via geointel-backend (pyproject.toml)
|
||||
mako==1.3.12 \
|
||||
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
|
||||
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
|
||||
@@ -589,6 +743,10 @@ psycopg-binary==3.3.4 \
|
||||
--hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \
|
||||
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
|
||||
# via psycopg
|
||||
pycparser==3.0 \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via cffi
|
||||
pydantic==2.13.4 \
|
||||
--hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
|
||||
--hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
|
||||
@@ -722,6 +880,12 @@ pydantic-settings==2.14.2 \
|
||||
--hash=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 \
|
||||
--hash=sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f
|
||||
# via geointel-backend (pyproject.toml)
|
||||
pyjwt==2.13.0 \
|
||||
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
|
||||
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
|
||||
# via
|
||||
# geointel-backend (pyproject.toml)
|
||||
# pyjwt
|
||||
pyogrio==0.13.0 \
|
||||
--hash=sha256:1b91f6d6e6757a6ea84b9459d24f479dcb52bbf4ebcdb16baf39e49d2836a1cf \
|
||||
--hash=sha256:220a988ce2a26591d6db5c775b07289d4f54cabdf274cc048f0e17a0b9d5be14 \
|
||||
@@ -1075,9 +1239,9 @@ sqlalchemy==2.0.51 \
|
||||
# alembic
|
||||
# geoalchemy2
|
||||
# geointel-backend (pyproject.toml)
|
||||
starlette==0.52.1 \
|
||||
--hash=sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74 \
|
||||
--hash=sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933
|
||||
starlette==1.6.0 \
|
||||
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
|
||||
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
|
||||
# via
|
||||
# fastapi
|
||||
# geointel-backend (pyproject.toml)
|
||||
|
||||
@@ -14,10 +14,13 @@ SCRIPTS_ROOT = REPOSITORY_ROOT / "scripts"
|
||||
if SCRIPTS_ROOT.is_dir():
|
||||
sys.path.insert(0, str(SCRIPTS_ROOT))
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import Export, Project
|
||||
from release_backup_guard import require_confirmation, verify_current_backup
|
||||
from app.core.config import get_settings # noqa: E402 - imported after backend path bootstrap
|
||||
from app.db.session import SessionLocal # noqa: E402 - imported after backend path bootstrap
|
||||
from app.models import Export, Project # noqa: E402 - imported after backend path bootstrap
|
||||
from release_backup_guard import ( # noqa: E402 - imported after scripts path bootstrap
|
||||
require_confirmation,
|
||||
verify_current_backup,
|
||||
)
|
||||
|
||||
|
||||
DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
|
||||
|
||||
@@ -40,6 +40,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
|
||||
"components/map/useMapWorkspaceViewModel.ts",
|
||||
"components/map/MapExplorerView.tsx",
|
||||
"components/map/MapAdvancedWorkbench.tsx",
|
||||
"components/map/MunicipalitySearch.tsx",
|
||||
"hooks/useMapImageOverlays.ts",
|
||||
"hooks/useMapRectangleSelection.ts",
|
||||
"hooks/useFullGisWorkflow.ts",
|
||||
|
||||
@@ -9,7 +9,13 @@ from pathlib import Path
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "run_accuracy_phase3_full_data_scan.py"
|
||||
|
||||
|
||||
def run_scan(repo: Path, output: Path, *, resume: bool = False) -> dict:
|
||||
def run_scan(
|
||||
repo: Path,
|
||||
output: Path,
|
||||
*,
|
||||
resume: bool = False,
|
||||
unreachable_scopes: tuple[str, ...] = (),
|
||||
) -> dict:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
@@ -24,6 +30,8 @@ def run_scan(repo: Path, output: Path, *, resume: bool = False) -> dict:
|
||||
]
|
||||
if resume:
|
||||
command.append("--resume")
|
||||
for scope in unreachable_scopes:
|
||||
command.extend(("--unreachable-scope", scope))
|
||||
completed = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
@@ -57,8 +65,18 @@ def test_phase3_scan_reconciles_and_resumes_deterministically(tmp_path: Path) ->
|
||||
(data / "broken.tif").write_bytes(b"not a geotiff")
|
||||
|
||||
output = tmp_path / "evidence"
|
||||
first = run_scan(tmp_path, output)
|
||||
second = run_scan(tmp_path, output, resume=True)
|
||||
unreachable_scopes = (
|
||||
"external://tower-corpora=Tower corpora are not mounted in this fixture",
|
||||
"external://mounted-model-volumes=Model volumes are not mounted in this fixture",
|
||||
"external://production-postgis-or-api=Production database is not configured in this fixture",
|
||||
)
|
||||
first = run_scan(tmp_path, output, unreachable_scopes=unreachable_scopes)
|
||||
second = run_scan(
|
||||
tmp_path,
|
||||
output,
|
||||
resume=True,
|
||||
unreachable_scopes=unreachable_scopes,
|
||||
)
|
||||
manifest = json.loads((output / "full-scan-manifest.json").read_text(encoding="utf-8"))
|
||||
quarantine = json.loads((output / "quarantine-manifest.json").read_text(encoding="utf-8"))
|
||||
|
||||
@@ -68,3 +86,46 @@ def test_phase3_scan_reconciles_and_resumes_deterministically(tmp_path: Path) ->
|
||||
assert any(item["path"] == "data/broken.tif" for item in quarantine["items"])
|
||||
assert any(item["path"] == "data/invalid.geojson" for item in quarantine["items"])
|
||||
assert len(manifest["duplicates"]["exact_duplicate_groups"]) == 1
|
||||
|
||||
|
||||
def test_phase3_scan_does_not_invent_unreachable_production_boundaries(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
(data / "present.json").write_text("{}", encoding="utf-8")
|
||||
output = tmp_path / "evidence"
|
||||
|
||||
result = run_scan(tmp_path, output)
|
||||
|
||||
assert result["reconciliation"] == {
|
||||
"examined": 1,
|
||||
"skipped": 0,
|
||||
"unreachable": 0,
|
||||
"inventory_total": 1,
|
||||
"reconciles": True,
|
||||
}
|
||||
|
||||
|
||||
def test_phase3_scan_records_a_missing_requested_root(tmp_path: Path) -> None:
|
||||
output = tmp_path / "evidence"
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--repo-root",
|
||||
str(tmp_path),
|
||||
"--output-dir",
|
||||
str(output),
|
||||
"--roots",
|
||||
"missing-data",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = json.loads(completed.stdout)
|
||||
manifest = json.loads((output / "full-scan-manifest.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert result["reconciliation"]["unreachable"] == 1
|
||||
assert manifest["items"][0]["path"] == "root://missing-data"
|
||||
|
||||
@@ -14,7 +14,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import AnalysisRun, Detection, Job
|
||||
from app.models import Job
|
||||
from app.services.analysis_job_worker import AnalysisJobWorker
|
||||
from app.services.detection_service import DetectionService
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from geoalchemy2.shape import from_shape, to_shape
|
||||
from pyproj import Transformer
|
||||
import pytest
|
||||
from shapely.geometry import Polygon, mapping
|
||||
from shapely.ops import transform
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import Area, Project
|
||||
from app.schemas.area import AreaCreate, AreaUpdate
|
||||
from app.services.area_service import AreaService
|
||||
from app.utils.geometry import area_m2, normalize_area_to_epsg4326
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, objects=None) -> None:
|
||||
self.objects = objects or {}
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
self.refreshes = []
|
||||
|
||||
def get(self, model, item_id):
|
||||
return self.objects.get((model, item_id))
|
||||
|
||||
def add(self, item) -> None:
|
||||
self.added.append(item)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def refresh(self, item) -> None:
|
||||
self.refreshes.append(item)
|
||||
|
||||
|
||||
def _wgs84_polygon(offset: float = 0.0) -> Polygon:
|
||||
return Polygon(
|
||||
[
|
||||
(5.00 + offset, 51.00),
|
||||
(5.01 + offset, 51.00),
|
||||
(5.01 + offset, 51.01),
|
||||
(5.00 + offset, 51.01),
|
||||
(5.00 + offset, 51.00),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _to_lambert(geometry: Polygon) -> Polygon:
|
||||
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
|
||||
return transform(transformer.transform, geometry)
|
||||
|
||||
|
||||
def test_create_area_transforms_declared_lambert_geometry_before_storage() -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")})
|
||||
source = _wgs84_polygon()
|
||||
|
||||
area = AreaService.create_area(
|
||||
db,
|
||||
project_id,
|
||||
AreaCreate(name="Lambert AOI", geometry=mapping(_to_lambert(source)), crs="EPSG:31370"),
|
||||
)
|
||||
|
||||
stored = to_shape(area.geometry)
|
||||
assert stored.bounds == pytest.approx(source.bounds, abs=1e-7)
|
||||
assert area.original_crs == "EPSG:31370"
|
||||
assert area.area_m2 == pytest.approx(area_m2(normalize_area_to_epsg4326(mapping(source), "EPSG:4326")[0]))
|
||||
assert area.area_m2 and area.area_m2 > 0
|
||||
assert to_shape(area.bbox).bounds == pytest.approx(source.bounds, abs=1e-7)
|
||||
|
||||
|
||||
def test_patch_area_replaces_geometry_and_recomputes_all_spatial_fields() -> None:
|
||||
area_id = uuid4()
|
||||
project_id = uuid4()
|
||||
original = _wgs84_polygon()
|
||||
normalized, _ = normalize_area_to_epsg4326(mapping(original), "EPSG:4326")
|
||||
area = Area(
|
||||
id=area_id,
|
||||
project_id=project_id,
|
||||
name="Original",
|
||||
geometry=from_shape(normalized, srid=4326),
|
||||
bbox=from_shape(normalized.envelope, srid=4326),
|
||||
original_crs="EPSG:4326",
|
||||
area_m2=area_m2(normalized),
|
||||
)
|
||||
db = FakeSession({(Area, area_id): area})
|
||||
replacement = _wgs84_polygon(offset=0.05)
|
||||
|
||||
updated = AreaService.update_area(
|
||||
db,
|
||||
area_id,
|
||||
AreaUpdate(
|
||||
name="Replacement",
|
||||
geometry=mapping(_to_lambert(replacement)),
|
||||
crs="EPSG:31370",
|
||||
),
|
||||
)
|
||||
|
||||
assert updated.name == "Replacement"
|
||||
assert updated.original_crs == "EPSG:31370"
|
||||
assert to_shape(updated.geometry).bounds == pytest.approx(replacement.bounds, abs=1e-7)
|
||||
assert to_shape(updated.bbox).bounds == pytest.approx(replacement.bounds, abs=1e-7)
|
||||
assert updated.area_m2 and updated.area_m2 > 0
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("geometry", "crs", "message_fragment"),
|
||||
[
|
||||
(mapping(_wgs84_polygon()), "EPSG:not-real", "unknown or invalid"),
|
||||
(mapping(_wgs84_polygon()), "EPSG:4979", "exactly two spatial axes"),
|
||||
(
|
||||
{
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[5.0, 51.0], [float("nan"), 51.0], [5.1, 51.1], [5.0, 51.0]]],
|
||||
},
|
||||
"EPSG:4326",
|
||||
"finite",
|
||||
),
|
||||
(mapping(Polygon([(10.0, 51.0), (10.1, 51.0), (10.1, 51.1), (10.0, 51.0)])), "EPSG:4326", "workbench domain"),
|
||||
(
|
||||
{
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[5.0, 51.0], [5.1, 51.1], [5.1, 51.0], [5.0, 51.1], [5.0, 51.0]]],
|
||||
},
|
||||
"EPSG:4326",
|
||||
"invalid",
|
||||
),
|
||||
({"type": "Point", "coordinates": [5.0, 51.0]}, "EPSG:4326", "Polygon or MultiPolygon"),
|
||||
],
|
||||
)
|
||||
def test_create_area_rejects_invalid_crs_nonfinite_and_out_of_domain_geometry(
|
||||
geometry: dict,
|
||||
crs: str,
|
||||
message_fragment: str,
|
||||
) -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")})
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
AreaService.create_area(db, project_id, AreaCreate(name="Invalid", geometry=geometry, crs=crs))
|
||||
|
||||
assert exc_info.value.code == "INVALID_GEOMETRY"
|
||||
assert message_fragment in exc_info.value.message
|
||||
assert db.commits == 0
|
||||
|
||||
|
||||
def test_patch_area_rejects_crs_without_replacement_geometry() -> None:
|
||||
area_id = uuid4()
|
||||
area = Area(id=area_id, project_id=uuid4(), name="AOI", original_crs="EPSG:4326")
|
||||
db = FakeSession({(Area, area_id): area})
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
AreaService.update_area(db, area_id, AreaUpdate(crs="EPSG:31370"))
|
||||
|
||||
assert exc_info.value.code == "INVALID_AREA_CRS_UPDATE"
|
||||
assert db.commits == 0
|
||||
+236
-1
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -10,7 +11,13 @@ from app.db.session import get_db
|
||||
from app.main import create_app
|
||||
from app.schemas.demo import DemoWorkflowResponse
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.change_detection_service import ChangeDetectionService
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.demo_workflow_service import DemoWorkflowService
|
||||
from app.services.raster_operations_service import RasterOperationsService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.services.job_service import JobService
|
||||
|
||||
|
||||
def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient:
|
||||
@@ -80,6 +87,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
|
||||
"expires_at": None,
|
||||
"role": None,
|
||||
"guest_access_enabled": False,
|
||||
"authentik_enabled": False,
|
||||
"guest_project_id": None,
|
||||
}
|
||||
assert protected.status_code == 401
|
||||
@@ -113,6 +121,7 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m
|
||||
"expires_at": login.json()["data"]["expires_at"],
|
||||
"role": "operator",
|
||||
"guest_access_enabled": True,
|
||||
"authentik_enabled": False,
|
||||
"guest_project_id": None,
|
||||
}
|
||||
cookie = login.headers["set-cookie"].lower()
|
||||
@@ -166,6 +175,18 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
|
||||
"themes": [],
|
||||
},
|
||||
)
|
||||
bounded_acquisition = client.post(
|
||||
f"/api/v1/projects/{project_id}/datasets/orthophoto/acquire",
|
||||
json={},
|
||||
)
|
||||
cross_project_acquisition = client.post(
|
||||
"/api/v1/projects/00000000-0000-0000-0000-000000000999/datasets/orthophoto/acquire",
|
||||
json={},
|
||||
)
|
||||
bounded_derived_selection = client.post(
|
||||
f"/api/v1/projects/{project_id}/datasets/{demo.candidate_dataset_id}/vector/select/derive",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert guest_login.status_code == 200
|
||||
assert guest_login.json()["data"]["role"] == "guest"
|
||||
@@ -186,6 +207,218 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
|
||||
assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
assert cross_project_coverage.status_code == 403
|
||||
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
assert bounded_acquisition.status_code == 422
|
||||
assert bounded_acquisition.json()["error"] != "GUEST_READ_ONLY"
|
||||
assert cross_project_acquisition.status_code == 403
|
||||
assert cross_project_acquisition.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
assert bounded_derived_selection.status_code == 422
|
||||
assert bounded_derived_selection.json()["error"] != "GUEST_READ_ONLY"
|
||||
|
||||
|
||||
def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monkeypatch) -> None:
|
||||
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
other_project_id = UUID("00000000-0000-0000-0000-000000000999")
|
||||
source_dataset_id = UUID("00000000-0000-0000-0000-000000000125")
|
||||
target_dataset_id = UUID("00000000-0000-0000-0000-000000000126")
|
||||
cross_project_dataset_id = UUID("00000000-0000-0000-0000-000000000998")
|
||||
demo = DemoWorkflowResponse(
|
||||
project_id=project_id,
|
||||
area_id=UUID("00000000-0000-0000-0000-000000000124"),
|
||||
reference_dataset_id=source_dataset_id,
|
||||
candidate_dataset_id=target_dataset_id,
|
||||
raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"),
|
||||
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
|
||||
metric_count=6,
|
||||
status="ok",
|
||||
message="Demo ready",
|
||||
created=False,
|
||||
)
|
||||
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
|
||||
|
||||
class FakeDb:
|
||||
def get(self, _model, dataset_id):
|
||||
bound_project_id = other_project_id if dataset_id == cross_project_dataset_id else project_id
|
||||
return SimpleNamespace(id=dataset_id, project_id=bound_project_id, dataset_type="vector")
|
||||
|
||||
validated_datasets: list[tuple[UUID, UUID, str]] = []
|
||||
|
||||
def validate_dataset(_db, dataset_id, requested_project_id, label):
|
||||
validated_datasets.append((dataset_id, requested_project_id, label))
|
||||
return SimpleNamespace(id=dataset_id, project_id=requested_project_id, dataset_type="vector")
|
||||
|
||||
monkeypatch.setattr(
|
||||
ChangeDetectionService,
|
||||
"_get_project_vector_dataset",
|
||||
staticmethod(validate_dataset),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
JobService,
|
||||
"run_sync_job",
|
||||
staticmethod(
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
id=uuid4(),
|
||||
job_type=kwargs["job_type"],
|
||||
status="success",
|
||||
project_id=kwargs["project_id"],
|
||||
dataset_id=source_dataset_id,
|
||||
input_dataset_id=source_dataset_id,
|
||||
output_dataset_id=None,
|
||||
parameters_json=kwargs["parameters"],
|
||||
result_json={},
|
||||
error_message=None,
|
||||
created_at=None,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
client = auth_client(monkeypatch, guest_access=True)
|
||||
|
||||
def fake_db():
|
||||
yield FakeDb()
|
||||
|
||||
client.app.dependency_overrides[get_db] = fake_db
|
||||
assert client.post("/api/v1/auth/guest").status_code == 200
|
||||
|
||||
accepted = client.post(
|
||||
"/api/v1/analysis/change-detection",
|
||||
json={
|
||||
"source_dataset_id": str(source_dataset_id),
|
||||
"target_dataset_id": str(target_dataset_id),
|
||||
},
|
||||
)
|
||||
rejected = client.post(
|
||||
"/api/v1/analysis/change-detection",
|
||||
json={
|
||||
"source_dataset_id": str(cross_project_dataset_id),
|
||||
"target_dataset_id": str(target_dataset_id),
|
||||
},
|
||||
)
|
||||
|
||||
assert accepted.status_code == 200
|
||||
assert accepted.json()["data"]["project_id"] == str(project_id)
|
||||
assert validated_datasets == [
|
||||
(source_dataset_id, project_id, "Source"),
|
||||
(target_dataset_id, project_id, "Target"),
|
||||
]
|
||||
assert rejected.status_code == 403
|
||||
assert rejected.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
|
||||
|
||||
def test_guest_can_prepare_tiles_and_queue_project_scoped_detection(monkeypatch) -> None:
|
||||
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
raster_dataset_id = UUID("00000000-0000-0000-0000-000000000127")
|
||||
manifest_path = "/app/storage/tiles/demo/manifest.json"
|
||||
demo = DemoWorkflowResponse(
|
||||
project_id=project_id,
|
||||
area_id=UUID("00000000-0000-0000-0000-000000000124"),
|
||||
reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"),
|
||||
candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"),
|
||||
raster_dataset_id=raster_dataset_id,
|
||||
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
|
||||
metric_count=6,
|
||||
status="ok",
|
||||
message="Demo ready",
|
||||
created=False,
|
||||
)
|
||||
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
|
||||
monkeypatch.setattr(
|
||||
DatasetService,
|
||||
"get_dataset",
|
||||
staticmethod(lambda _db, _dataset_id: SimpleNamespace(project_id=project_id)),
|
||||
)
|
||||
|
||||
def job(*, job_type: str, result_json: dict | None = None):
|
||||
return SimpleNamespace(
|
||||
id=uuid4(),
|
||||
job_type=job_type,
|
||||
status="success" if result_json else "queued",
|
||||
project_id=project_id,
|
||||
dataset_id=raster_dataset_id,
|
||||
input_dataset_id=raster_dataset_id,
|
||||
output_dataset_id=None,
|
||||
parameters_json={},
|
||||
result_json=result_json,
|
||||
error_message=None,
|
||||
created_at=None,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
)
|
||||
|
||||
tile_parameters: dict = {}
|
||||
|
||||
def tile(_db, _dataset_id, **kwargs):
|
||||
tile_parameters.update(kwargs)
|
||||
return {"manifest_path": manifest_path}
|
||||
|
||||
monkeypatch.setattr(RasterOperationsService, "tile", staticmethod(tile))
|
||||
monkeypatch.setattr(
|
||||
"app.api.routes.datasets._run_job_sync",
|
||||
lambda **kwargs: job(job_type="raster.tile", result_json=kwargs["operation"]()),
|
||||
)
|
||||
queued_parameters: dict = {}
|
||||
|
||||
def enqueue_detection(**kwargs):
|
||||
queued_parameters.update(kwargs)
|
||||
return job(job_type="detection.run")
|
||||
|
||||
monkeypatch.setattr(DetectionService, "enqueue_detection", staticmethod(enqueue_detection))
|
||||
queued_segmentation_parameters: dict = {}
|
||||
|
||||
def enqueue_segmentation(**kwargs):
|
||||
queued_segmentation_parameters.update(kwargs)
|
||||
return job(job_type="segmentation.run")
|
||||
|
||||
monkeypatch.setattr(SegmentationService, "enqueue_segmentation", staticmethod(enqueue_segmentation))
|
||||
client = auth_client(monkeypatch, guest_access=True)
|
||||
|
||||
def fake_db():
|
||||
yield object()
|
||||
|
||||
client.app.dependency_overrides[get_db] = fake_db
|
||||
assert client.post("/api/v1/auth/guest").status_code == 200
|
||||
|
||||
tile_response = client.post(
|
||||
f"/api/v1/projects/{project_id}/datasets/{raster_dataset_id}/raster/tile",
|
||||
json={"tile_size": 512, "overlap": 64},
|
||||
)
|
||||
detection_response = client.post(
|
||||
f"/api/v1/detection/run-async?project_id={project_id}",
|
||||
json={
|
||||
"project_id": str(project_id),
|
||||
"dataset_id": str(raster_dataset_id),
|
||||
"model_id": "yolo-configured",
|
||||
"model_asset_id": "active-model",
|
||||
"confidence_threshold": 0.15,
|
||||
"tile_manifest_path": manifest_path,
|
||||
"parameters_json": {},
|
||||
},
|
||||
)
|
||||
segmentation_response = client.post(
|
||||
f"/api/v1/segmentation/run-async?project_id={project_id}",
|
||||
json={
|
||||
"project_id": str(project_id),
|
||||
"dataset_id": str(raster_dataset_id),
|
||||
"model_id": "sam-configured",
|
||||
"confidence_threshold": 0.5,
|
||||
"tile_manifest_path": manifest_path,
|
||||
"parameters_json": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert tile_response.status_code == 201
|
||||
assert tile_response.json()["data"]["result_json"]["manifest_path"] == manifest_path
|
||||
assert detection_response.status_code == 200
|
||||
assert detection_response.json()["data"]["status"] == "queued"
|
||||
assert segmentation_response.status_code == 200
|
||||
assert segmentation_response.json()["data"]["status"] == "queued"
|
||||
assert queued_parameters["project_id"] == project_id
|
||||
assert queued_parameters["dataset_id"] == raster_dataset_id
|
||||
assert queued_parameters["tile_manifest_path"] == manifest_path
|
||||
assert queued_segmentation_parameters["project_id"] == project_id
|
||||
assert queued_segmentation_parameters["dataset_id"] == raster_dataset_id
|
||||
assert queued_segmentation_parameters["tile_manifest_path"] == manifest_path
|
||||
assert tile_parameters["max_tiles"] == get_settings().yolo_max_tiles
|
||||
|
||||
|
||||
def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None:
|
||||
@@ -223,7 +456,9 @@ def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
|
||||
browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
|
||||
assert '-e GEOINTEL_AUTHENTIK_CLIENT_SECRET="$GEOINTEL_AUTHENTIK_CLIENT_SECRET"' in runner
|
||||
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
|
||||
assert "GEOINTEL_AUTHENTIK_CLIENT_SECRET=" in example
|
||||
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
|
||||
assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example
|
||||
assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.authentik_oidc_service import (
|
||||
MAX_OIDC_JSON_BYTES,
|
||||
AuthentikOidcService,
|
||||
)
|
||||
|
||||
|
||||
ISSUER = "https://auth.example.test/application/o/geointel"
|
||||
|
||||
|
||||
def configured_settings(**overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"auth_enabled": True,
|
||||
"auth_username": "ITWorx",
|
||||
"auth_password_hash": "pbkdf2_sha256$1$salt$digest",
|
||||
"auth_session_secret": "s" * 48,
|
||||
"authentik_issuer": ISSUER,
|
||||
"authentik_client_id": "geointel-client",
|
||||
"authentik_client_secret": "client-secret",
|
||||
"authentik_allowed_email": "operator@example.test",
|
||||
"public_base_url": "https://geointel.example.test",
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def discovery_document() -> dict[str, str]:
|
||||
return {
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{ISSUER}/authorize",
|
||||
"token_endpoint": f"{ISSUER}/token",
|
||||
"jwks_uri": f"{ISSUER}/jwks",
|
||||
}
|
||||
|
||||
|
||||
def test_authentik_configuration_is_all_or_nothing_and_https_only() -> None:
|
||||
with pytest.raises(ValidationError, match="configured together"):
|
||||
configured_settings(authentik_client_secret=None)
|
||||
with pytest.raises(ValidationError, match="absolute HTTPS URL"):
|
||||
configured_settings(authentik_issuer="http://auth.example.test/issuer")
|
||||
with pytest.raises(ValidationError, match="must not contain a path"):
|
||||
configured_settings(public_base_url="https://geointel.example.test/app")
|
||||
|
||||
|
||||
def test_start_uses_same_origin_discovery_and_pkce(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: discovery_document())
|
||||
|
||||
location, flow_cookie = service.start()
|
||||
|
||||
parsed = urlsplit(location)
|
||||
query = parse_qs(parsed.query)
|
||||
flow = service.serializer.loads(flow_cookie, max_age=600)
|
||||
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == f"{ISSUER}/authorize"
|
||||
assert query["redirect_uri"] == [
|
||||
"https://geointel.example.test/api/v1/auth/authentik/callback"
|
||||
]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert query["state"] == [flow["state"]]
|
||||
assert query["nonce"] == [flow["nonce"]]
|
||||
assert query["code_challenge"][0]
|
||||
|
||||
|
||||
def test_discovery_rejects_cross_origin_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
document = discovery_document()
|
||||
document["jwks_uri"] = "https://attacker.example.test/jwks"
|
||||
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: document)
|
||||
|
||||
with pytest.raises(ValueError, match="outside the configured issuer origin"):
|
||||
service._discovery()
|
||||
|
||||
|
||||
def test_finish_verifies_signature_nonce_and_exact_allowed_email(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
state, nonce, verifier = "state-value", "nonce-value", "verifier-value"
|
||||
flow_cookie = service.serializer.dumps(
|
||||
{"state": state, "nonce": nonce, "verifier": verifier}
|
||||
)
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(
|
||||
private_key.public_key(), as_dict=True
|
||||
)
|
||||
public_jwk["kid"] = "operator-key"
|
||||
now = int(time.time())
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "geointel-client",
|
||||
"sub": "authentik-user-id",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": nonce,
|
||||
"email": "Operator@Example.Test",
|
||||
"email_verified": True,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "operator-key"},
|
||||
)
|
||||
token_holder = {"value": token}
|
||||
|
||||
def fetch(url: str, data: dict[str, str] | None = None) -> dict:
|
||||
if url.endswith("openid-configuration"):
|
||||
return discovery_document()
|
||||
if url.endswith("/token"):
|
||||
assert data is not None
|
||||
assert data["code_verifier"] == verifier
|
||||
return {"id_token": token_holder["value"]}
|
||||
if url.endswith("/jwks"):
|
||||
return {"keys": [public_jwk]}
|
||||
raise AssertionError(url)
|
||||
|
||||
monkeypatch.setattr(service, "_fetch_json", fetch)
|
||||
|
||||
claims = service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
|
||||
|
||||
assert claims["sub"] == "authentik-user-id"
|
||||
token_holder["value"] = jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "geointel-client",
|
||||
"sub": "different-user",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": nonce,
|
||||
"email": "other@example.test",
|
||||
"email_verified": True,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "operator-key"},
|
||||
)
|
||||
with pytest.raises(ValueError, match="not authorized"):
|
||||
service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
|
||||
with pytest.raises(ValueError, match="state mismatch"):
|
||||
service.finish(
|
||||
code="authorization-code",
|
||||
state="different-state",
|
||||
flow_cookie=flow_cookie,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_json_rejects_declared_oversize_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
|
||||
class OversizeResponse:
|
||||
headers = {"Content-Length": str(MAX_OIDC_JSON_BYTES + 1)}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self, _size: int) -> bytes:
|
||||
raise AssertionError("oversized responses must not be read")
|
||||
|
||||
class Opener:
|
||||
def open(self, *_args: object, **_kwargs: object) -> OversizeResponse:
|
||||
return OversizeResponse()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.authentik_oidc_service.build_opener",
|
||||
lambda *_args: Opener(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="size limit"):
|
||||
service._fetch_json(f"{ISSUER}/oversized")
|
||||
@@ -175,6 +175,31 @@ def test_failed_iteration_builds_train_only_sampling_for_next_checkpoint(tmp_pat
|
||||
assert command[command.index("--output-dir") + 1].endswith("failure-driven-training")
|
||||
|
||||
|
||||
def test_protected_assessment_uses_frozen_threshold_and_configured_gates(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = MODULE.protected_assessment_command(
|
||||
scripts_dir=tmp_path / "scripts",
|
||||
calibration=tmp_path / "calibration.json",
|
||||
test=tmp_path / "test.json",
|
||||
background=tmp_path / "background.json",
|
||||
output=tmp_path / "assessment.json",
|
||||
selected_threshold=0.275,
|
||||
min_aggregate_f1=0.71,
|
||||
min_region_f1=0.62,
|
||||
min_region_precision=0.73,
|
||||
min_region_recall=0.58,
|
||||
max_pure_empty_fp=1,
|
||||
)
|
||||
|
||||
assert command[command.index("--selected-threshold") + 1] == "0.275"
|
||||
assert command[command.index("--min-aggregate-f1") + 1] == "0.71"
|
||||
assert command[command.index("--min-region-f1") + 1] == "0.62"
|
||||
assert command[command.index("--min-region-precision") + 1] == "0.73"
|
||||
assert command[command.index("--min-region-recall") + 1] == "0.58"
|
||||
assert command[command.index("--max-pure-empty-fp") + 1] == "1"
|
||||
|
||||
|
||||
def test_partial_iteration_resume_uses_exact_checkpoint_and_cuda(tmp_path: Path) -> None:
|
||||
checkpoint = tmp_path / "runs" / "iteration-002" / "weights" / "last.pt"
|
||||
assert MODULE.resumable_training_command("yolo", checkpoint) == [
|
||||
|
||||
@@ -190,6 +190,17 @@ def test_passed_manual_or_experimental_dataset_cannot_cross_production_boundary(
|
||||
assert "experimental_source_not_allowed_for_purpose" in exc_info.value.details["reasons"]
|
||||
|
||||
|
||||
def test_fully_governed_demo_fixture_still_cannot_enter_production_inference() -> None:
|
||||
fixture = _governed_dataset(source_key="fixture", classification="experimental")
|
||||
fixture.source_metadata = {"fixture": True, "usage": "offline demo raster workflow only"}
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
DatasetConsumptionGate.assert_eligible(fixture, purpose="production_inference")
|
||||
|
||||
assert exc_info.value.code == "DATASET_PROVENANCE_INCOMPLETE"
|
||||
assert "experimental_source_not_allowed_for_purpose" in exc_info.value.details["reasons"]
|
||||
|
||||
|
||||
def test_reference_validation_requires_authoritative_ground_truth_reference() -> None:
|
||||
reference = _governed_dataset()
|
||||
reference.dataset_type = "vector"
|
||||
|
||||
@@ -172,6 +172,25 @@ def test_walloon_runtime_settings_are_editable_in_compose_and_unraid() -> None:
|
||||
assert "WALOUS_MAX_PIXELS" in content
|
||||
|
||||
|
||||
def test_in_memory_vector_limit_is_propagated_and_validated_in_every_runtime() -> None:
|
||||
expected = "GEOINTEL_MAX_IN_MEMORY_VECTOR_MB"
|
||||
for path in (
|
||||
ROOT / ".env.example",
|
||||
ROOT / "docker-compose.yml",
|
||||
ROOT / "docker-compose.unraid.yml",
|
||||
ROOT / "deploy" / "unraid" / "geointel.env.example",
|
||||
ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml",
|
||||
):
|
||||
assert expected in path.read_text(encoding="utf-8"), path
|
||||
|
||||
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'GEOINTEL_MAX_IN_MEMORY_VECTOR_MB="${GEOINTEL_MAX_IN_MEMORY_VECTOR_MB:-64}"' in run_script
|
||||
assert "GEOINTEL_MAX_IN_MEMORY_VECTOR_MB must be between 1 and 256." in run_script
|
||||
assert '-e GEOINTEL_MAX_IN_MEMORY_VECTOR_MB="$GEOINTEL_MAX_IN_MEMORY_VECTOR_MB"' in run_script
|
||||
|
||||
|
||||
def test_frontend_uses_same_origin_api_proxy_by_default() -> None:
|
||||
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
|
||||
nginx_config = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8")
|
||||
@@ -208,6 +227,45 @@ def test_nginx_runtime_allows_long_ai_and_qa_requests() -> None:
|
||||
assert "proxy_send_timeout 600s;" in config
|
||||
|
||||
|
||||
def test_nginx_preserves_outer_https_scheme_for_secure_session_cookies() -> None:
|
||||
configs = (
|
||||
(ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8"),
|
||||
(ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(encoding="utf-8"),
|
||||
)
|
||||
for config in configs:
|
||||
assert "geo $geointel_trusted_forwarder" in config
|
||||
assert "default 0;" in config
|
||||
assert "172.16.0.0/12 1;" in config
|
||||
assert 'map "$geointel_trusted_forwarder:$http_x_forwarded_proto"' in config
|
||||
assert '"1:https" https;' in config
|
||||
assert "proxy_set_header X-Forwarded-Proto $geointel_forwarded_proto;" in config
|
||||
assert "proxy_set_header X-Forwarded-Proto $scheme;" not in config
|
||||
|
||||
|
||||
def test_nginx_runtime_sets_security_headers_on_all_cached_locations() -> None:
|
||||
configs = (
|
||||
(ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8"),
|
||||
(ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
)
|
||||
required = (
|
||||
'Content-Security-Policy "frame-ancestors \'none\'" always;',
|
||||
'X-Frame-Options "DENY" always;',
|
||||
'X-Content-Type-Options "nosniff" always;',
|
||||
'Referrer-Policy "strict-origin-when-cross-origin" always;',
|
||||
'Permissions-Policy "camera=(), microphone=(), geolocation=()" always;',
|
||||
)
|
||||
|
||||
for config in configs:
|
||||
cached_locations = config.count("add_header Cache-Control")
|
||||
assert cached_locations >= 2
|
||||
for header in required:
|
||||
# Nginx 1.27 locations with Cache-Control do not inherit server-level
|
||||
# add_header directives, so every cached location repeats the policy.
|
||||
assert config.count(f"add_header {header}") == cached_locations + 1
|
||||
|
||||
|
||||
def test_compose_does_not_publish_postgis_on_default_host_port() -> None:
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ import pytest
|
||||
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
from app.services.flood_hazard_analysis_service import FloodHazardCellStatistics
|
||||
from app.services.flood_hazard_analysis_service import ( # noqa: E402 - optional NumPy gate precedes service import
|
||||
FloodHazardCellStatistics,
|
||||
)
|
||||
|
||||
|
||||
NODATA = -9999.0
|
||||
|
||||
@@ -11,10 +11,20 @@ from fastapi.testclient import TestClient
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import AppError
|
||||
from app.main import app
|
||||
from app.models import AnalysisRun, Dataset, Detection, Job, Project, SourceRegistry, SourceSnapshot
|
||||
from app.models import (
|
||||
AnalysisRun,
|
||||
Dataset,
|
||||
DatasetVersion,
|
||||
Detection,
|
||||
Job,
|
||||
Project,
|
||||
SourceRegistry,
|
||||
SourceSnapshot,
|
||||
)
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
||||
from app.services.tile_manifest_service import TileManifestService
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -98,6 +108,8 @@ def _project_and_raster_dataset():
|
||||
source_name="test-derived-raster",
|
||||
storage_path="storage/uploads/source.tif",
|
||||
checksum_sha256=checksum,
|
||||
crs="EPSG:4326",
|
||||
bounds_json={"min_x": 4.0, "min_y": 51.0, "max_x": 5.0, "max_y": 52.0},
|
||||
source_registry_id=source_registry_id,
|
||||
source_snapshot_id=source_snapshot_id,
|
||||
data_contract_key="geointel.raster.geotiff",
|
||||
@@ -110,17 +122,22 @@ def _project_and_raster_dataset():
|
||||
)
|
||||
dataset.source_registry = source_registry
|
||||
dataset.source_snapshot = source_snapshot
|
||||
dataset.versions.append(
|
||||
DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1, checksum_sha256=checksum)
|
||||
)
|
||||
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
||||
return db, project_id, dataset_id
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> Path:
|
||||
def _manifest(tmp_path: Path, db: FakeSession, dataset: Dataset) -> Path:
|
||||
tile_path = tmp_path / "tile_0000.tif"
|
||||
tile_path.write_bytes(b"tile")
|
||||
binding = TileManifestService.dataset_binding(db, dataset)
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**binding,
|
||||
"tile_set_id": "tiles-fixture",
|
||||
"count": 1,
|
||||
"crs": "EPSG:4326",
|
||||
@@ -133,6 +150,7 @@ def _manifest(tmp_path: Path) -> Path:
|
||||
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
||||
"crs": "EPSG:4326",
|
||||
"index": 0,
|
||||
**TileManifestService.tile_integrity(tile_path),
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -226,7 +244,11 @@ def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -
|
||||
assert asset.size_bytes == len(b"local model")
|
||||
assert len(asset.sha256) == 64
|
||||
assert asset.active is True
|
||||
assert asset.status == "approved"
|
||||
assert asset.runtime_available is True
|
||||
assert asset.runtime_status == "active"
|
||||
assert asset.governed_validation_status == "not_verified_by_catalog"
|
||||
assert asset.promotion_status == "not_verified_by_catalog"
|
||||
assert asset.status == "runtime_active"
|
||||
assert asset.will_download_models is False
|
||||
|
||||
|
||||
@@ -257,7 +279,10 @@ def test_model_asset_catalog_only_exposes_explicit_active_asset_in_runtime(tmp_p
|
||||
assert response.total == 1
|
||||
assert response.items[0].filename == active_file.name
|
||||
assert response.items[0].active is True
|
||||
assert response.items[0].status == "approved"
|
||||
assert response.items[0].runtime_status == "active"
|
||||
assert response.items[0].governed_validation_status == "not_verified_by_catalog"
|
||||
assert response.items[0].promotion_status == "not_verified_by_catalog"
|
||||
assert response.items[0].status == "runtime_active"
|
||||
|
||||
|
||||
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
|
||||
@@ -284,6 +309,9 @@ def test_model_assets_api_returns_canonical_envelope(monkeypatch, tmp_path: Path
|
||||
assert payload["data"]["total"] == 1
|
||||
assert payload["data"]["items"][0]["model_asset_id"] == "building-detector-pt"
|
||||
assert payload["data"]["items"][0]["active"] is True
|
||||
assert payload["data"]["items"][0]["runtime_status"] == "active"
|
||||
assert payload["data"]["items"][0]["governed_validation_status"] == "not_verified_by_catalog"
|
||||
assert payload["data"]["items"][0]["promotion_status"] == "not_verified_by_catalog"
|
||||
assert payload["data"]["items"][0]["will_download_models"] is False
|
||||
|
||||
|
||||
@@ -309,7 +337,7 @@ def test_detection_run_persists_selected_model_asset_parameters(tmp_path, monkey
|
||||
model_id="yolo-configured",
|
||||
model_asset_id="building-detector-pt",
|
||||
confidence_threshold=0.5,
|
||||
tile_manifest_path=str(_manifest(tmp_path)),
|
||||
tile_manifest_path=str(_manifest(tmp_path, db, db.get(Dataset, dataset_id))),
|
||||
settings=settings,
|
||||
yolo_adapter_class=MockYoloAdapter,
|
||||
)
|
||||
|
||||
@@ -18,10 +18,10 @@ import pytest
|
||||
np = pytest.importorskip("numpy")
|
||||
rasterio = pytest.importorskip("rasterio")
|
||||
|
||||
from rasterio.transform import from_origin
|
||||
from shapely.geometry import box
|
||||
from rasterio.transform import from_origin # noqa: E402 - optional rasterio gate precedes imports
|
||||
from shapely.geometry import box # noqa: E402 - optional rasterio gate precedes imports
|
||||
|
||||
from app.services.raster_cell_selection import select_cells
|
||||
from app.services.raster_cell_selection import select_cells # noqa: E402 - optional rasterio gate precedes service import
|
||||
|
||||
|
||||
# 100 m cells, origin at the top-left corner of a 3x3 grid.
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
import importlib
|
||||
from hashlib import sha256
|
||||
|
||||
from geoalchemy2.shape import from_shape
|
||||
from app.core.errors import AppError
|
||||
from app.models import Area, Dataset, DatasetVersion
|
||||
from app.services.raster_operations_service import RasterOperationsService
|
||||
from app.services.storage_service import StorageService
|
||||
from app.api.routes.datasets import _run_job_sync
|
||||
from shapely.geometry import box
|
||||
import pytest
|
||||
@@ -688,6 +690,104 @@ def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None:
|
||||
assert payload["manifest"]["tile_server"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dimension", "expected"),
|
||||
[
|
||||
(512, [0]),
|
||||
(513, [0, 1]),
|
||||
(960, [0, 448]),
|
||||
(961, [0, 448, 449]),
|
||||
],
|
||||
)
|
||||
def test_raster_tile_offsets_use_full_tiles_and_one_unique_edge_start(dimension, expected) -> None:
|
||||
assert RasterOperationsService._tile_offsets(dimension, tile_size=512, step=448) == expected
|
||||
|
||||
|
||||
def test_raster_tile_rejects_limit_before_creating_output(monkeypatch, tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
source = tmp_path / "large-raster.tif"
|
||||
source.write_bytes(b"source")
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="large-raster.tif",
|
||||
dataset_type="raster",
|
||||
source="user_upload",
|
||||
storage_path=str(source),
|
||||
original_filename="large-raster.tif",
|
||||
stored_filename="large-raster.tif",
|
||||
content_type="image/tiff",
|
||||
size_bytes=6,
|
||||
)
|
||||
db = FakeSession([dataset])
|
||||
|
||||
class FakeSource:
|
||||
width = 2048
|
||||
height = 2048
|
||||
count = 1
|
||||
crs = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
fake_rasterio = SimpleNamespace(open=lambda _path: FakeSource())
|
||||
monkeypatch.setattr(
|
||||
"app.services.raster_operations_service._import_rasterio",
|
||||
lambda: (fake_rasterio, SimpleNamespace()),
|
||||
)
|
||||
tile_root = tmp_path / "tiles-that-must-not-exist"
|
||||
monkeypatch.setattr(
|
||||
StorageService,
|
||||
"raster_tiles_root",
|
||||
staticmethod(lambda *_args: tile_root),
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as error:
|
||||
RasterOperationsService.tile(db, dataset_id, tile_size=512, overlap=64, max_tiles=1)
|
||||
|
||||
assert error.value.code == "RASTER_TILE_LIMIT_EXCEEDED"
|
||||
assert error.value.details == {"expected_tile_count": 25, "max_tiles": 1}
|
||||
assert not tile_root.exists()
|
||||
|
||||
|
||||
def test_raster_tile_rejects_changed_source_bytes_before_creating_output(monkeypatch, tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
source = tmp_path / "changed-raster.tif"
|
||||
source.write_bytes(b"changed")
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="changed-raster.tif",
|
||||
dataset_type="raster",
|
||||
source="user_upload",
|
||||
storage_path=str(source),
|
||||
original_filename="changed-raster.tif",
|
||||
stored_filename="changed-raster.tif",
|
||||
content_type="image/tiff",
|
||||
size_bytes=7,
|
||||
checksum_sha256=sha256(b"original").hexdigest(),
|
||||
data_contract_key="raster.generic",
|
||||
)
|
||||
db = FakeSession([dataset])
|
||||
tile_root = tmp_path / "tiles-that-must-not-exist"
|
||||
monkeypatch.setattr(
|
||||
StorageService,
|
||||
"raster_tiles_root",
|
||||
staticmethod(lambda *_args: tile_root),
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as error:
|
||||
RasterOperationsService.tile(db, dataset_id)
|
||||
|
||||
assert error.value.code == "DATASET_STORAGE_CHECKSUM_MISMATCH"
|
||||
assert not tile_root.exists()
|
||||
|
||||
|
||||
def test_raster_clip_persists_derived_dataset(monkeypatch, tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
@@ -1393,4 +1493,3 @@ def test_run_job_sync_serializes_index_job_output_dataset_id(monkeypatch) -> Non
|
||||
assert result["job_type"] == "raster.ndvi"
|
||||
assert result["output_dataset_id"] == str(output_dataset_id)
|
||||
assert result["result_json"]["output_dataset_id"] == str(output_dataset_id)
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -18,6 +20,8 @@ SCRIPTS = ROOT / "scripts"
|
||||
|
||||
def load_script(name: str):
|
||||
path = SCRIPTS / name
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
spec = importlib.util.spec_from_file_location(f"rc10_{path.stem}", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
@@ -36,6 +40,9 @@ def write_backup(root: Path, *, created_at: datetime, inventory_mode: str = "sha
|
||||
"database_password_secure": True,
|
||||
"inventory_mode": inventory_mode,
|
||||
"storage_inventory_requested": True,
|
||||
"storage_snapshot_requested": True,
|
||||
"models_inventory_requested": False,
|
||||
"models_snapshot_requested": False,
|
||||
"git_commit": "0123456789abcdef",
|
||||
}
|
||||
files = {
|
||||
@@ -48,6 +55,7 @@ def write_backup(root: Path, *, created_at: datetime, inventory_mode: str = "sha
|
||||
}
|
||||
for name, content in files.items():
|
||||
(root / name).write_text(content, encoding="utf-8")
|
||||
(root / "storage-snapshot").mkdir()
|
||||
checksums = []
|
||||
for name in sorted(files):
|
||||
digest = hashlib.sha256((root / name).read_bytes()).hexdigest()
|
||||
@@ -337,8 +345,11 @@ def test_cleanup_commands_require_backup_confirmation_and_read_only_mount() -> N
|
||||
readiness = (SCRIPTS / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
live_audit = (SCRIPTS / "run_rc10_data_operations_audit.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "DELETE_STORAGE_ARTIFACTS" in generic
|
||||
assert "QUARANTINE_STORAGE_ARTIFACTS" in generic
|
||||
assert "verify_current_backup" in generic
|
||||
assert "os.link" in generic
|
||||
assert 'entry["status"] = "linked"' in generic
|
||||
assert "cleanup-quarantine" in generic
|
||||
assert "DELETE_DEMO_EXPORTS" in demo
|
||||
assert "verify_current_backup" in demo
|
||||
assert "/app/backups:ro" in compose
|
||||
@@ -347,6 +358,8 @@ def test_cleanup_commands_require_backup_confirmation_and_read_only_mount() -> N
|
||||
"release_backup_guard.py",
|
||||
"audit_data_operations.py",
|
||||
"cleanup_storage_artifacts.py",
|
||||
"restore_storage_quarantine.py",
|
||||
"release_backup_snapshot.py",
|
||||
):
|
||||
assert f"COPY scripts/{name}" in dockerfile
|
||||
assert f"py_compile scripts/{name}" in readiness
|
||||
@@ -356,3 +369,201 @@ def test_cleanup_commands_require_backup_confirmation_and_read_only_mount() -> N
|
||||
assert "table-counts-after.tsv" in live_audit
|
||||
assert "deleted_count" in live_audit
|
||||
assert "missing_manifest_artifact_count" in live_audit
|
||||
|
||||
|
||||
def test_cleanup_apply_moves_bytes_to_protected_traceable_quarantine(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.syspath_prepend(str(SCRIPTS))
|
||||
cleanup = load_script("cleanup_storage_artifacts.py")
|
||||
storage = tmp_path / "storage"
|
||||
source = storage / "derived" / "orphan.bin"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_bytes(b"recoverable-derived-artifact")
|
||||
candidate = SimpleNamespace(
|
||||
path=source.resolve(),
|
||||
relative_path="derived/orphan.bin",
|
||||
size_bytes=source.stat().st_size,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
class SessionContext:
|
||||
def __enter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
cleanup,
|
||||
"parse_args",
|
||||
lambda: SimpleNamespace(
|
||||
storage_root=storage,
|
||||
minimum_age_days=7,
|
||||
max_delete=1,
|
||||
apply=True,
|
||||
confirm="QUARANTINE_STORAGE_ARTIFACTS",
|
||||
backup_dir=tmp_path / "backup",
|
||||
backup_max_age_hours=24.0,
|
||||
quarantine_root=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(cleanup, "SessionLocal", lambda: SessionContext())
|
||||
monkeypatch.setattr(
|
||||
cleanup,
|
||||
"build_report",
|
||||
lambda *_args, **_kwargs: ({"cleanup": {"protected_prefixes": []}}, [candidate]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cleanup,
|
||||
"verify_current_backup",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(
|
||||
release_id="predeploy-test",
|
||||
created_at=now,
|
||||
age_hours=0.1,
|
||||
backup_tool_revision="0123456789abcdef",
|
||||
),
|
||||
)
|
||||
|
||||
assert cleanup.main() == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
manifest_path = Path(payload["quarantine_manifest"])
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
quarantined_path = storage / payload["quarantined"][0]["quarantine_relative_path"]
|
||||
|
||||
assert not source.exists()
|
||||
assert quarantined_path.read_bytes() == b"recoverable-derived-artifact"
|
||||
assert manifest["state"] == "complete"
|
||||
assert manifest["backup_release_id"] == "predeploy-test"
|
||||
assert manifest["entries"][0]["status"] == "quarantined"
|
||||
assert payload["deleted_count"] == 0
|
||||
|
||||
restore = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS / "restore_storage_quarantine.py"),
|
||||
"--storage-root",
|
||||
str(storage),
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--confirm",
|
||||
"RESTORE_QUARANTINED_ARTIFACTS",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert restore.returncode == 0, restore.stderr
|
||||
assert source.read_bytes() == b"recoverable-derived-artifact"
|
||||
assert not quarantined_path.exists()
|
||||
restored_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert restored_manifest["state"] == "restored"
|
||||
assert restored_manifest["entries"][0]["status"] == "restored"
|
||||
|
||||
|
||||
def _write_interrupted_quarantine(
|
||||
storage: Path,
|
||||
*,
|
||||
original_exists: bool,
|
||||
quarantine_exists: bool,
|
||||
hard_linked: bool = False,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
original = storage / "derived" / "interrupted.bin"
|
||||
operation = storage / "operator-evidence" / "cleanup-quarantine" / "cleanup-interrupted"
|
||||
quarantined = operation / "files" / "derived" / "interrupted.bin"
|
||||
original.parent.mkdir(parents=True, exist_ok=True)
|
||||
quarantined.parent.mkdir(parents=True, exist_ok=True)
|
||||
retained = b"interrupted-retained-bytes"
|
||||
if original_exists:
|
||||
original.write_bytes(retained)
|
||||
if quarantine_exists:
|
||||
if hard_linked:
|
||||
os.link(original, quarantined)
|
||||
else:
|
||||
quarantined.write_bytes(retained)
|
||||
manifest = operation / "manifest.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"state": "in_progress",
|
||||
"entries": [
|
||||
{
|
||||
"relative_path": "derived/interrupted.bin",
|
||||
"quarantine_relative_path": quarantined.relative_to(storage).as_posix(),
|
||||
"size_bytes": len(retained),
|
||||
"sha256": hashlib.sha256(retained).hexdigest(),
|
||||
"status": "linked" if hard_linked else "planned",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest, original, quarantined
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("original_exists", "quarantine_exists", "hard_linked"),
|
||||
((False, True, False), (True, True, True)),
|
||||
)
|
||||
def test_quarantine_restore_recovers_each_interrupted_move_window(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
original_exists: bool,
|
||||
quarantine_exists: bool,
|
||||
hard_linked: bool,
|
||||
) -> None:
|
||||
restore = load_script("restore_storage_quarantine.py")
|
||||
storage = tmp_path / "storage"
|
||||
manifest, original, quarantined = _write_interrupted_quarantine(
|
||||
storage,
|
||||
original_exists=original_exists,
|
||||
quarantine_exists=quarantine_exists,
|
||||
hard_linked=hard_linked,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
restore,
|
||||
"parse_args",
|
||||
lambda: SimpleNamespace(
|
||||
storage_root=storage,
|
||||
manifest=manifest,
|
||||
confirm="RESTORE_QUARANTINED_ARTIFACTS",
|
||||
),
|
||||
)
|
||||
|
||||
assert restore.main() == 0
|
||||
assert original.read_bytes() == b"interrupted-retained-bytes"
|
||||
assert not quarantined.exists()
|
||||
assert json.loads(manifest.read_text(encoding="utf-8"))["state"] == "restored"
|
||||
|
||||
|
||||
def test_quarantine_restore_never_clobbers_recreated_destination(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
restore = load_script("restore_storage_quarantine.py")
|
||||
storage = tmp_path / "storage"
|
||||
manifest, original, quarantined = _write_interrupted_quarantine(
|
||||
storage,
|
||||
original_exists=False,
|
||||
quarantine_exists=True,
|
||||
)
|
||||
original.write_bytes(b"new-runtime-bytes")
|
||||
monkeypatch.setattr(
|
||||
restore,
|
||||
"parse_args",
|
||||
lambda: SimpleNamespace(
|
||||
storage_root=storage,
|
||||
manifest=manifest,
|
||||
confirm="RESTORE_QUARANTINED_ARTIFACTS",
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="different bytes"):
|
||||
restore.main()
|
||||
assert original.read_bytes() == b"new-runtime-bytes"
|
||||
assert quarantined.read_bytes() == b"interrupted-retained-bytes"
|
||||
|
||||
@@ -18,17 +18,16 @@ def test_build_identity_does_not_invalidate_dependency_layers() -> None:
|
||||
assert 'io.geointel.ai.enabled="${GEOINTEL_INSTALL_AI}"' in dockerfile
|
||||
|
||||
|
||||
def test_release_deploy_preserves_immutable_and_previous_images() -> None:
|
||||
def test_release_deploy_preserves_immutable_and_backup_specific_rollback_images() -> None:
|
||||
script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert 'GEOINTEL_RELEASE_VARIANT="ai"' in script
|
||||
assert 'GEOINTEL_RELEASE_VARIANT="gis"' in script
|
||||
assert 'GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}-${GEOINTEL_RELEASE_VARIANT}"' in script
|
||||
assert 'GEOINTEL_PREVIOUS_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:previous"' in script
|
||||
assert 'release_image_id="$(' in script
|
||||
assert '[ "$current_image_id" != "$release_image_id" ]' in script
|
||||
assert 'docker tag "$current_image_id" "$GEOINTEL_PREVIOUS_IMAGE"' in script
|
||||
assert "preserving the existing previous image" in script
|
||||
assert 'GEOINTEL_PREDEPLOY_ROLLBACK_TAG="${GEOINTEL_IMAGE_REPOSITORY}:rollback-${release_id}"' in script
|
||||
assert 'docker tag "$current_image_id" "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG"' in script
|
||||
assert '--rollback-image-tag "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG"' in script
|
||||
assert "GEOINTEL_PREVIOUS_IMAGE" not in script
|
||||
assert 'if docker image inspect "$GEOINTEL_RELEASE_IMAGE"' in script
|
||||
assert "Immutable release tag has conflicting metadata" in script
|
||||
assert "Reusing existing immutable image" in script
|
||||
@@ -36,12 +35,80 @@ def test_release_deploy_preserves_immutable_and_previous_images() -> None:
|
||||
assert "Deployed immutable image" in script
|
||||
|
||||
|
||||
def test_release_creates_verified_backup_before_candidate_migrations() -> None:
|
||||
script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
backup_index = script.index("create_predeploy_backup\n")
|
||||
scan_index = script.index("scan_release_image\n")
|
||||
candidate_start_index = script.index('if ! start_image "$GEOINTEL_RELEASE_IMAGE_ID"')
|
||||
assert scan_index < backup_index
|
||||
assert backup_index < candidate_start_index
|
||||
assert script.index("docker build") < backup_index
|
||||
assert "/mnt/user/appdata/geointel/backups" in script
|
||||
assert "--inventory-mode sha256" in script
|
||||
assert "scripts/verify_release_backup.sh" in script
|
||||
assert "refusing an unbacked migration" in script
|
||||
assert "Quiescing the current backend" in script
|
||||
assert "restarting the unchanged current release" in script
|
||||
assert "preflight_backup_capacity" in script
|
||||
assert "select_verified_link_dest" in script
|
||||
assert "release_backup_snapshot.py" in script
|
||||
assert "run_low_impact()" in script
|
||||
assert "ionice -c 2 -n 7" in script
|
||||
assert "nice -n 10" in script
|
||||
assert "run_low_impact bash scripts/backup_release_state.sh" in script
|
||||
assert "run_low_impact bash scripts/verify_release_backup.sh" in script
|
||||
|
||||
|
||||
def test_gitea_deploy_waits_for_the_mandatory_large_snapshot() -> None:
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "release-gates.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "cancel-in-progress: false" in workflow
|
||||
deploy_job = workflow.split("\n deploy:\n", maxsplit=1)[1]
|
||||
assert "timeout-minutes: 720" in deploy_job
|
||||
assert "docker exec gitea-deploy-control" in deploy_job
|
||||
|
||||
|
||||
def test_release_starts_only_the_locally_attested_ai_image() -> None:
|
||||
script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert 'GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-true}"' in script
|
||||
assert "Production release deployment requires the gated AI image" in script
|
||||
assert 'bash scripts/generate_container_sbom.sh "$scanned_image_id"' in script
|
||||
assert 'bash scripts/scan_container_image.sh "$scanned_image_id"' in script
|
||||
assert 'running_image_id="$(docker inspect --format \'{{.Image}}\' geointel)"' in script
|
||||
assert 'if [ "$running_image_id" != "$image" ]' in script
|
||||
assert "deployment-attestation.json" in script
|
||||
assert "artifacts/release-evidence/deploy" in script
|
||||
assert "GITEA_COMMIT_SHA" in script
|
||||
assert "GITHUB_SHA" in script
|
||||
assert "must contain one full 40-character Git commit SHA" in script
|
||||
assert 'marker_path="$ROOT/.gitea-deploy/revision"' in script
|
||||
assert "git rev-parse --show-toplevel" in script
|
||||
assert "Prepared source revision marker does not match" in script
|
||||
assert "neither an exact Git checkout nor bound" in script
|
||||
assert 'running_revision" != "$GEOINTEL_BUILD_SHA"' in script
|
||||
assert 'running_ai" != "true"' in script
|
||||
assert '"revision": revision' in script
|
||||
|
||||
|
||||
def test_release_and_container_replacement_are_serialized() -> None:
|
||||
release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
|
||||
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
|
||||
rollback_script = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
restore_script = (ROOT / "deploy" / "unraid" / "restore-predeploy-database.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "GEOINTEL_DEPLOY_LOCK_FILE" in release_script
|
||||
assert "flock -n 9" in release_script
|
||||
assert "GEOINTEL_DEPLOY_LOCK_FILE" in rollback_script
|
||||
assert "flock -n 9" in rollback_script
|
||||
assert "GEOINTEL_DEPLOY_LOCK_FILE" in restore_script
|
||||
assert "flock -n 9" in restore_script
|
||||
assert "GEOINTEL_DEPLOY_LOCK_HELD=true" in release_script
|
||||
assert "GEOINTEL_CONTAINER_LOCK_FILE" in run_script
|
||||
assert "flock -w 300 8" in run_script
|
||||
assert "GeoIntel container removal did not complete within 60 seconds" in run_script
|
||||
@@ -89,13 +156,35 @@ def test_manual_rollback_reuses_persistent_paths_and_requires_existing_image() -
|
||||
rollback = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(encoding="utf-8")
|
||||
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "geointel-all-in-one:previous" in rollback
|
||||
assert "Backup manifest lacks an immutable rollback image ID" in rollback
|
||||
assert 'get("image_id", "")' in rollback
|
||||
assert 'docker image inspect "$GEOINTEL_ROLLBACK_IMAGE"' in rollback
|
||||
assert 'GEOINTEL_IMAGE="$GEOINTEL_ROLLBACK_IMAGE"' in rollback
|
||||
assert "restore-predeploy-database.sh" in rollback
|
||||
assert "--confirm-production-database-restore" in rollback
|
||||
assert "Image-only rollback" in rollback
|
||||
assert '-v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data"' in run_script
|
||||
assert '-v "${GEOINTEL_STORAGE_PATH}:/app/storage"' in run_script
|
||||
|
||||
|
||||
def test_same_revision_redeploy_rolls_back_by_backup_bound_image_id() -> None:
|
||||
release = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
|
||||
rollback = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
restore = (ROOT / "deploy" / "unraid" / "restore-predeploy-database.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
backup = (ROOT / "scripts" / "backup_release_state.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "current_image_id" in release
|
||||
assert "release_image_id" not in release
|
||||
assert '"rollback_image_tag": ${ROLLBACK_IMAGE_TAG@Q} or None' in backup
|
||||
assert 'if [ -z "$RESTORE_IMAGE" ]; then\n RESTORE_IMAGE="$BACKUP_IMAGE_ID"' in restore
|
||||
assert 'GEOINTEL_ROLLBACK_IMAGE="$(python3 - "$BACKUP_DIR/manifest.json"' in rollback
|
||||
assert "geointel-all-in-one:previous" not in release + rollback + restore
|
||||
|
||||
|
||||
def test_readiness_checks_all_release_shell_entrypoints() -> None:
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
|
||||
@@ -107,6 +196,7 @@ def test_readiness_checks_all_release_shell_entrypoints() -> None:
|
||||
"deploy/unraid/run-dockerman-container.sh",
|
||||
"deploy/unraid/deploy-release.sh",
|
||||
"deploy/unraid/rollback-dockerman-container.sh",
|
||||
"deploy/unraid/restore-predeploy-database.sh",
|
||||
):
|
||||
assert f"bash -n {path}" in readiness
|
||||
|
||||
|
||||
@@ -48,25 +48,95 @@ def test_ci_runs_complete_release_and_supply_chain_gates() -> None:
|
||||
assert "pip-audit==2.10.1" in workflow
|
||||
assert "audit_python_dependencies.sh" in workflow
|
||||
assert "npm audit --audit-level=high" in workflow
|
||||
assert "GEOINTEL_INSTALL_AI=false" in workflow
|
||||
assert "GEOINTEL_INSTALL_AI=true" in workflow
|
||||
assert "geointel-ci:$RELEASE_SHA-ai" in workflow
|
||||
assert "artifacts/image-id.txt" in workflow
|
||||
assert 'scan_container_image.sh "$IMAGE_ID"' in workflow
|
||||
assert "generate_container_sbom.sh" in workflow
|
||||
assert "scan_container_image.sh" in workflow
|
||||
assert "actions/upload-artifact@v4" in workflow
|
||||
assert "GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar" in workflow
|
||||
assert "Remove temporary image archive" in workflow
|
||||
assert context in workflow
|
||||
|
||||
assert (
|
||||
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"
|
||||
in read(".github/workflows/release-gates.yml")
|
||||
)
|
||||
assert (
|
||||
"actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de"
|
||||
in read(".gitea/workflows/release-gates.yml")
|
||||
)
|
||||
|
||||
|
||||
def test_gitea_production_deploy_depends_on_every_release_gate() -> None:
|
||||
release = read(".gitea/workflows/release-gates.yml")
|
||||
legacy_deploy = ROOT / ".gitea" / "workflows" / "unraid-deploy.yml"
|
||||
|
||||
assert "pull_request:" in release
|
||||
assert "needs: [quality, dependency-audit, container]" in release
|
||||
assert "gitea.event_name == 'push'" in release
|
||||
assert "gitea.ref == 'refs/heads/main'" in release
|
||||
assert "/opt/gitea-deploy/deploy.py deploy" in release
|
||||
assert not legacy_deploy.exists()
|
||||
assert "workflow_dispatch:" in release
|
||||
|
||||
|
||||
def test_release_workflows_pin_third_party_actions_to_reviewed_commits() -> None:
|
||||
shared = (
|
||||
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
|
||||
"actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065",
|
||||
"actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020",
|
||||
)
|
||||
for path in (".gitea/workflows/release-gates.yml", ".github/workflows/release-gates.yml"):
|
||||
workflow = read(path)
|
||||
for action in shared:
|
||||
assert action in workflow
|
||||
|
||||
assert (
|
||||
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"
|
||||
in read(".github/workflows/release-gates.yml")
|
||||
)
|
||||
assert (
|
||||
"actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de"
|
||||
in read(".gitea/workflows/release-gates.yml")
|
||||
)
|
||||
|
||||
|
||||
def test_managed_validation_targets_the_actual_backend_and_frontend_projects() -> None:
|
||||
workflow = read(".gitea/workflows/managed-validation.yml")
|
||||
|
||||
assert "backend/requirements-ci.lock" in workflow
|
||||
assert "frontend/package-lock.json" in workflow
|
||||
assert "python -m pytest -W error::DeprecationWarning" in workflow
|
||||
assert "cd frontend && npm run test:unit" in workflow
|
||||
assert "python -m ruff check backend scripts tests" in workflow
|
||||
assert "python scripts/verify_repository_layout.py" in workflow
|
||||
assert "[[ -f pyproject.toml" not in workflow
|
||||
|
||||
|
||||
def test_scanner_images_are_versioned_and_digest_pinned() -> None:
|
||||
sbom = read("scripts/generate_container_sbom.sh")
|
||||
scan = read("scripts/scan_container_image.sh")
|
||||
|
||||
assert "anchore/syft:v1.44.0@sha256:" in sbom
|
||||
assert "--user 0:0" in sbom
|
||||
assert 'docker save "$IMAGE_ID"' in sbom
|
||||
assert '"docker-archive:$WORKDIR/$IMAGE_ARCHIVE"' in sbom
|
||||
assert 'SYFT_PARALLELISM=${SYFT_PARALLELISM:-1}' in sbom
|
||||
assert "--select-catalogers=-binary" in sbom
|
||||
assert '--volumes-from "$HOSTNAME"' in sbom
|
||||
assert 'ARCHIVE_ID_FILE="${IMAGE_ARCHIVE}.image-id"' in sbom
|
||||
assert "/var/run/docker.sock" not in sbom
|
||||
assert "aquasec/trivy:0.70.0@sha256:" in scan
|
||||
assert "--severity HIGH,CRITICAL" in scan
|
||||
assert "--ignore-unfixed" in scan
|
||||
assert "--timeout 20m" in scan
|
||||
assert "--scanners vuln" in scan
|
||||
assert '-v "$IGNORE_FILE:$CONTAINER_IGNORE_FILE:ro"' in scan
|
||||
assert 'docker save "$IMAGE_ID"' in scan
|
||||
assert '--input "$WORKDIR/$IMAGE_ARCHIVE"' in scan
|
||||
assert '--volumes-from "$HOSTNAME"' in scan
|
||||
assert '--ignorefile "$CONTAINER_IGNORE_FILE"' in scan
|
||||
assert "/var/run/docker.sock" not in scan
|
||||
assert "--skip-files /usr/local/bin/gosu" in scan
|
||||
assert "final filesystem replaces it with the audited setpriv shell wrapper" in scan
|
||||
assert "geointel-container-vulnerabilities.json" in scan
|
||||
@@ -86,11 +156,12 @@ def test_readiness_guards_lock_and_supply_chain_entrypoints() -> None:
|
||||
assert f"bash -n {path}" in readiness
|
||||
|
||||
|
||||
def test_python_audit_exceptions_are_timeboxed_and_full_evidence_is_kept() -> None:
|
||||
def test_python_audit_policy_has_no_active_exceptions_and_keeps_full_evidence() -> None:
|
||||
policy = read("security/pip-audit-exceptions.json")
|
||||
audit_script = read("scripts/audit_python_dependencies.sh")
|
||||
|
||||
assert '"review_by": "2026-08-31"' in policy
|
||||
assert '"schema_version": 1' in policy
|
||||
assert '"advisories": []' in policy
|
||||
assert "pip-audit-full.json" in audit_script
|
||||
assert "pip-audit-policy.json" in audit_script
|
||||
assert "--ignore-vuln" in audit_script
|
||||
|
||||
@@ -20,12 +20,35 @@ def test_backup_is_atomic_read_only_and_checksum_bound() -> None:
|
||||
assert "--no-owner" in script
|
||||
assert "CHECKSUMS.sha256" in script
|
||||
assert "database-password" not in script.lower()
|
||||
assert 'git -C "$ROOT" rev-parse HEAD' in script
|
||||
assert 'git -C "$ROOT" status --porcelain=v1' in script
|
||||
assert 'for required in docker python3 sha256sum; do' in script
|
||||
assert 'for required in docker python3 sha256sum git; do' not in script
|
||||
assert "GITEA_COMMIT_SHA" in script
|
||||
assert "GITHUB_SHA" in script
|
||||
assert "GEOINTEL_BUILD_SHA" in script
|
||||
assert 'if command -v git >/dev/null 2>&1' in script
|
||||
assert "mv \"$PARTIAL\" \"$FINAL\"" in script
|
||||
assert "rm -rf -- \"$PARTIAL\"" in script
|
||||
assert "DROP DATABASE" not in script
|
||||
assert "pg_restore --clean" not in script
|
||||
assert "/mnt/user/appdata/geointel/backups" in script
|
||||
assert "release_backup_snapshot.py" in script
|
||||
assert "storage-snapshot" not in script # labels are composed without unsafe path interpolation
|
||||
assert "--link-dest-backup" in script
|
||||
assert "--rollback-image-tag" in script
|
||||
assert '"rollback_image_tag": ${ROLLBACK_IMAGE_TAG@Q} or None' in script
|
||||
|
||||
|
||||
def test_backup_binds_prepared_source_without_requiring_dot_git() -> None:
|
||||
script = read("backup_release_state.sh")
|
||||
|
||||
controller_resolution = script.index('local gitea_sha="${GITEA_COMMIT_SHA:-}"')
|
||||
optional_git_fallback = script.index('if command -v git >/dev/null 2>&1')
|
||||
docker_access = script.index("docker inspect -f '{{.State.Running}}'")
|
||||
assert controller_resolution < optional_git_fallback < docker_access
|
||||
assert 'SOURCE_REVISION="$explicit_sha"' in script
|
||||
assert '"backup_tool_revision": ${SOURCE_REVISION@Q}' in script
|
||||
assert '"running_image_revision": ${RUNNING_IMAGE_REVISION@Q}' in script
|
||||
assert "Cannot bind backup to a source revision" in script
|
||||
|
||||
|
||||
def test_backup_verification_is_read_only() -> None:
|
||||
@@ -55,9 +78,11 @@ def test_release_safety_scripts_have_valid_bash_syntax() -> None:
|
||||
"backup_release_state.sh",
|
||||
"verify_release_backup.sh",
|
||||
"restore_release_backup_smoke.sh",
|
||||
"../deploy/unraid/restore-predeploy-database.sh",
|
||||
):
|
||||
script_path = f"scripts/{name}" if not name.startswith("../") else name.removeprefix("../")
|
||||
result = subprocess.run(
|
||||
["bash", "-n", f"scripts/{name}"],
|
||||
["bash", "-n", script_path],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -66,6 +91,30 @@ def test_release_safety_scripts_have_valid_bash_syntax() -> None:
|
||||
assert result.returncode == 0, f"{name}: {result.stderr}"
|
||||
|
||||
|
||||
def test_production_restore_is_explicit_bounded_and_verified() -> None:
|
||||
script = (ROOT / "deploy" / "unraid" / "restore-predeploy-database.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "--confirm-production-database-restore" in script
|
||||
assert "/mnt/user/appdata/geointel/backups" in script
|
||||
assert "backup.relative_to(root)" in script
|
||||
assert "sha256sum -c CHECKSUMS.sha256" in script
|
||||
assert '"$RESTORE_PROOF_DB"' in script
|
||||
assert "pg_restore" in script
|
||||
assert "Restored Alembic head" in script
|
||||
assert "Restored count mismatch" in script
|
||||
assert "pg_restore --clean" not in script
|
||||
assert "geointel_restore_proof_" in script
|
||||
assert "Isolated predeploy restore proof passed" in script
|
||||
assert "ALTER DATABASE" in script
|
||||
assert "Pre-restore production database retained" in script
|
||||
drop_start = script.index("dropdb --if-exists --force")
|
||||
drop_command = script[drop_start : script.index("\n fi", drop_start)]
|
||||
assert '"$RESTORE_PROOF_DB"' in drop_command
|
||||
assert '"$GEOINTEL_POSTGRES_DB"' not in drop_command
|
||||
|
||||
|
||||
def test_readiness_gate_checks_release_safety_scripts() -> None:
|
||||
readiness = read("run_readiness_check.sh")
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_backend_keeps_starlette_on_the_supported_pre_httpx2_line() -> None:
|
||||
def test_backend_uses_patched_starlette_and_explicit_httpx2_test_client() -> None:
|
||||
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
content = pyproject.read_text(encoding="utf-8")
|
||||
|
||||
assert '"starlette>=0.46.0,<1.0.0"' in content
|
||||
assert '"starlette>=1.3.1,<2.0.0"' in content
|
||||
assert '"httpx2>=2.0.0"' in content
|
||||
|
||||
|
||||
def test_readiness_gate_treats_deprecation_warnings_as_errors() -> None:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "release_backup_snapshot.py"
|
||||
|
||||
|
||||
def load_snapshot_module():
|
||||
spec = importlib.util.spec_from_file_location("release_backup_snapshot_test", SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_snapshot_is_byte_complete_and_reuses_only_verified_backup_bytes(tmp_path: Path) -> None:
|
||||
snapshot = load_snapshot_module()
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "same.bin").write_bytes(b"unchanged")
|
||||
(source / "changed.bin").write_bytes(b"before")
|
||||
(source / "empty").mkdir()
|
||||
|
||||
prior = tmp_path / "prior"
|
||||
prior_manifest = tmp_path / "prior.tsv"
|
||||
snapshot.create_snapshot(source, prior, prior_manifest, label="storage")
|
||||
snapshot.verify_snapshot(prior, prior_manifest)
|
||||
|
||||
(source / "changed.bin").write_bytes(b"after")
|
||||
current = tmp_path / "current"
|
||||
current_manifest = tmp_path / "current.tsv"
|
||||
snapshot.create_snapshot(
|
||||
source,
|
||||
current,
|
||||
current_manifest,
|
||||
label="storage",
|
||||
link_dest_snapshot=prior,
|
||||
link_dest_manifest=prior_manifest,
|
||||
)
|
||||
snapshot.verify_snapshot(current, current_manifest)
|
||||
|
||||
assert os.path.samefile(prior / "same.bin", current / "same.bin")
|
||||
assert not os.path.samefile(prior / "changed.bin", current / "changed.bin")
|
||||
assert (current / "changed.bin").read_bytes() == b"after"
|
||||
assert (current / "empty").is_dir()
|
||||
|
||||
|
||||
def test_snapshot_rejects_symlinked_content(tmp_path: Path) -> None:
|
||||
snapshot = load_snapshot_module()
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
target = source / "target.bin"
|
||||
target.write_bytes(b"target")
|
||||
try:
|
||||
(source / "link.bin").symlink_to(target)
|
||||
except OSError:
|
||||
pytest.skip("Symlink creation is unavailable on this host")
|
||||
|
||||
with pytest.raises(RuntimeError, match="refuses symlinked content"):
|
||||
snapshot.create_snapshot(source, tmp_path / "snapshot", tmp_path / "manifest.tsv", label="storage")
|
||||
|
||||
|
||||
def test_snapshot_verification_rejects_changed_retained_bytes(tmp_path: Path) -> None:
|
||||
snapshot = load_snapshot_module()
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "artifact.bin").write_bytes(b"retained")
|
||||
retained = tmp_path / "snapshot"
|
||||
manifest = tmp_path / "manifest.tsv"
|
||||
snapshot.create_snapshot(source, retained, manifest, label="storage")
|
||||
(retained / "artifact.bin").chmod(0o644)
|
||||
(retained / "artifact.bin").write_bytes(b"tampered")
|
||||
|
||||
with pytest.raises(RuntimeError, match="checksum differs"):
|
||||
snapshot.verify_snapshot(retained, manifest)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "verify_repository_layout.py"
|
||||
SPEC = importlib.util.spec_from_file_location("verify_repository_layout", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def test_nested_repository_mirror_is_absent() -> None:
|
||||
assert MODULE.nested_mirror_markers(ROOT) == []
|
||||
|
||||
|
||||
def test_nested_repository_mirror_is_detected(tmp_path: Path) -> None:
|
||||
for marker in MODULE.CANONICAL_MARKERS:
|
||||
path = tmp_path / "geointel" / marker
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("fixture", encoding="utf-8")
|
||||
|
||||
assert MODULE.nested_mirror_markers(tmp_path) == list(MODULE.CANONICAL_MARKERS)
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
@@ -6,8 +7,16 @@ from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_invalid_host_request_target_is_rejected_canonically() -> None:
|
||||
response = client.get("/health/live", headers={"host": "trusted.example/@admin"})
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
[
|
||||
"trusted.example/@admin",
|
||||
"trusted.example?shadow=admin",
|
||||
"trusted.example#shadow",
|
||||
],
|
||||
)
|
||||
def test_invalid_host_request_target_is_rejected_canonically(host: str) -> None:
|
||||
response = client.get("/health/live", headers={"host": host})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["x-request-id"]
|
||||
@@ -15,6 +24,16 @@ def test_invalid_host_request_target_is_rejected_canonically() -> None:
|
||||
assert response.json()["request_id"] == response.headers["x-request-id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
["localhost:1202", "127.0.0.1:8000", "[::1]:8000", "testserver"],
|
||||
)
|
||||
def test_normal_host_forms_remain_available(host: str) -> None:
|
||||
response = client.get("/health/live", headers={"host": host})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/datasets/upload",
|
||||
|
||||
@@ -9,11 +9,12 @@ import pytest
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models import Dataset, Project, Segmentation, SourceRegistry, SourceSnapshot
|
||||
from app.models import Dataset, DatasetVersion, Project, Segmentation, SourceRegistry, SourceSnapshot
|
||||
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.services.tile_manifest_service import TileManifestService
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -122,6 +123,8 @@ def _project_and_dataset(dataset_type: str = "raster"):
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
storage_path="storage/uploads/ortho.tif",
|
||||
checksum_sha256=checksum,
|
||||
crs="EPSG:4326",
|
||||
bounds_json={"min_x": 4.0, "min_y": 51.0, "max_x": 5.0, "max_y": 52.0},
|
||||
source_registry_id=source_id,
|
||||
source_snapshot_id=snapshot_id,
|
||||
data_contract_key="geointel.raster.geotiff",
|
||||
@@ -134,6 +137,9 @@ def _project_and_dataset(dataset_type: str = "raster"):
|
||||
)
|
||||
dataset.source_registry = source
|
||||
dataset.source_snapshot = snapshot
|
||||
dataset.versions.append(
|
||||
DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1, checksum_sha256=checksum)
|
||||
)
|
||||
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
||||
return db, project_id, dataset_id
|
||||
|
||||
@@ -249,28 +255,36 @@ def _write_configured_model_sidecars(
|
||||
)
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
|
||||
def _manifest(
|
||||
tmp_path: Path,
|
||||
tile_count: int = 1,
|
||||
*,
|
||||
db: FakeSession | None = None,
|
||||
dataset: Dataset | None = None,
|
||||
) -> Path:
|
||||
tiles = []
|
||||
for index in range(tile_count):
|
||||
tile_path = tmp_path / f"tile_{index:04d}.tif"
|
||||
tile_path.write_bytes(b"fixture")
|
||||
tiles.append(
|
||||
{
|
||||
tile = {
|
||||
"path": str(tile_path),
|
||||
"pixel_window": [0, 0, 100, 100],
|
||||
"bounds": [4.0, 51.0, 5.0, 52.0],
|
||||
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
||||
"crs": "EPSG:4326",
|
||||
"index": index,
|
||||
**TileManifestService.tile_integrity(tile_path),
|
||||
}
|
||||
)
|
||||
tiles.append(tile)
|
||||
binding = TileManifestService.dataset_binding(db or FakeSession(), dataset) if dataset is not None else {}
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**binding,
|
||||
"tile_set_id": "tiles-fixture",
|
||||
"source_dataset_id": str(uuid4()),
|
||||
"source_raster_id": str(uuid4()),
|
||||
"source_dataset_id": binding.get("source_dataset_id", str(uuid4())),
|
||||
"source_raster_id": binding.get("source_raster_id", str(uuid4())),
|
||||
"crs": "EPSG:4326",
|
||||
"bounds": [4.0, 51.0, 5.0, 52.0],
|
||||
"tile_size": 100,
|
||||
@@ -424,7 +438,9 @@ def test_configured_segmentation_rejects_unbound_model_snapshot_before_adapter_l
|
||||
dataset_id=dataset_id,
|
||||
model_id="yolo-seg-configured",
|
||||
confidence_threshold=0.5,
|
||||
tile_manifest_path=str(_manifest(tmp_path)),
|
||||
tile_manifest_path=str(
|
||||
_manifest(tmp_path, db=db, dataset=db.get(Dataset, dataset_id))
|
||||
),
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=NeverLoadSegAdapter,
|
||||
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||
@@ -440,7 +456,7 @@ def test_configured_yolo_seg_run_persists_georeferenced_masks(tmp_path: Path) ->
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
settings = _settings(tmp_path)
|
||||
_write_configured_model_sidecars(tmp_path, settings, include_sam=False, db=db)
|
||||
manifest_path = _manifest(tmp_path)
|
||||
manifest_path = _manifest(tmp_path, db=db, dataset=db.get(Dataset, dataset_id))
|
||||
|
||||
response = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
@@ -479,7 +495,7 @@ def test_configured_sam_run_is_class_agnostic(tmp_path: Path) -> None:
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
settings = _settings(tmp_path)
|
||||
_write_configured_model_sidecars(tmp_path, settings, include_yolo=False, db=db)
|
||||
manifest_path = _manifest(tmp_path)
|
||||
manifest_path = _manifest(tmp_path, db=db, dataset=db.get(Dataset, dataset_id))
|
||||
|
||||
response = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
|
||||
@@ -15,14 +15,18 @@ import pytest
|
||||
np = pytest.importorskip("numpy")
|
||||
rasterio = pytest.importorskip("rasterio")
|
||||
|
||||
from pyproj import Transformer
|
||||
from rasterio.transform import from_origin
|
||||
from pyproj import Transformer # noqa: E402 - optional rasterio gate precedes geospatial imports
|
||||
from rasterio.transform import from_origin # noqa: E402 - optional rasterio gate precedes geospatial imports
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models import Dataset
|
||||
from app.schemas.flood_hazard import FloodHazardSelectionRequest
|
||||
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
|
||||
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
|
||||
from app.core.config import Settings # noqa: E402 - optional rasterio gate precedes app imports
|
||||
from app.models import Dataset # noqa: E402 - optional rasterio gate precedes app imports
|
||||
from app.schemas.flood_hazard import FloodHazardSelectionRequest # noqa: E402 - optional rasterio gate precedes app imports
|
||||
from app.services.flood_hazard_acquisition_service import ( # noqa: E402 - optional rasterio gate precedes app imports
|
||||
FloodHazardAcquisitionService,
|
||||
)
|
||||
from app.services.flood_hazard_analysis_service import ( # noqa: E402 - optional rasterio gate precedes app imports
|
||||
FloodHazardAnalysisService,
|
||||
)
|
||||
|
||||
|
||||
TO_4326 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
|
||||
|
||||
@@ -14,7 +14,10 @@ def test_raster_tile_manifest_can_handoff_to_segmentation_lab() -> None:
|
||||
|
||||
assert "segmentationTileManifestPath" in hook
|
||||
assert "setSegmentationTileManifestPath" in hook
|
||||
assert "tile_manifest_path: segmentationTileManifestPath.trim() || null" in hook
|
||||
assert "let manifestPath = segmentationTileManifestPath.trim()" in hook
|
||||
assert "datasetsApi.rasterInspect(projectId, datasetId)" in hook
|
||||
assert "datasetsApi.rasterTile(projectId, datasetId" in hook
|
||||
assert "tile_manifest_path: manifestPath" in hook
|
||||
assert "segmentationTileManifestPath={segmentationTileManifestPath}" in app
|
||||
assert "onSetTileManifestPath={setSegmentationTileManifestPath}" in app
|
||||
assert "onUseTileManifestForSegmentation: useRasterTileManifestForSegmentation" in app
|
||||
@@ -24,4 +27,3 @@ def test_raster_tile_manifest_can_handoff_to_segmentation_lab() -> None:
|
||||
assert "Gebruik voor segmentatie" in raster_controls
|
||||
assert "disabled={!latestRasterTileManifestPath}" in raster_controls
|
||||
assert "Beeldtegelmanifest" in segmentation_lab
|
||||
assert "Beeldtegelmanifest" in segmentation_lab
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -11,7 +10,7 @@ from shapely.geometry import Polygon, box
|
||||
from app.core.errors import AppError
|
||||
from app.models import Dataset, VectorFeature
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
from tests.frontend_contract import assert_calls, assert_wired, read_map_workspace, read_feature
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -12,8 +12,12 @@ class FakeUploadFile:
|
||||
filename = "real-orthophoto.tif"
|
||||
content_type = "image/tiff"
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return b"fake-raster"
|
||||
def __init__(self) -> None:
|
||||
self._content = b"fake-raster"
|
||||
|
||||
async def read(self, size: int) -> bytes:
|
||||
chunk, self._content = self._content[:size], self._content[size:]
|
||||
return chunk
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -40,16 +44,19 @@ def test_raster_upload_maps_metadata_bounds_resolution_and_bands(monkeypatch) ->
|
||||
project_id = uuid4()
|
||||
db = FakeSession(project_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.dataset_service.StorageService.persist_dataset_file",
|
||||
lambda **_: {
|
||||
async def persist_upload_file(**_kwargs):
|
||||
return {
|
||||
"storage_path": "/tmp/real-orthophoto.tif",
|
||||
"original_filename": "real-orthophoto.tif",
|
||||
"stored_filename": "real-orthophoto.tif",
|
||||
"content_type": "image/tiff",
|
||||
"size_bytes": 11,
|
||||
"checksum_sha256": "checksum",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.dataset_service.StorageService.persist_upload_file",
|
||||
persist_upload_file,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.dataset_service.extract_raster_metadata",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -51,11 +52,27 @@ def _write_report(path: Path, *, promotion_status: str = "promote_candidate") ->
|
||||
)
|
||||
|
||||
|
||||
def _write_governed_release_gate(path: Path, model_file: Path, **overrides: object) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"status": "pass",
|
||||
"product_benchmark_status": "pass",
|
||||
"promotion_allowed": True,
|
||||
"phase_decision": "ready",
|
||||
"candidate_key": CANDIDATE_KEY,
|
||||
"candidate_model_sha256": hashlib.sha256(model_file.read_bytes()).hexdigest(),
|
||||
"benchmark_manifest_sha256": "b" * 64,
|
||||
}
|
||||
payload.update(overrides)
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _run_activation(tmp_path: Path, *extra_args: str) -> subprocess.CompletedProcess[str]:
|
||||
models_dir = tmp_path / "models"
|
||||
_write_model(models_dir)
|
||||
model_file = _write_model(models_dir)
|
||||
report_path = tmp_path / "promotion_report.json"
|
||||
_write_report(report_path)
|
||||
release_gate_path = tmp_path / "phase4-release-gate.json"
|
||||
_write_governed_release_gate(release_gate_path, model_file)
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("GEOINTEL_ENV=production\nYOLO_ENABLED=false\n", encoding="utf-8")
|
||||
|
||||
@@ -67,6 +84,8 @@ def _run_activation(tmp_path: Path, *extra_args: str) -> subprocess.CompletedPro
|
||||
str(report_path),
|
||||
"--candidate-key",
|
||||
CANDIDATE_KEY,
|
||||
"--phase4-release-gate-report",
|
||||
str(release_gate_path),
|
||||
"--models-dir",
|
||||
str(models_dir),
|
||||
"--container-model-dir",
|
||||
@@ -94,6 +113,9 @@ def test_promoted_yolo_activation_dry_run_validates_report_and_model(tmp_path: P
|
||||
assert payload["will_download_models"] is False
|
||||
assert payload["candidate"]["candidate_key"] == CANDIDATE_KEY
|
||||
assert payload["candidate"]["threshold"] == 0.35
|
||||
assert payload["selected_model_sha256"] == hashlib.sha256(
|
||||
(tmp_path / "models" / "geointel-building-yolov8s-aoi1024bg512r3e50.pt").read_bytes()
|
||||
).hexdigest()
|
||||
assert payload["env_updates"]["YOLO_ENABLED"] == "true"
|
||||
assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/geointel-building-yolov8s-aoi1024bg512r3e50.pt"
|
||||
|
||||
@@ -114,9 +136,11 @@ def test_promoted_yolo_activation_apply_updates_env_file(tmp_path: Path) -> None
|
||||
|
||||
def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) -> None:
|
||||
models_dir = tmp_path / "models"
|
||||
_write_model(models_dir)
|
||||
model_file = _write_model(models_dir)
|
||||
report_path = tmp_path / "promotion_report.json"
|
||||
_write_report(report_path, promotion_status="reject")
|
||||
release_gate_path = tmp_path / "phase4-release-gate.json"
|
||||
_write_governed_release_gate(release_gate_path, model_file)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
@@ -126,6 +150,8 @@ def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) ->
|
||||
str(report_path),
|
||||
"--candidate-key",
|
||||
CANDIDATE_KEY,
|
||||
"--phase4-release-gate-report",
|
||||
str(release_gate_path),
|
||||
"--models-dir",
|
||||
str(models_dir),
|
||||
"--env-file",
|
||||
@@ -145,6 +171,52 @@ def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) ->
|
||||
assert "rejection_reasons" in payload
|
||||
|
||||
|
||||
def test_promoted_yolo_activation_rejects_failed_or_unbound_phase4_gate(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
models_dir = tmp_path / "models"
|
||||
model_file = _write_model(models_dir)
|
||||
report_path = tmp_path / "promotion_report.json"
|
||||
_write_report(report_path)
|
||||
release_gate_path = tmp_path / "phase4-release-gate.json"
|
||||
_write_governed_release_gate(
|
||||
release_gate_path,
|
||||
model_file,
|
||||
status="fail",
|
||||
promotion_allowed=False,
|
||||
candidate_model_sha256="0" * 64,
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python",
|
||||
str(SCRIPT),
|
||||
"--promotion-report",
|
||||
str(report_path),
|
||||
"--phase4-release-gate-report",
|
||||
str(release_gate_path),
|
||||
"--candidate-key",
|
||||
CANDIDATE_KEY,
|
||||
"--models-dir",
|
||||
str(models_dir),
|
||||
"--env-file",
|
||||
str(tmp_path / ".env"),
|
||||
"--json",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 3
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "governed_release_not_approved"
|
||||
assert "phase4_release_gate_not_passed" in payload["rejection_reasons"]
|
||||
assert "governed_candidate_model_sha256_mismatch" in payload["rejection_reasons"]
|
||||
|
||||
|
||||
def test_readiness_gate_compiles_promoted_activation_script() -> None:
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_frontend_declares_national_scope_as_primary_operating_focus() -> None:
|
||||
navigation = read_feature("shell")
|
||||
|
||||
assert "NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'" in focus
|
||||
assert "NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'" in focus
|
||||
assert "NATIONAL_WORKSPACE_REGION = 'België en Belgische Noordzee'" in focus
|
||||
assert "NATIONAL_MAP_CENTER" in focus
|
||||
assert "return nationalProject.id" in project_hook
|
||||
assert "hasMappedAnalysisContext(data)" in project_hook
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
from tests.frontend_contract import assert_calls, assert_wired, read_map_workspace, read_feature
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -137,7 +137,8 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None:
|
||||
assert "Kempen (28 gemeenten)" in workspace
|
||||
assert 'aria-label="Regio"' not in workspace
|
||||
assert 'aria-label="Ingeladen regiobereik"' in workspace
|
||||
assert "Zoek optioneel een gemeente" in workspace
|
||||
assert 'aria-label="Optioneel een gemeente zoeken"' in workspace
|
||||
assert 'placeholder="Gemeentenaam of NIS-code"' in workspace
|
||||
assert "projects={projects}" in app
|
||||
map_props = app.split("<MapWorkspace", maxsplit=1)[1].split("/>", maxsplit=1)[0]
|
||||
assert "onSelectProject={selectProject}" not in map_props
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
from tests.frontend_contract import assert_calls, read_map_workspace, read_feature
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -20,7 +20,8 @@ def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> Non
|
||||
assert "const municipalityProject" not in project_hook
|
||||
assert 'aria-label="Regio"' not in map_workspace
|
||||
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
|
||||
assert "Zoek optioneel een gemeente" in map_workspace
|
||||
assert 'aria-label="Optioneel een gemeente zoeken"' in map_workspace
|
||||
assert 'placeholder="Gemeentenaam of NIS-code"' in map_workspace
|
||||
assert 'aria-label="Werkgebied"' in map_workspace
|
||||
|
||||
|
||||
|
||||
@@ -251,7 +251,8 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
|
||||
assert "statbel: 'Statbel'" in display
|
||||
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
|
||||
assert "getDatasetSourceDisplayName(resultDataset)" in workspace
|
||||
assert "Zoek optioneel een gemeente" in workspace
|
||||
assert 'aria-label="Optioneel een gemeente zoeken"' in workspace
|
||||
assert 'placeholder="Gemeentenaam of NIS-code"' in workspace
|
||||
assert "latestDatasetBySeries" in catalog
|
||||
assert "Historische meetmomenten" in catalog
|
||||
assert "getDatasetSourceDisplayName(dataset)" in catalog
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models import Area, Dataset, DatasetVersion, Job, Project, SourceRegistry, SourceSnapshot
|
||||
from app.schemas.orthophoto import OrthophotoAcquireRequest
|
||||
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
||||
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
|
||||
from tests.frontend_contract import read_feature
|
||||
|
||||
@@ -262,6 +263,9 @@ def test_regional_orthophoto_products_bind_provider_and_governed_scope(
|
||||
assert snapshot.checksum_sha256 == dataset.checksum_sha256
|
||||
assert snapshot.ingest_status == "ingested"
|
||||
assert snapshot.freshness_status == "current"
|
||||
dataset.source_registry = source
|
||||
dataset.source_snapshot = snapshot
|
||||
assert DatasetConsumptionGate.assert_eligible(dataset, purpose="production_inference").eligible is True
|
||||
|
||||
prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="1971"), settings)
|
||||
assert prepared["params"]["LAYERS"] == "OKZPAN71VL"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
|
||||
from tests.frontend_contract import assert_wired, read_map_workspace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -7,7 +7,6 @@ from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from fastapi.testclient import TestClient
|
||||
from pyproj import Transformer
|
||||
from rasterio.io import MemoryFile
|
||||
|
||||
@@ -252,7 +252,6 @@ def test_refresh_api_and_frontend_remain_explicit_only() -> None:
|
||||
|
||||
|
||||
def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workspace = read_map_workspace()
|
||||
observed_sort = workspace.index("const observedAtDifference")
|
||||
feature_tiebreaker = workspace.index("right.feature_count", observed_sort)
|
||||
@@ -262,7 +261,6 @@ def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> Non
|
||||
|
||||
|
||||
def test_map_workspace_restores_theme_from_selected_dataset() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workspace = read_map_workspace()
|
||||
assert "function themeIdForDataset(" in workspace
|
||||
assert "useState<DataThemeId>(() =>" in workspace
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.services.storage_service import StorageService
|
||||
from app.services.source_registry_service import SourceRegistryService
|
||||
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
from tests.frontend_contract import assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -418,7 +418,6 @@ def test_frontend_fetches_canonical_workspace_outside_default_project_page() ->
|
||||
|
||||
|
||||
def test_theme_failures_name_the_source_and_reason() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
hook = read_feature("map_workspace")
|
||||
|
||||
assert "queries[index]?.dataset?.name" in hook
|
||||
@@ -428,7 +427,6 @@ def test_theme_failures_name_the_source_and_reason() -> None:
|
||||
|
||||
|
||||
def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
app = read_feature("shell")
|
||||
|
||||
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
|
||||
@@ -438,7 +436,6 @@ def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
|
||||
|
||||
|
||||
def test_quality_scores_have_plain_language_interpretation() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
quality = read_feature("quality")
|
||||
detection = read_feature("detection")
|
||||
|
||||
@@ -471,7 +468,9 @@ def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
|
||||
assert "department_omgeving_thematic_raster" in capability_source
|
||||
assert "digitaal_vlaanderen_dhmv" in capability_source
|
||||
assert "vmm_flood_hazard" in capability_source
|
||||
assert "dataset.dataset_type !== 'raster' || dataset.status !== 'ready'" in capability_source
|
||||
assert "dataset.dataset_type !== 'raster'" in capability_source
|
||||
assert "dataset.status !== 'ready'" in capability_source
|
||||
assert "return datasetInferenceBlockReason(dataset) === null" in capability_source
|
||||
assert "const detectionRasterDatasets = useMemo(" in app_source
|
||||
assert "rasterDatasets: detectionRasterDatasets" in app_source
|
||||
assert "rasterDatasets={detectionRasterDatasets}" in app_source
|
||||
@@ -479,7 +478,6 @@ def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
|
||||
|
||||
|
||||
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
exports = read_feature("exports")
|
||||
|
||||
assert "Gebiedsanalyse (JSON)" in exports
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import sys
|
||||
@@ -31,7 +30,7 @@ if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import provision_flanders_geographic_scope as flanders_scope # noqa: E402
|
||||
from tests.frontend_contract import read_map_workspace, read_feature
|
||||
from tests.frontend_contract import read_map_workspace, read_feature # noqa: E402
|
||||
|
||||
|
||||
class BinaryResponse:
|
||||
@@ -136,6 +135,32 @@ def test_mdk_probe_parses_capabilities_without_enabling_acquisition() -> None:
|
||||
assert seen["timeout"] == 20
|
||||
|
||||
|
||||
def test_mdk_probe_default_opener_uses_guarded_strict_tls_path(monkeypatch) -> None:
|
||||
seen = {}
|
||||
|
||||
def guarded_factory(expected_url):
|
||||
seen["expected_url"] = expected_url
|
||||
|
||||
def open_request(request, timeout):
|
||||
seen["request_url"] = request.full_url
|
||||
seen["timeout"] = timeout
|
||||
return BinaryResponse(capabilities_xml())
|
||||
|
||||
return open_request
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mdk_bathymetry_probe_service.guarded_opener",
|
||||
guarded_factory,
|
||||
)
|
||||
|
||||
result = MdkBathymetryProbeService.probe(settings=Settings(_env_file=None))
|
||||
|
||||
assert result["status"] == "reachable"
|
||||
assert seen["expected_url"] == seen["request_url"]
|
||||
assert seen["expected_url"].startswith("https://")
|
||||
assert seen["timeout"] == 20
|
||||
|
||||
|
||||
def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None:
|
||||
calls = 0
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
from tests.frontend_contract import assert_wired, read_map_workspace, read_feature
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models import Area, Dataset, Job, Project
|
||||
from app.schemas.grb import GrbAcquireRequest
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.grb_acquisition_service import GrbAcquisitionService
|
||||
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
|
||||
from tests.frontend_contract import assert_wired, read_map_workspace, read_feature
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user