From e189a2881db14faa67f8309fecc3b31be287d8d6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 25 Jun 2026 02:35:21 +0200 Subject: [PATCH] Add map selection QA shortcut --- CHANGELOG.md | 8 ++ ...est_sprint109_map_selection_qa_shortcut.py | 30 +++++++ docs/CODEX_EXECUTION_LOG.md | 25 ++++++ docs/TODO.md | 1 + frontend/README.md | 1 + frontend/src/App.tsx | 21 +++++ frontend/src/components/map/MapWorkspace.tsx | 64 +++++++++++++- frontend/src/hooks/useMapSelectionQa.ts | 85 +++++++++++++++++++ frontend/src/styles/app.css | 31 +++++++ 9 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_sprint109_map_selection_qa_shortcut.py create mode 100644 frontend/src/hooks/useMapSelectionQa.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d899cab3..ddc5aba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 109 Map selection QA shortcut (2026-06-25) + +- Added a Map workspace QA/QC shortcut for saved derived selection datasets. +- The shortcut reuses the existing QA comparison workflow and persists `QualityCheck`/`Metric` rows through the existing backend route. +- Added reference dataset selection, loading/error state and compact precision/recall/F1/status feedback beside the saved map selection. +- Kept QA orchestration in a dedicated frontend hook so `App.tsx` remains an orchestrator and API calls stay out of the shell component. +- No backend API contracts, migrations, provider fetching, AI behavior or new product domains were introduced. + ## Sprint 108 Map selection derived datasets (2026-06-25) - Added `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive` to persist a map bbox selection as a reusable derived vector dataset. diff --git a/backend/tests/test_sprint109_map_selection_qa_shortcut.py b/backend/tests/test_sprint109_map_selection_qa_shortcut.py new file mode 100644 index 00000000..12635e0d --- /dev/null +++ b/backend/tests/test_sprint109_map_selection_qa_shortcut.py @@ -0,0 +1,30 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_map_selection_qa_shortcut_uses_existing_qa_workflow_contract() -> None: + hook_path = ROOT / "frontend" / "src" / "hooks" / "useMapSelectionQa.ts" + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") + + assert hook_path.exists() + hook = hook_path.read_text(encoding="utf-8") + assert "qaApi.runQa" in hook + assert "loadQualityChecks" in hook + assert "candidate_dataset_id: latestSelectionDataset.id" in hook + assert "reference_dataset_id: selectedMapQaReferenceDatasetId" in hook + assert "setMapQaResult" in hook + assert "useMapSelectionQa" in app + assert "mapQaReferenceDatasets={referenceDatasets}" in app + assert "onRunMapSelectionQa={runMapSelectionQa}" in app + assert "Run QA on saved dataset" in map_workspace + assert "mapSelectionQaError" in map_workspace + assert "mapSelectionQaResult" in map_workspace + + +def test_app_keeps_qa_api_calls_out_of_orchestration() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "qaApi" not in app + assert "from './services/api'" not in app diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 64238ac7..76487f23 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,28 @@ +## Sprint 109 Map selection QA shortcut (2026-06-25) + +Changed: +- Added `useMapSelectionQa` to keep Map workspace QA/QC orchestration out of `App.tsx`. +- Added a Map workspace QA/QC shortcut after `Save as dataset`, allowing the latest derived map selection dataset to be compared against a selected reference dataset. +- The shortcut reuses the existing `qaApi.runQa` flow and refreshes persisted quality checks/project data after completion. +- Added inline precision, recall, F1 and quality-check status feedback in the Map workspace. +- Added compact styling for the Map selection QA surface. +- Updated `frontend/README.md`, `CHANGELOG.md` and `docs/TODO.md`. +- Added regression coverage in `backend/tests/test_sprint109_map_selection_qa_shortcut.py`. + +Validation: +- RED: `python -m pytest backend\tests\test_sprint109_map_selection_qa_shortcut.py -q` failed before implementation because the hook and wiring were absent. +- `python -m pytest backend\tests\test_sprint109_map_selection_qa_shortcut.py -q` passed: 2 tests. +- `cd frontend && npm run typecheck` passed. +- `cd backend && python -m pytest -q` passed: 347 tests. + +Limitations: +- The Map shortcut currently uses the existing QA comparison defaults with IoU threshold `0.5`. +- QA results are summarized inline; detailed false-positive/false-negative evidence remains in the QA/QC workspace. +- No backend API contract, migration, provider fetching, AI dependency, real model behavior or new product domain was added. + +Next recommended pass: +- Add a QA result drilldown/handoff from the Map workspace so operators can inspect persisted false-positive and false-negative evidence directly after running selection QA/QC. + ## Sprint 108 Map selection derived datasets (2026-06-25) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 3f4efb0d..78a57615 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -375,3 +375,4 @@ This file now starts with the current implementation status. Older preparation/b - [x] Add persisted vector area selection from the Map workspace with bbox extract and GeoJSON download. - [x] Persist map area selections as Export Center handoff artifacts. - [x] Persist map area selections as reusable derived vector datasets indexed into `vector_features`. +- [x] Add Map workspace QA/QC shortcut for saved derived selection datasets. diff --git a/frontend/README.md b/frontend/README.md index d65970d0..888c9869 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -280,6 +280,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst - The Map workspace can extract persisted vector features by area: open a ready vector dataset, use `Start map bbox` and click two map corners or enter EPSG:4326 bbox values, then run `Run area extract` to query backend `vector_features`. Results are highlighted on the map and can be downloaded as GeoJSON. - After an area extract, `Save area export` persists the selected FeatureCollection as a normal Export Center artifact (`vector_selection_geojson`) so the handoff remains in project export history. - `Save as dataset` persists the same selected FeatureCollection as a derived vector dataset, selects it in the workbench and keeps it queryable through backend `vector_features` for later QA/QC or analysis. +- Once a map selection has been saved as a derived dataset, the Map workspace can run QA/QC against a selected reference dataset without switching workspaces. The action reuses the existing QA comparison endpoint and shows precision, recall, F1 and persisted quality-check status inline. - The Data catalog shows a compact selected/reference/candidate/source summary and scan-friendly badges. Persisted `reference` datasets are shown as Reference, non-reference vector/GeoJSON layers are shown as QA Candidates for workbench scanning, and raster/other uploads remain Source. - Dataset cards explain the recommended next action and use compact two-line action buttons for inspect, map, export/QA and metadata refresh. Disabled actions keep a visible reason, such as `Vector/GeoJSON only`. - Raster controls show the latest generated tile manifest path from persisted `raster.tile` jobs and can hand that path directly to Detection Lab or Segmentation Lab with the selected raster dataset. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01eeaf30..33fc678b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow' import { useMapSelectionDataset } from './hooks/useMapSelectionDataset' +import { useMapSelectionQa } from './hooks/useMapSelectionQa' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useProviderCapabilities } from './hooks/useProviderCapabilities' @@ -399,6 +400,19 @@ function App(): JSX.Element { loadDatasetDetails, setMapLayerVisible, }) + const { + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, + runMapSelectionQa, + setSelectedMapQaReferenceDatasetId, + } = useMapSelectionQa({ + selectedProjectId, + latestSelectionDataset, + loadQualityChecks, + loadProjectData, + }) const { loadingDemoWorkflow, demoWorkflowMessage, @@ -826,6 +840,11 @@ function App(): JSX.Element { selectionDatasetSaving={selectionDatasetSaving} selectionDatasetError={selectionDatasetError} latestSelectionDatasetName={latestSelectionDataset?.name ?? null} + mapQaReferenceDatasets={referenceDatasets} + selectedMapQaReferenceDatasetId={selectedMapQaReferenceDatasetId} + mapSelectionQaRunning={mapSelectionQaRunning} + mapSelectionQaError={mapSelectionQaError} + mapSelectionQaResult={mapSelectionQaResult} availableMapDatasets={availableMapDatasets} selectedFeature={selectedMapFeature} onSelectMapArea={setSelectedMapAreaId} @@ -840,6 +859,8 @@ function App(): JSX.Element { onClearMapSelectionExtract={resetMapSelectionExtract} onExportMapSelection={exportMapSelectionGeoJson} onDeriveMapSelectionDataset={deriveMapSelectionDataset} + onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId} + onRunMapSelectionQa={runMapSelectionQa} /> ) : null} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 9384a36d..18b96249 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import GeoMap from '../GeoMap' -import type { AreaRead, DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../../types' +import type { AreaRead, DatasetCreateResponse, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' @@ -197,6 +197,11 @@ interface MapWorkspaceProps { selectionDatasetSaving: boolean selectionDatasetError: string | null latestSelectionDatasetName: string | null + mapQaReferenceDatasets: DatasetCreateResponse[] + selectedMapQaReferenceDatasetId: string + mapSelectionQaRunning: boolean + mapSelectionQaError: string | null + mapSelectionQaResult: QaComparisonResult | null availableMapDatasets: DatasetCreateResponse[] onSelectMapArea: (areaId: string) => void onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void @@ -210,6 +215,8 @@ interface MapWorkspaceProps { onClearMapSelectionExtract: () => void onExportMapSelection: (bbox: VectorSelectionBBox) => void onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => void + onSelectMapQaReferenceDataset: (datasetId: string) => void + onRunMapSelectionQa: () => void } export function MapWorkspace({ @@ -238,6 +245,11 @@ export function MapWorkspace({ selectionDatasetSaving, selectionDatasetError, latestSelectionDatasetName, + mapQaReferenceDatasets, + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, availableMapDatasets, onSelectMapArea, onOpenDatasetInMap, @@ -251,6 +263,8 @@ export function MapWorkspace({ onClearMapSelectionExtract, onExportMapSelection, onDeriveMapSelectionDataset, + onSelectMapQaReferenceDataset, + onRunMapSelectionQa, }: MapWorkspaceProps): JSX.Element { const [bboxSelectionMode, setBboxSelectionMode] = useState(false) const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) @@ -644,6 +658,54 @@ export function MapWorkspace({ {latestSelectionDatasetName ? (

