Harden V1 demo workflow smoke
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 00:33:09 +02:00
parent e330283fbe
commit be7705f6c5
13 changed files with 248 additions and 53 deletions
+7
View File
@@ -7,6 +7,13 @@
# Changelog # Changelog
## Sprint 21 V1 demo workflow smoke hardening (2026-06-17)
- Hardened the browser-facing demo/export smoke to verify connected V1 state: project area GeoJSON, fixture datasets, vector FeatureCollection content, vector feature summary, persisted QA/QC metrics and export downloads.
- Loading the offline demo workflow in the frontend now opens the candidate vector fixture dataset directly, so the map workbench is populated after the demo action.
- Added regression tests for the strengthened demo smoke and frontend demo dataset loading contract.
- No migrations, provider downloads, AI inference, new dependencies or API route renames were introduced.
## Sprint 20 selected area map overlay (2026-06-17) ## Sprint 20 selected area map overlay (2026-06-17)
- Added persisted AOI GeoJSON to area API responses without changing the database schema. - Added persisted AOI GeoJSON to area API responses without changing the database schema.
+66 -45
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -30,6 +31,16 @@ class DemoWorkflowService:
@staticmethod @staticmethod
def _fixture_path(filename: str) -> Path: def _fixture_path(filename: str) -> Path:
roots: list[Path] = []
if os.getenv("GEOINTEL_FIXTURES_ROOT"):
roots.append(Path(os.environ["GEOINTEL_FIXTURES_ROOT"]))
roots.extend(parent / "fixtures" / "golden" for parent in Path(__file__).resolve().parents)
roots.append(Path("/app/fixtures/golden"))
for root in roots:
path = root / filename
if path.exists():
return path
return DemoWorkflowService._repo_root() / "fixtures" / "golden" / filename return DemoWorkflowService._repo_root() / "fixtures" / "golden" / filename
@staticmethod @staticmethod
@@ -194,6 +205,8 @@ class DemoWorkflowService:
@staticmethod @staticmethod
def seed(db: Session) -> DemoWorkflowResponse: def seed(db: Session) -> DemoWorkflowResponse:
existing = DemoWorkflowService._find_existing_project(db) existing = DemoWorkflowService._find_existing_project(db)
reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson")
candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson")
if existing: if existing:
area = db.query(Area).filter(Area.project_id == existing.id).order_by(Area.created_at.asc()).first() area = db.query(Area).filter(Area.project_id == existing.id).order_by(Area.created_at.asc()).first()
reference = ( reference = (
@@ -229,51 +242,59 @@ class DemoWorkflowService:
message="Demo workflow already exists.", message="Demo workflow already exists.",
created=False, created=False,
) )
project = existing
created = True
else:
project = Project(
id=uuid4(),
name=DemoWorkflowService.PROJECT_NAME,
description="Offline fixture workflow: reference buildings, predicted buildings and persisted QA metrics.",
region="Kempen",
status="active",
)
db.add(project)
db.commit()
db.refresh(project)
area = None
reference = None
candidate = None
quality_check = None
created = True
project = Project( if not area:
id=uuid4(), area = DemoWorkflowService._create_area(db, project.id)
name=DemoWorkflowService.PROJECT_NAME, if not reference:
description="Offline fixture workflow: reference buildings, predicted buildings and persisted QA metrics.", reference = DemoWorkflowService._create_dataset(
region="Kempen", db=db,
status="active", project_id=project.id,
) area_id=area.id,
db.add(project) filename=DemoWorkflowService.REFERENCE_FILENAME,
db.commit() payload=reference_payload,
db.refresh(project) raw=reference_raw,
role="reference",
area = DemoWorkflowService._create_area(db, project.id) source_name="fixture",
reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson") reference_layer_name="buildings",
candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson") )
if not candidate:
reference = DemoWorkflowService._create_dataset( candidate = DemoWorkflowService._create_dataset(
db=db, db=db,
project_id=project.id, project_id=project.id,
area_id=area.id, area_id=area.id,
filename=DemoWorkflowService.REFERENCE_FILENAME, filename=DemoWorkflowService.CANDIDATE_FILENAME,
payload=reference_payload, payload=candidate_payload,
raw=reference_raw, raw=candidate_raw,
role="reference", role="source",
source_name="fixture", source_name="fixture",
reference_layer_name="buildings", reference_layer_name=None,
) )
candidate = DemoWorkflowService._create_dataset( if not quality_check:
db=db, quality_check = DemoWorkflowService._persist_qa(
project_id=project.id, db=db,
area_id=area.id, project_id=project.id,
filename=DemoWorkflowService.CANDIDATE_FILENAME, candidate_dataset_id=candidate.id,
payload=candidate_payload, reference_dataset_id=reference.id,
raw=candidate_raw, area_id=area.id,
role="source", )
source_name="fixture",
reference_layer_name=None,
)
quality_check = DemoWorkflowService._persist_qa(
db=db,
project_id=project.id,
candidate_dataset_id=candidate.id,
reference_dataset_id=reference.id,
area_id=area.id,
)
return DemoWorkflowResponse( return DemoWorkflowResponse(
project_id=project.id, project_id=project.id,
@@ -284,5 +305,5 @@ class DemoWorkflowService:
metric_count=6, metric_count=6,
status="ready", status="ready",
message="Demo workflow seeded from explicit local fixtures.", message="Demo workflow seeded from explicit local fixtures.",
created=True, created=created,
) )
@@ -97,6 +97,12 @@ def test_compose_waits_for_healthy_database_and_applies_migrations() -> None:
assert "sh /app/docker_start.sh" in compose assert "sh /app/docker_start.sh" in compose
def test_compose_mounts_demo_fixtures_for_backend_runtime() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "./fixtures:/app/fixtures:ro" in compose
def test_compose_has_backend_and_frontend_healthchecks() -> None: def test_compose_has_backend_and_frontend_healthchecks() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+7
View File
@@ -27,6 +27,13 @@ def test_demo_export_workflow_script_verifies_export_endpoints() -> None:
content = script.read_text(encoding="utf-8") content = script.read_text(encoding="utf-8")
assert "/api/v1/demo/workflow" in content assert "/api/v1/demo/workflow" in content
assert "/areas" in content
assert "/datasets" in content
assert "/content" in content
assert "/vector/summary" in content
assert "GeoJSON Polygon/MultiPolygon geometry" in content
assert "precision" in content
assert "false_negative_count" in content
assert "/api/v1/exports/metadata" in content assert "/api/v1/exports/metadata" in content
assert "/api/v1/exports/report" in content assert "/api/v1/exports/report" in content
assert "/api/v1/exports/geojson" in content assert "/api/v1/exports/geojson" in content
@@ -55,3 +55,14 @@ def test_demo_workflow_service_uses_explicit_golden_fixtures() -> None:
assert DemoWorkflowService.PROJECT_NAME == "GeoIntel Demo - Building QA" assert DemoWorkflowService.PROJECT_NAME == "GeoIntel Demo - Building QA"
assert DemoWorkflowService.REFERENCE_FILENAME == "demo_reference_buildings.geojson" assert DemoWorkflowService.REFERENCE_FILENAME == "demo_reference_buildings.geojson"
assert DemoWorkflowService.CANDIDATE_FILENAME == "demo_predicted_buildings.geojson" assert DemoWorkflowService.CANDIDATE_FILENAME == "demo_predicted_buildings.geojson"
def test_demo_workflow_service_supports_container_fixture_mount() -> None:
service = (DemoWorkflowService._repo_root() / "backend" / "app" / "services" / "demo_workflow_service.py").read_text(encoding="utf-8")
assert "GEOINTEL_FIXTURES_ROOT" in service
assert 'Path("/app/fixtures/golden")' in service
assert "reference_payload, reference_raw = DemoWorkflowService._load_fixture" in service
assert "project = existing" in service
assert "if not reference:" in service
assert "if not candidate:" in service
@@ -0,0 +1,26 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_demo_workflow_browser_smoke_script_checks_connected_v1_state() -> None:
script = (ROOT / "scripts" / "verify_demo_export_workflow.sh").read_text(encoding="utf-8")
assert "/api/v1/demo/workflow" in script
assert "/api/v1/projects/${project_id}/areas" in script
assert "/api/v1/projects/${project_id}/datasets" in script
assert "/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/content" in script
assert "/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/vector/summary" in script
assert "GeoJSON Polygon/MultiPolygon geometry" in script
assert "Candidate vector summary does not report persisted features" in script
def test_frontend_demo_action_loads_candidate_dataset_details_for_map_layer() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
assert "const candidateDataset = projectData?.datasets.find" in app
assert "dataset.id === result.candidate_dataset_id" in app
assert "await loadDatasetDetails(result.project_id, candidateDataset)" in app
+1
View File
@@ -24,6 +24,7 @@ services:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./storage:/app/storage - ./storage:/app/storage
- ./fixtures:/app/fixtures:ro
command: sh /app/docker_start.sh command: sh /app/docker_start.sh
depends_on: depends_on:
db: db:
+16
View File
@@ -1,3 +1,19 @@
## Sprint 21 V1 demo workflow smoke hardening (2026-06-17)
Changed:
- Hardened `scripts/verify_demo_export_workflow.sh` so the explicit offline demo smoke validates area GeoJSON, fixture datasets, vector FeatureCollection content, vector feature summaries, persisted QA/QC metrics and export downloads through the frontend proxy.
- Updated the frontend demo workflow action to open the candidate vector fixture dataset after seeding/loading the demo, so the Map Workbench is populated without a manual dataset click.
- Added regression tests for the strengthened smoke script and frontend demo loading contract.
- Updated scripts/frontend documentation, changelog and TODO status.
Tested:
- Pending validation in this pass: backend compile, backend pytest, readiness, frontend typecheck/build, Alembic checks, demo/export smoke, Tower deploy and browser audit.
Known limitations:
- The demo smoke intentionally seeds fixture demo data when run; it should be used as an explicit verification command, not as an implicit healthcheck.
Next recommended pass:
- Add a compact V1 dashboard/status strip for project, AOI, datasets, QA and exports so operators can see readiness at a glance after opening a project.
## Sprint 20 V1 selected area map overlay (2026-06-17) ## Sprint 20 V1 selected area map overlay (2026-06-17)
Changed: Changed:
+3 -3
View File
@@ -9,7 +9,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Enforce Python deprecation warnings as release-readiness failures. - [x] Enforce Python deprecation warnings as release-readiness failures.
- [x] Fix Docker backend package install order and remove mandatory root `.env` dependency. - [x] Fix Docker backend package install order and remove mandatory root `.env` dependency.
- [x] Add Docker build context ignores for backend and frontend. - [x] Add Docker build context ignores for backend and frontend.
- [ ] Run Docker/PostGIS live validation on a machine where Docker is available. - [x] Run Docker/PostGIS live validation on Tower/Unraid.
## Current implementation status ## Current implementation status
@@ -30,8 +30,8 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Project-scoped QA/QC result listing and frontend QA/QC Results panel. - [x] Project-scoped QA/QC result listing and frontend QA/QC Results panel.
- [x] Persisted export foundation for vector/detection/segmentation GeoJSON and project metadata JSON. - [x] Persisted export foundation for vector/detection/segmentation GeoJSON and project metadata JSON.
- [x] Lightweight HTML project report artifact export. - [x] Lightweight HTML project report artifact export.
- [x] Browser-facing demo/export workflow smoke script. - [x] Browser-facing demo/export workflow smoke script with connected V1 state checks.
- [ ] Live Docker/PostGIS validation in this execution environment. - [x] Live Docker/PostGIS validation on Tower/Unraid.
- [ ] Real YOLO compatibility smoke with optional AI extras and local model file. - [ ] Real YOLO compatibility smoke with optional AI extras and local model file.
- [ ] Further frontend state/module decomposition beyond Sprint 10 extraction. - [ ] Further frontend state/module decomposition beyond Sprint 10 extraction.
+5
View File
@@ -151,6 +151,11 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
- Added area visibility and opacity controls alongside the existing active vector/result layer controls. - Added area visibility and opacity controls alongside the existing active vector/result layer controls.
- Area list items can select which AOI is shown on the map. - Area list items can select which AOI is shown on the map.
## Sprint 21 additions
- Loading the explicit demo workflow now opens the candidate vector fixture dataset directly, so the Map Workbench shows the demo vector layer without an extra manual dataset click.
- The demo/export verification script now checks connected V1 state: area GeoJSON, fixture datasets, vector FeatureCollection content, vector feature summary, persisted QA/QC metrics and export downloads through the frontend proxy.
## Release hardening updates ## Release hardening updates
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
+10 -2
View File
@@ -339,7 +339,9 @@ function App(): JSX.Element {
]) ])
setAreas(areaResponse.items) setAreas(areaResponse.items)
setDatasets(datasetResponse.items) setDatasets(datasetResponse.items)
if (!selectedClipAreaId && areaResponse.items.length > 0) { if (areaResponse.items.length === 0) {
setSelectedClipAreaId('')
} else if (!selectedClipAreaId || !areaResponse.items.some((area) => area.id === selectedClipAreaId)) {
setSelectedClipAreaId(areaResponse.items[0].id) setSelectedClipAreaId(areaResponse.items[0].id)
} }
if (areaResponse.items.length === 0) { if (areaResponse.items.length === 0) {
@@ -347,8 +349,10 @@ function App(): JSX.Element {
} else if (!selectedMapAreaId || !areaResponse.items.some((area) => area.id === selectedMapAreaId)) { } else if (!selectedMapAreaId || !areaResponse.items.some((area) => area.id === selectedMapAreaId)) {
setSelectedMapAreaId(areaResponse.items[0].id) setSelectedMapAreaId(areaResponse.items[0].id)
} }
return { areas: areaResponse.items, datasets: datasetResponse.items }
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data') setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
return null
} finally { } finally {
setLoadingAreas(false) setLoadingAreas(false)
setLoadingDatasets(false) setLoadingDatasets(false)
@@ -759,13 +763,17 @@ function App(): JSX.Element {
setSegmentationReferenceDatasetId(result.reference_dataset_id) setSegmentationReferenceDatasetId(result.reference_dataset_id)
setDemoWorkflowMessage(result.message) setDemoWorkflowMessage(result.message)
await loadProjects() await loadProjects()
await Promise.all([ const [projectData] = await Promise.all([
loadProjectData(result.project_id), loadProjectData(result.project_id),
loadDetectionRuns(result.project_id), loadDetectionRuns(result.project_id),
loadSegmentationRuns(result.project_id), loadSegmentationRuns(result.project_id),
loadQualityChecks(result.project_id), loadQualityChecks(result.project_id),
loadExports(result.project_id), loadExports(result.project_id),
]) ])
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
if (candidateDataset) {
await loadDatasetDetails(result.project_id, candidateDataset)
}
} catch (error) { } catch (error) {
setErrorMessage(formatError(error, 'Failed to load demo workflow')) setErrorMessage(formatError(error, 'Failed to load demo workflow'))
} finally { } finally {
+5 -3
View File
@@ -17,9 +17,11 @@ Verify the explicit demo workflow plus export artifact path:
bash scripts/verify_demo_export_workflow.sh http://192.168.10.150:1202 bash scripts/verify_demo_export_workflow.sh http://192.168.10.150:1202
``` ```
The demo/export smoke seeds the offline fixture demo, verifies persisted QA/QC The demo/export smoke is intentionally mutating and idempotent: it seeds the
results, creates metadata/report/vector GeoJSON exports, lists exports and offline fixture demo if needed, verifies the project area GeoJSON, fixture
downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy. datasets, vector FeatureCollection content, vector feature summary, persisted
QA/QC metrics, creates metadata/report/vector GeoJSON exports, lists exports
and downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy.
## Tower deployment ## Tower deployment
+85
View File
@@ -72,6 +72,72 @@ curl -fsS -X POST "${BASE_URL%/}/api/v1/demo/workflow" > "${TMP_DIR}/demo.json"
require_json_data "${TMP_DIR}/demo.json" require_json_data "${TMP_DIR}/demo.json"
project_id="$(json_field "${TMP_DIR}/demo.json" "data.project_id")" project_id="$(json_field "${TMP_DIR}/demo.json" "data.project_id")"
candidate_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.candidate_dataset_id")" candidate_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.candidate_dataset_id")"
reference_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.reference_dataset_id")"
area_id="$(json_field "${TMP_DIR}/demo.json" "data.area_id")"
quality_check_id="$(json_field "${TMP_DIR}/demo.json" "data.quality_check_id")"
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}" > "${TMP_DIR}/project.json"
require_json_data "${TMP_DIR}/project.json"
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/areas" > "${TMP_DIR}/areas.json"
require_json_data "${TMP_DIR}/areas.json"
"${PYTHON_BIN}" - "${TMP_DIR}/areas.json" "${area_id}" <<'PY'
import json
import sys
path, area_id = sys.argv[1], sys.argv[2]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
items = payload["data"]["items"]
if not items:
raise SystemExit("Demo workflow did not expose any project areas")
matches = [item for item in items if item["id"] == area_id]
if not matches:
raise SystemExit(f"Demo area {area_id} was not returned by the area list")
geometry = matches[0].get("geometry")
if not geometry or geometry.get("type") not in {"Polygon", "MultiPolygon"}:
raise SystemExit("Demo area response does not include GeoJSON Polygon/MultiPolygon geometry")
PY
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets" > "${TMP_DIR}/datasets.json"
require_json_data "${TMP_DIR}/datasets.json"
"${PYTHON_BIN}" - "${TMP_DIR}/datasets.json" "${candidate_dataset_id}" "${reference_dataset_id}" <<'PY'
import json
import sys
path, candidate_id, reference_id = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
items = payload["data"]["items"]
ids = {item["id"]: item for item in items}
if candidate_id not in ids:
raise SystemExit("Candidate fixture dataset is missing from project dataset list")
if reference_id not in ids:
raise SystemExit("Reference fixture dataset is missing from project dataset list")
if ids[candidate_id]["dataset_type"] not in {"vector", "geojson"}:
raise SystemExit("Candidate fixture dataset is not vector-like")
if ids[reference_id].get("dataset_role") != "reference":
raise SystemExit("Reference fixture dataset is not marked as reference")
PY
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/content" > "${TMP_DIR}/candidate_content.json"
if ! grep -q '"FeatureCollection"' "${TMP_DIR}/candidate_content.json"; then
echo "Candidate dataset content is not a FeatureCollection" >&2
exit 1
fi
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${candidate_dataset_id}/vector/summary" > "${TMP_DIR}/candidate_summary.json"
require_json_data "${TMP_DIR}/candidate_summary.json"
"${PYTHON_BIN}" - "${TMP_DIR}/candidate_summary.json" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as handle:
payload = json.load(handle)
feature_count = payload["data"].get("feature_count") or 0
if feature_count < 1:
raise SystemExit("Candidate vector summary does not report persisted features")
PY
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/quality-checks" > "${TMP_DIR}/quality_checks.json" curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/quality-checks" > "${TMP_DIR}/quality_checks.json"
require_json_data "${TMP_DIR}/quality_checks.json" require_json_data "${TMP_DIR}/quality_checks.json"
@@ -80,6 +146,23 @@ if [ "${quality_count}" -lt 1 ]; then
echo "Expected at least one persisted QA/QC result after demo workflow" >&2 echo "Expected at least one persisted QA/QC result after demo workflow" >&2
exit 1 exit 1
fi fi
"${PYTHON_BIN}" - "${TMP_DIR}/quality_checks.json" "${quality_check_id}" <<'PY'
import json
import sys
path, quality_check_id = sys.argv[1], sys.argv[2]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
items = payload["data"]["items"]
matches = [item for item in items if item["id"] == quality_check_id]
if not matches:
raise SystemExit("Seeded quality_check_id is not listed in project QA/QC results")
metric_keys = {metric["metric_key"] for metric in matches[0].get("metrics", [])}
required = {"precision", "recall", "f1", "mean_iou", "false_positive_count", "false_negative_count"}
missing = required - metric_keys
if missing:
raise SystemExit(f"QA/QC result is missing metrics: {sorted(missing)}")
PY
curl -fsS -X POST "${BASE_URL%/}/api/v1/exports/metadata" \ curl -fsS -X POST "${BASE_URL%/}/api/v1/exports/metadata" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -128,4 +211,6 @@ fi
echo "Demo/export workflow verification passed" echo "Demo/export workflow verification passed"
echo "Project: ${project_id}" echo "Project: ${project_id}"
echo "Area: ${area_id}"
echo "Datasets: candidate=${candidate_dataset_id}, reference=${reference_dataset_id}"
echo "Exports created: metadata=${metadata_export_id}, report=${report_export_id}, dataset=${dataset_export_id}" echo "Exports created: metadata=${metadata_export_id}, report=${report_export_id}, dataset=${dataset_export_id}"