Add map selection QA shortcut
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<p className="muted">Saved derived dataset: {latestSelectionDatasetName}</p>
|
||||
) : null}
|
||||
{latestSelectionDatasetName ? (
|
||||
<div className="map-selection-qa-surface" aria-label="Map selection QA shortcut">
|
||||
<label>
|
||||
Reference dataset
|
||||
<select
|
||||
value={selectedMapQaReferenceDatasetId}
|
||||
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
|
||||
disabled={mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
|
||||
>
|
||||
<option value="">Select reference</option>
|
||||
{mapQaReferenceDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{dataset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="primary-action"
|
||||
disabled={!selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
|
||||
type="button"
|
||||
onClick={onRunMapSelectionQa}
|
||||
>
|
||||
{mapSelectionQaRunning ? 'Running QA...' : 'Run QA on saved dataset'}
|
||||
</button>
|
||||
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
|
||||
{mapSelectionQaResult ? (
|
||||
<div className="feature-extract-grid" aria-label="Map selection QA result">
|
||||
<div>
|
||||
<span>Precision</span>
|
||||
<strong>{mapSelectionQaResult.precision ?? 'n/a'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Recall</span>
|
||||
<strong>{mapSelectionQaResult.recall ?? 'n/a'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>F1</span>
|
||||
<strong>{mapSelectionQaResult.f1_score ?? 'n/a'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Quality check</span>
|
||||
<strong>{mapSelectionQaResult.status}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{areaSelectionPreviewFeatures.length > 0 ? (
|
||||
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
|
||||
<table>
|
||||
|
||||
@@ -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<QualityCheckRead[] | void>
|
||||
loadProjectData: (projectId: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function useMapSelectionQa({
|
||||
selectedProjectId,
|
||||
latestSelectionDataset,
|
||||
loadQualityChecks,
|
||||
loadProjectData,
|
||||
}: UseMapSelectionQaOptions) {
|
||||
const [selectedMapQaReferenceDatasetId, setSelectedMapQaReferenceDatasetId] = useState('')
|
||||
const [mapSelectionQaRunning, setMapSelectionQaRunning] = useState(false)
|
||||
const [mapSelectionQaError, setMapSelectionQaError] = useState<string | null>(null)
|
||||
const [mapSelectionQaResult, setMapQaResult] = useState<QaComparisonResult | null>(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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user