Saved derived dataset: {latestSelectionDatasetName}

) : null} + {latestSelectionDatasetName ? ( +
+ + + {mapSelectionQaError ?

{mapSelectionQaError}

: null} + {mapSelectionQaResult ? ( +
+
+ Precision + {mapSelectionQaResult.precision ?? 'n/a'} +
+
+ Recall + {mapSelectionQaResult.recall ?? 'n/a'} +
+
+ F1 + {mapSelectionQaResult.f1_score ?? 'n/a'} +
+
+ Quality check + {mapSelectionQaResult.status} +
+
+ ) : null} +
+ ) : null} {areaSelectionPreviewFeatures.length > 0 ? (
diff --git a/frontend/src/hooks/useMapSelectionQa.ts b/frontend/src/hooks/useMapSelectionQa.ts new file mode 100644 index 00000000..a8662123 --- /dev/null +++ b/frontend/src/hooks/useMapSelectionQa.ts @@ -0,0 +1,85 @@ +import { useState } from 'react' +import type { DatasetCreateResponse, JobRead, QaComparisonRequest, QaComparisonResult, QualityCheckRead } from '../types' +import { formatError } from '../lib/formatError' +import { qaApi } from '../services/api' + +interface UseMapSelectionQaOptions { + selectedProjectId: string | null + latestSelectionDataset: DatasetCreateResponse | null + loadQualityChecks: (projectId?: string | null) => Promise + loadProjectData: (projectId: string) => Promise +} + +export function useMapSelectionQa({ + selectedProjectId, + latestSelectionDataset, + loadQualityChecks, + loadProjectData, +}: UseMapSelectionQaOptions) { + const [selectedMapQaReferenceDatasetId, setSelectedMapQaReferenceDatasetId] = useState('') + const [mapSelectionQaRunning, setMapSelectionQaRunning] = useState(false) + const [mapSelectionQaError, setMapSelectionQaError] = useState(null) + const [mapSelectionQaResult, setMapQaResult] = useState(null) + + const runMapSelectionQa = async () => { + if (!selectedProjectId) { + setMapSelectionQaError('Select a project before running QA/QC.') + return + } + if (!latestSelectionDataset) { + setMapSelectionQaError('Save the map selection as a dataset before running QA/QC.') + return + } + if (!selectedMapQaReferenceDatasetId) { + setMapSelectionQaError('Select a reference dataset for QA/QC.') + return + } + if (latestSelectionDataset.id === selectedMapQaReferenceDatasetId) { + setMapSelectionQaError('Candidate and reference datasets must be different.') + return + } + + setMapSelectionQaRunning(true) + setMapSelectionQaError(null) + setMapQaResult(null) + try { + const request: QaComparisonRequest = { + candidate_dataset_id: latestSelectionDataset.id, + reference_dataset_id: selectedMapQaReferenceDatasetId, + iou_threshold: 0.5, + area_id: latestSelectionDataset.area_id ?? null, + } + const job: JobRead = await qaApi.runQa(request) + if (job.status === 'failed') { + setMapSelectionQaError(job.error_message || 'Map selection QA/QC failed') + return + } + const payload = job.result_json + if (!payload || typeof payload !== 'object') { + setMapSelectionQaError('Map selection QA/QC result was not available') + return + } + const parsed = payload as unknown as QaComparisonResult + if (!parsed || typeof parsed.status !== 'string') { + setMapSelectionQaError('Map selection QA/QC result format was unexpected') + return + } + setMapQaResult(parsed) + await loadQualityChecks(selectedProjectId) + await loadProjectData(selectedProjectId) + } catch (error) { + setMapSelectionQaError(formatError(error, 'Map selection QA/QC failed')) + } finally { + setMapSelectionQaRunning(false) + } + } + + return { + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, + runMapSelectionQa, + setSelectedMapQaReferenceDatasetId, + } +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index dfff2274..e691499b 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -2669,6 +2669,37 @@ button.entity-card { padding-top: 0.68rem; } +.map-selection-qa-surface { + display: grid; + grid-template-columns: minmax(11rem, 1fr) auto; + gap: 0.58rem; + align-items: end; + min-width: 0; + border-top: 1px solid var(--line); + padding-top: 0.68rem; +} + +.map-selection-qa-surface label { + min-width: 0; +} + +.map-selection-qa-surface .primary-action { + width: fit-content; + max-width: 100%; + margin-top: 0; +} + +.map-selection-qa-surface .error, +.map-selection-qa-surface .feature-extract-grid { + grid-column: 1 / -1; +} + +@media (max-width: 720px) { + .map-selection-qa-surface { + grid-template-columns: 1fr; + } +} + .feature-extract-surface { display: grid; gap: 0.7rem;