from __future__ import annotations import json import os from datetime import datetime, timezone from pathlib import Path from uuid import UUID, uuid4 from geoalchemy2.shape import from_shape from sqlalchemy.orm import Session from app.models import Area, Dataset, Metric, Project, QualityCheck from app.schemas.demo import DemoWorkflowResponse from app.services.geojson_service import parse_geojson_payload from app.services.qa_service import QaService from app.services.quality_service import QualityService from app.services.storage_service import StorageService from app.services.vector_feature_service import VectorFeatureService from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon class DemoWorkflowService: PROJECT_NAME = "GeoIntel Demo - Building QA" AREA_NAME = "Demo AOI - Geel buildings" REFERENCE_FILENAME = "demo_reference_buildings.geojson" CANDIDATE_FILENAME = "demo_predicted_buildings.geojson" @staticmethod def _repo_root() -> Path: return Path(__file__).resolve().parents[3] @staticmethod 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 @staticmethod def _load_fixture(filename: str) -> tuple[dict, bytes]: path = DemoWorkflowService._fixture_path(filename) raw = path.read_bytes() return json.loads(raw.decode("utf-8")), raw @staticmethod def _find_existing_project(db: Session) -> Project | None: return ( db.query(Project) .filter(Project.name == DemoWorkflowService.PROJECT_NAME) .filter(Project.status != "deleted") .first() ) @staticmethod def _create_area(db: Session, project_id: UUID) -> Area: geometry = { "type": "MultiPolygon", "coordinates": [ [ [ [4.30, 51.18], [4.45, 51.18], [4.45, 51.33], [4.30, 51.33], [4.30, 51.18], ] ] ], } multipolygon = normalize_to_multipolygon(geometry) area = Area( id=uuid4(), project_id=project_id, name=DemoWorkflowService.AREA_NAME, geometry=from_shape(multipolygon, srid=4326), original_crs="EPSG:4326", area_m2=area_m2(multipolygon), bbox=from_shape(geometry_bbox_polygon(multipolygon), srid=4326), ) db.add(area) db.commit() db.refresh(area) return area @staticmethod def _create_dataset( db: Session, *, project_id: UUID, area_id: UUID, filename: str, payload: dict, raw: bytes, role: str, source_name: str, reference_layer_name: str | None, ) -> Dataset: dataset_id = uuid4() storage_info = StorageService.persist_dataset_file( project_id=str(project_id), dataset_id=str(dataset_id), dataset_type="vector", original_filename=filename, content=raw, content_type="application/geo+json", ) metadata = parse_geojson_payload(payload) dataset = Dataset( id=dataset_id, project_id=project_id, area_id=area_id, name=filename, dataset_type="vector", source="fixture", dataset_role=role, source_name=source_name, reference_layer_name=reference_layer_name, source_metadata={ "fixture": True, "fixture_name": filename, "usage": "offline demo workflow only", }, provenance_metadata={ "created_by": "demo_workflow", "source_path": str(DemoWorkflowService._fixture_path(filename)), }, imported_at=datetime.now(timezone.utc), storage_path=storage_info["storage_path"], original_filename=storage_info["original_filename"], stored_filename=storage_info["stored_filename"], content_type=storage_info["content_type"], size_bytes=storage_info["size_bytes"], checksum_sha256=storage_info["checksum_sha256"], crs=metadata.get("crs"), bounds_json=metadata.get("bounds_json"), metadata_json=metadata, status="ready", ) db.add(dataset) db.commit() db.refresh(dataset) VectorFeatureService.persist_geojson_features( db=db, dataset_id=dataset.id, payload=payload, feature_class=reference_layer_name or "building", ) return dataset @staticmethod def _persist_qa( db: Session, *, project_id: UUID, candidate_dataset_id: UUID, reference_dataset_id: UUID, area_id: UUID, ) -> QualityCheck: result = QaService.compare_candidate_with_reference( db=db, project_id=project_id, candidate_dataset_id=candidate_dataset_id, reference_dataset_id=reference_dataset_id, iou_threshold=0.5, area_id=area_id, ) return QualityService.persist_quality_check( db=db, project_id=project_id, candidate_dataset_id=candidate_dataset_id, reference_dataset_id=reference_dataset_id, check_type="demo_candidate_vs_reference", status=result.status, score=result.f1_score, parameters={ "iou_threshold": result.iou_threshold, "area_id": str(area_id), "fixture_workflow": True, }, findings={ "matches": result.matches, "false_positives": result.false_positives, "false_negatives": result.false_negatives, "warnings": result.warnings, "unsupported_geometry": result.unsupported_geometry, "unsupported_geometries": result.unsupported_geometries, }, metrics={ "precision": result.precision, "recall": result.recall, "f1": result.f1_score, "mean_iou": result.mean_iou, "false_positive_count": result.false_positives, "false_negative_count": result.false_negatives, }, ) @staticmethod def seed(db: Session) -> DemoWorkflowResponse: 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: area = db.query(Area).filter(Area.project_id == existing.id).order_by(Area.created_at.asc()).first() reference = ( db.query(Dataset) .filter(Dataset.project_id == existing.id) .filter(Dataset.dataset_role == "reference") .filter(Dataset.source_name == "fixture") .first() ) candidate = ( db.query(Dataset) .filter(Dataset.project_id == existing.id) .filter(Dataset.dataset_role == "source") .filter(Dataset.source_name == "fixture") .first() ) quality_check = ( db.query(QualityCheck) .filter(QualityCheck.project_id == existing.id) .filter(QualityCheck.check_type == "demo_candidate_vs_reference") .order_by(QualityCheck.created_at.desc()) .first() ) if area and reference and candidate and quality_check: return DemoWorkflowResponse( project_id=existing.id, area_id=area.id, reference_dataset_id=reference.id, candidate_dataset_id=candidate.id, quality_check_id=quality_check.id, metric_count=db.query(Metric).filter(Metric.quality_check_id == quality_check.id).count(), status="ready", message="Demo workflow already exists.", 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 if not area: area = DemoWorkflowService._create_area(db, project.id) if not reference: reference = DemoWorkflowService._create_dataset( db=db, project_id=project.id, area_id=area.id, filename=DemoWorkflowService.REFERENCE_FILENAME, payload=reference_payload, raw=reference_raw, role="reference", source_name="fixture", reference_layer_name="buildings", ) if not candidate: candidate = DemoWorkflowService._create_dataset( db=db, project_id=project.id, area_id=area.id, filename=DemoWorkflowService.CANDIDATE_FILENAME, payload=candidate_payload, raw=candidate_raw, role="source", source_name="fixture", reference_layer_name=None, ) if not quality_check: 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( project_id=project.id, area_id=area.id, reference_dataset_id=reference.id, candidate_dataset_id=candidate.id, quality_check_id=quality_check.id, metric_count=6, status="ready", message="Demo workflow seeded from explicit local fixtures.", created=created, )