Initial GeoIntel V1 foundation
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-16 23:36:32 +02:00
commit 6ea3586a3e
605 changed files with 45284 additions and 0 deletions
View File
View File
+1
View File
@@ -0,0 +1 @@
__all__ = ["areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"]
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Area
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
from app.services.area_service import AreaService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
@router.get("", response_model=dict)
def list_areas(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
return envelope({"items": [AreaRead.model_validate(area).model_dump() for area in areas], "total": total, "limit": limit, "offset": offset})
@router.post("", status_code=201, response_model=dict)
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
area = AreaService.create_area(db, project_id, payload)
return envelope(AreaRead.model_validate(area).model_dump())
@router.get("/{area_id}", response_model=dict)
def get_area(
project_id: UUID,
area_id: UUID,
db: Session = Depends(get_db),
):
area = AreaService.get_area(db, area_id)
if area.project_id != project_id:
raise HTTPException(status_code=404, detail="Area not found")
return envelope(AreaRead.model_validate(area).model_dump())
@router.patch("/{area_id}", response_model=dict)
def update_area(
project_id: UUID,
area_id: UUID,
payload: AreaUpdate,
db: Session = Depends(get_db),
):
existing = db.get(Area, area_id)
if not existing or existing.project_id != project_id:
raise HTTPException(status_code=404, detail="Area not found")
area = AreaService.update_area(db, area_id, payload)
return envelope(AreaRead.model_validate(area).model_dump())
+489
View File
@@ -0,0 +1,489 @@
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
from uuid import UUID as _UUID
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query
from fastapi import UploadFile
from sqlalchemy.orm import Session
from app.models import Area
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas import (
RasterClipRequest,
RasterStatsResponse,
RasterReprojectRequest,
RasterTileRequest,
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
VectorBBoxResponse,
VectorBufferRequest,
VectorClipRequest,
VectorIntersectRequest,
)
from app.schemas.job import JobCreate
from app.schemas.dataset import DatasetCreateResponse
from app.schemas.operations import VectorOperationResult
from app.services.job_service import JobService
from app.services.raster_operations_service import RasterOperationsService
from app.services.vector_operations_service import VectorOperationsService
from app.services.dataset_service import DatasetService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["datasets"])
def _parse_metadata_json(raw: str | None, field_name: str) -> dict | None:
if raw is None:
return None
raw = raw.strip()
if not raw:
return None
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise AppError(code="INVALID_JSON", message=f"Invalid JSON for {field_name}", details={"field": field_name}, status_code=400) from exc
if not isinstance(value, dict):
raise AppError(code="INVALID_JSON", message=f"{field_name} must be a JSON object", details={"field": field_name}, status_code=400)
return value
def _run_job_sync(
db: Session,
project_id: UUID,
input_dataset_id: UUID,
job_type: str,
parameters: dict[str, Any],
operation,
) -> dict[str, Any]:
return JobService.run_sync_job(
db=db,
project_id=project_id,
job_type=job_type,
parameters=parameters,
operation=operation,
input_dataset_id=input_dataset_id,
)
@router.post("/datasets/upload", status_code=201, response_model=dict)
async def upload_dataset(
project_id: UUID,
file: UploadFile = File(...),
dataset_type: str = Form(...),
source: str = Form("user_upload"),
area_id: UUID | None = Form(None),
dataset_role: str = Form("source"),
source_name: str | None = Form(None),
reference_layer_name: str | None = Form(None),
source_metadata_json: str | None = Form(None),
provenance_metadata_json: str | None = Form(None),
db: Session = Depends(get_db),
):
if area_id is not None:
area = db.get(Area, area_id)
if not area:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
created = await DatasetService.upload_dataset(
db,
project_id=project_id,
file=file,
dataset_type=dataset_type,
source=source,
dataset_role=dataset_role,
source_name=source_name,
reference_layer_name=reference_layer_name,
source_metadata=_parse_metadata_json(source_metadata_json, "source_metadata_json"),
provenance_metadata=_parse_metadata_json(provenance_metadata_json, "provenance_metadata_json"),
area_id=area_id,
)
return envelope(created.model_dump())
@router.get("/datasets", response_model=dict)
def list_datasets(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
datasets, total = DatasetService.list_datasets(db, project_id, limit=limit, offset=offset)
return envelope({"items": [item.model_dump() for item in datasets], "total": total, "limit": limit, "offset": offset})
@router.get("/datasets/{dataset_id}", response_model=dict)
def get_dataset(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(DatasetCreateResponse.model_validate(dataset).model_dump())
@router.post("/datasets/{dataset_id}/metadata/refresh", response_model=dict)
def refresh_dataset_metadata(
project_id: UUID,
dataset_id: UUID,
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")
refreshed = DatasetService.refresh_metadata(db, dataset_id)
return envelope(refreshed.model_dump())
@router.get("/datasets/{dataset_id}/vector/inspect", response_model=dict)
def inspect_vector_dataset(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(VectorOperationsService.inspect(db, dataset_id).model_dump())
@router.get("/datasets/{dataset_id}/vector/bbox", response_model=dict)
def vector_bbox(
project_id: UUID,
dataset_id: UUID,
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")
payload = VectorOperationsService.bbox(db, dataset_id)
return envelope(VectorBBoxResponse(**payload).model_dump())
@router.get("/datasets/{dataset_id}/vector/stats", response_model=dict)
def vector_stats(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(VectorOperationsService.stats(db, dataset_id))
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
def clip_vector_dataset(
project_id: UUID,
dataset_id: UUID,
payload: VectorClipRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="vector.clip",
parameters=payload.model_dump(),
operation=lambda: VectorOperationsService.clip_by_area(
db,
dataset_id=dataset_id,
area_id=payload.area_id,
output_name=payload.output_name,
),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/vector/buffer", status_code=201, response_model=dict)
def buffer_vector_dataset(
project_id: UUID,
dataset_id: UUID,
payload: VectorBufferRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="vector.buffer",
parameters=payload.model_dump(),
operation=lambda: VectorOperationsService.buffer(
db,
dataset_id=dataset_id,
distance_m=payload.distance_m,
dissolve=payload.dissolve,
output_name=payload.output_name,
),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/vector/intersect", status_code=201, response_model=dict)
def intersect_vector_dataset(
project_id: UUID,
dataset_id: UUID,
payload: VectorIntersectRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="vector.intersect",
parameters=payload.model_dump(),
operation=lambda: VectorOperationsService.intersect(
db,
source_dataset_id=dataset_id,
target_dataset_id=UUID(payload.other_dataset_id),
output_name=payload.output_name,
),
)
return envelope(job)
@router.get("/datasets/{dataset_id}/vector/summary", response_model=dict)
def vector_dataset_summary(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(DatasetService.vector_summary(db, dataset_id))
@router.get("/datasets/{dataset_id}/raster/inspect", response_model=dict)
def raster_dataset_inspect(
project_id: UUID,
dataset_id: UUID,
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")
payload = RasterOperationsService.inspect(db, dataset_id)
return envelope(payload)
@router.get("/datasets/{dataset_id}/raster/preview", response_model=dict)
def raster_preview_readiness(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(RasterOperationsService.preview(db, dataset_id))
@router.get("/datasets/{dataset_id}/raster/stats", response_model=dict)
def raster_stats(
project_id: UUID,
dataset_id: UUID,
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")
payload = RasterOperationsService.stats(db, dataset_id)
return envelope(RasterStatsResponse(**payload).model_dump())
@router.post("/datasets/{dataset_id}/raster/reproject", status_code=201, response_model=dict)
def raster_reproject_dataset(
project_id: UUID,
dataset_id: UUID,
payload: RasterReprojectRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="raster.reproject",
parameters=payload.model_dump(),
operation=lambda: RasterOperationsService.reproject(
db,
dataset_id,
target_crs=payload.target_crs,
output_name=payload.output_name,
resampling=payload.resampling,
),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/raster/clip", status_code=201, response_model=dict)
def raster_clip_dataset(
project_id: UUID,
dataset_id: UUID,
payload: RasterClipRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="raster.clip",
parameters=payload.model_dump(),
operation=lambda: RasterOperationsService.clip(db, dataset_id, UUID(payload.area_id), payload.output_name),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/raster/tile", status_code=201, response_model=dict)
def raster_tile_dataset(
project_id: UUID,
dataset_id: UUID,
payload: RasterTileRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="raster.tile",
parameters=payload.model_dump(),
operation=lambda: RasterOperationsService.tile(
db,
dataset_id,
tile_size=payload.tile_size,
overlap=payload.overlap,
output_name=payload.output_name,
),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/raster/indices/ndvi", status_code=201, response_model=dict)
def raster_ndvi_dataset(
project_id: UUID,
dataset_id: UUID,
payload: RasterNdviRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="raster.ndvi",
parameters=payload.model_dump(),
operation=lambda: RasterOperationsService.ndvi(
db,
dataset_id=dataset_id,
nir_band=payload.nir_band,
red_band=payload.red_band,
output_name=payload.output_name,
),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/raster/indices/ndwi", status_code=201, response_model=dict)
def raster_ndwi_dataset(
project_id: UUID,
dataset_id: UUID,
payload: RasterNdwiRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="raster.ndwi",
parameters=payload.model_dump(),
operation=lambda: RasterOperationsService.ndwi(
db,
dataset_id=dataset_id,
green_band=payload.green_band,
nir_band=payload.nir_band,
output_name=payload.output_name,
),
)
return envelope(job)
@router.post("/datasets/{dataset_id}/raster/indices/ndbi", status_code=201, response_model=dict)
def raster_ndbi_dataset(
project_id: UUID,
dataset_id: UUID,
payload: RasterNdbiRequest,
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")
job = _run_job_sync(
db=db,
project_id=project_id,
input_dataset_id=dataset_id,
job_type="raster.ndbi",
parameters=payload.model_dump(),
operation=lambda: RasterOperationsService.ndbi(
db,
dataset_id=dataset_id,
swir_band=payload.swir_band,
nir_band=payload.nir_band,
output_name=payload.output_name,
),
)
return envelope(job)
@router.get("/datasets/{dataset_id}/raster/metadata", response_model=dict)
def raster_dataset_metadata(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(RasterOperationsService.metadata(db, dataset_id))
@router.get("/datasets/{dataset_id}/content", response_model=dict)
def dataset_content(
project_id: UUID,
dataset_id: UUID,
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")
return envelope(DatasetService.get_dataset_geojson(db, dataset_id))
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.demo import DemoWorkflowResponse
from app.services.demo_workflow_service import DemoWorkflowService
from app.utils.response import envelope
router = APIRouter(prefix="/demo", tags=["demo"])
@router.post("/workflow", status_code=status.HTTP_201_CREATED, response_model=dict)
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
return envelope(result.model_dump())
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import DetectionQaRequest, DetectionRunRequest
from app.services.detection_service import DetectionService
from app.services.model_registry_service import ModelRegistryService
from app.utils.response import envelope
router = APIRouter(prefix="/detection", tags=["detection"])
@router.get("/models", response_model=dict)
def list_detection_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
@router.post("/run", response_model=dict)
def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
result = DetectionService.run_detection(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(result.model_dump())
@router.get("/runs", response_model=dict)
def list_detection_runs(
project_id: UUID | None = None,
dataset_id: UUID | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(DetectionService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump())
@router.get("/runs/{analysis_run_id}", response_model=dict)
def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(DetectionService.get_run(db, analysis_run_id).model_dump())
@router.get("/runs/{analysis_run_id}/detections", response_model=dict)
def list_detection_run_detections(
analysis_run_id: UUID,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get("/datasets/{dataset_id}/detections", response_model=dict)
def list_dataset_detections(
dataset_id: UUID,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get("/detections/{detection_id}", response_model=dict)
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(DetectionService.get_detection(db, detection_id).model_dump())
@router.get("/runs/{analysis_run_id}/geojson", response_model=dict)
def get_detection_run_geojson(
analysis_run_id: UUID,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionService.detections_to_geojson(
db,
analysis_run_id=analysis_run_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.get("/datasets/{dataset_id}/geojson", response_model=dict)
def get_dataset_detection_geojson(
dataset_id: UUID,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionService.detections_to_geojson(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.post("/runs/{analysis_run_id}/qa/reference", response_model=dict)
def compare_detection_run_with_reference(
analysis_run_id: UUID,
payload: DetectionQaRequest,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
class_name=payload.class_name,
min_confidence=payload.min_confidence,
)
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.export import GeoJsonExportRequest, MetadataExportRequest, ReportExportRequest
from app.services.export_service import ExportService
from app.utils.response import envelope
router = APIRouter(prefix="/exports", tags=["exports"])
@router.post("/geojson", response_model=dict)
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_detection_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
)
if payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_segmentation_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
)
if payload.dataset_id is not None:
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
return envelope({})
@router.post("/metadata", response_model=dict)
def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)):
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/report", response_model=dict)
def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)):
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.get("/projects/{project_id}/exports", response_model=dict)
def list_project_exports(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
@router.get("/{export_id}", response_model=dict)
def get_export(export_id: UUID, db: Session = Depends(get_db)):
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
@router.get("/{export_id}/download")
def download_export(export_id: UUID, db: Session = Depends(get_db)):
path = ExportService.get_export_download_path(db, export_id)
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
return FileResponse(path, filename=path.name, media_type=media_type)
@router.get("/{export_id}/content", response_model=dict)
def get_export_content(export_id: UUID, db: Session = Depends(get_db)):
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Area, Project
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
from app.schemas import ExternalFetchRequest, ExternalFetchResponse, ProviderImportRequest
from app.utils.response import envelope
router = APIRouter(prefix="/external", tags=["external"])
def _validate_area_in_project(db: Session, project_id, area_id: str | None) -> None:
if area_id is None:
return
area = db.get(Area, area_id)
if not area:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
def _assert_project_exists(db: Session, project_id):
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
def _provider_payload(provider_name: str) -> dict:
return get_provider(provider_name).capability.to_dict()
@router.get("/providers")
def list_external_providers() -> dict:
return envelope({
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
})
@router.get("/providers/capabilities")
def get_external_provider_capabilities() -> dict:
return envelope({
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
})
@router.get("/providers/{provider_name}")
def get_external_provider(provider_name: str) -> dict:
return envelope(_provider_payload(provider_name))
@router.get("/providers/{provider_name}/layers")
def get_external_provider_layers(provider_name: str) -> dict:
provider = get_provider(provider_name)
return envelope({
"provider_name": provider.provider_name,
"layers": provider.supported_layers,
})
@router.get("/providers/{provider_name}/status")
def get_external_provider_status(provider_name: str) -> dict:
provider = get_provider(provider_name)
return envelope({
"provider_name": provider.provider_name,
"configured": provider.is_configured,
"status": provider.capability.status,
"limitation_message": provider.limitation_message,
})
@router.post("/providers/{provider_name}/import")
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
result = import_provider_dataset(
provider_name=provider_name,
project_id=payload.project_id,
area_id=payload.area_id,
layers=_normalize_layer_input(payload.layers),
requested_dataset_role=payload.dataset_role,
)
return envelope(result.model_dump())
def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFetchResponse:
area_id_str = str(payload.area_id) if payload.area_id else None
response = fetch_provider_data(
provider_name=provider_name,
project_id=str(payload.project_id),
area_id=area_id_str,
layers=_normalize_layer_input(payload.layers),
)
return ExternalFetchResponse(
provider=provider_name,
status=response.get("status", "not_configured"),
message=response.get("message", "Provider fetch executed."),
requested_layers=_normalize_layer_input(payload.layers),
project_id=payload.project_id,
area_id=payload.area_id,
)
@router.post("/osm/fetch")
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
_assert_project_exists(db, payload.project_id)
_validate_area_in_project(db, payload.project_id, payload.area_id)
return envelope(_run_fetch(payload, "osm").model_dump())
@router.post("/grb/fetch")
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
_assert_project_exists(db, payload.project_id)
_validate_area_in_project(db, payload.project_id, payload.area_id)
return envelope(_run_fetch(payload, "grb").model_dump())
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from importlib import import_module
from sqlalchemy import text
from fastapi import APIRouter
from app.schemas.health import HealthResponse, SystemCapabilities
from app.providers.registry import list_provider_capabilities
from app.db.session import get_engine
router = APIRouter()
def _dependency_enabled(module: str) -> bool:
try:
import_module(module)
return True
except Exception:
return False
@router.get("/health")
def readiness() -> HealthResponse:
db_status = "ok"
try:
with get_engine().connect() as connection:
connection.execute(text("SELECT 1"))
except Exception:
db_status = "degraded"
return HealthResponse(status="ok", service="geointel-backend", version="0.1.0", database=db_status)
@router.get("/api/v1/system/capabilities")
def capabilities() -> dict:
providers = [item.to_dict() for item in list_provider_capabilities()]
return {"data": SystemCapabilities(
postgis=True,
rasterio=_dependency_enabled("rasterio"),
geopandas=_dependency_enabled("geopandas"),
yolo=False,
sam=False,
grb="planned",
sentinel="planned",
providers=providers,
).model_dump()}
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import JobCreate, JobList, JobRead, JobStatus
from app.services.job_service import JobService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"])
@router.post("/jobs", status_code=201, response_model=dict)
def create_job(
project_id: UUID,
payload: JobCreate,
db: Session = Depends(get_db),
):
if payload.project_id != project_id:
raise HTTPException(status_code=400, detail="project_id mismatch")
return envelope(JobService.create_job(db, payload).model_dump())
@router.get("/jobs", response_model=dict)
def list_jobs(
project_id: UUID,
dataset_id: UUID | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
items, total = JobService.list_jobs(
db,
project_id=project_id,
dataset_id=dataset_id,
limit=limit,
offset=offset,
)
return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump())
@router.get("/jobs/{job_id}", response_model=dict)
def read_job(
project_id: UUID,
job_id: UUID,
db: Session = Depends(get_db),
):
job = JobService.get_job(db, job_id)
if job.project_id != project_id:
raise HTTPException(status_code=404, detail="Job not found")
return envelope(job.model_dump())
@router.get("/jobs/{job_id}/status", response_model=dict)
def read_job_status(
project_id: UUID,
job_id: UUID,
db: Session = Depends(get_db),
):
status_row = JobService.get_job_status(db, job_id)
if status_row["project_id"] != str(project_id):
raise HTTPException(status_code=404, detail="Job not found")
return envelope(JobStatus(**status_row).model_dump())
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from app.services.project_service import ProjectService
from app.utils.response import envelope
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=dict)
def list_projects(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset)
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
@router.post("", status_code=status.HTTP_201_CREATED, response_model=dict)
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
project = ProjectService.create_project(db, payload)
return envelope(ProjectRead.model_validate(project).model_dump())
@router.get("/{project_id}", response_model=dict)
def get_project(project_id: UUID, db: Session = Depends(get_db)):
project = ProjectService.get_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return envelope(ProjectRead.model_validate(project).model_dump())
@router.patch("/{project_id}", response_model=dict)
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
project = ProjectService.update_project(db, project_id, payload)
return envelope(ProjectRead.model_validate(project).model_dump())
@router.delete("/{project_id}", status_code=status.HTTP_200_OK, response_model=dict)
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
if not ProjectService.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return envelope({"deleted": True})
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.errors import AppError
from app.models import Dataset, Job
from app.schemas import QaProviderComparisonRequest
from app.services.qa_service import QaService
from app.services.job_service import JobService
from app.services.quality_service import QualityService
from app.utils.response import envelope
router = APIRouter(prefix="/qa", tags=["qa"])
@router.post("/detections-vs-reference")
def compare_candidate_with_reference(
payload: QaProviderComparisonRequest,
db: Session = Depends(get_db),
) -> dict:
candidate_dataset = db.get(Dataset, payload.candidate_dataset_id)
if not candidate_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Candidate dataset not found", status_code=404)
job = JobService.run_sync_job(
db=db,
project_id=candidate_dataset.project_id,
job_type="qa.compare-candidate-with-reference",
parameters=payload.model_dump(mode="json"),
input_dataset_id=candidate_dataset.id,
operation=lambda: QaService.compare_candidate_with_reference(
db=db,
project_id=candidate_dataset.project_id,
candidate_dataset_id=payload.candidate_dataset_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
area_id=payload.area_id,
).model_dump(mode="json"),
)
result_json = job.get("result_json") if isinstance(job, dict) else None
if isinstance(result_json, dict) and job.get("status") == "success":
quality_check = QualityService.persist_quality_check(
db=db,
project_id=candidate_dataset.project_id,
job_id=uuid.UUID(str(job["id"])),
candidate_dataset_id=payload.candidate_dataset_id,
reference_dataset_id=payload.reference_dataset_id,
check_type="candidate_vs_reference",
status=str(result_json.get("status", "ok")),
score=result_json.get("f1_score"),
parameters=payload.model_dump(mode="json"),
findings={
"matches": result_json.get("matches"),
"false_positives": result_json.get("false_positives"),
"false_negatives": result_json.get("false_negatives"),
"warnings": result_json.get("warnings", []),
"unsupported_geometry": result_json.get("unsupported_geometry", False),
"unsupported_geometries": result_json.get("unsupported_geometries", []),
},
metrics={
"precision": result_json.get("precision"),
"recall": result_json.get("recall"),
"f1": result_json.get("f1_score"),
"mean_iou": result_json.get("mean_iou"),
"false_positive_count": result_json.get("false_positives"),
"false_negative_count": result_json.get("false_negatives"),
},
)
result_json["quality_check_id"] = str(quality_check.id)
job_record = db.get(Job, uuid.UUID(str(job["id"])))
if job_record:
job_record.result_json = result_json
db.add(job_record)
db.commit()
return envelope(job)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.qa import QualityCheckList
from app.services.quality_check_service import QualityCheckService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"])
@router.get("/quality-checks", response_model=dict)
def list_quality_checks(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
items, total = QualityCheckService.list_quality_checks(
db,
project_id=project_id,
limit=limit,
offset=offset,
)
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import SegmentationQaRequest, SegmentationRunRequest
from app.services.model_registry_service import ModelRegistryService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
@router.get("/models", response_model=dict)
def list_segmentation_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
@router.post("/run", response_model=dict)
def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
result = SegmentationService.run_segmentation(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(result.model_dump())
@router.get("/runs", response_model=dict)
def list_segmentation_runs(
project_id: UUID | None = None,
dataset_id: UUID | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(SegmentationService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump())
@router.get("/runs/{analysis_run_id}", response_model=dict)
def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump())
@router.get("/runs/{analysis_run_id}/segmentations", response_model=dict)
def list_segmentation_run_outputs(
analysis_run_id: UUID,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
SegmentationService.list_segmentations(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get("/datasets/{dataset_id}/segmentations", response_model=dict)
def list_dataset_segmentations(
dataset_id: UUID,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
SegmentationService.list_segmentations(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get("/segmentations/{segmentation_id}", response_model=dict)
def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict:
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
@router.get("/runs/{analysis_run_id}/geojson", response_model=dict)
def get_segmentation_run_geojson(
analysis_run_id: UUID,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
SegmentationService.segmentations_to_geojson(
db,
analysis_run_id=analysis_run_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.get("/datasets/{dataset_id}/geojson", response_model=dict)
def get_dataset_segmentation_geojson(
dataset_id: UUID,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
SegmentationService.segmentations_to_geojson(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.post("/runs/{analysis_run_id}/qa/reference", response_model=dict)
def compare_segmentation_run_with_reference(
analysis_run_id: UUID,
payload: SegmentationQaRequest,
db: Session = Depends(get_db),
) -> dict:
return envelope(
SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
class_name=payload.class_name,
min_confidence=payload.min_confidence,
)
)