Complete the V1 analysis handoff
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 02:52:15 +02:00
parent d9bc8fdbed
commit 03c6154cac
9 changed files with 240 additions and 14 deletions
+15
View File
@@ -7,6 +7,21 @@
# Changelog # Changelog
## Sprint 232 V1 user-flow completion (2026-07-17)
- Completed the primary end-user handoff from map analysis to a usable result,
local AI question and downloads without exposing technical project or model
setup in the normal path.
- Added explicit metric names to the all-theme summary so values such as a
station water level are no longer shown without their measured meaning.
- Kept vector selections downloadable as GeoJSON and added JSON downloads for
governed raster analyses and complete historical comparisons.
- Added compact post-analysis actions for the local grounded Ollama assistant
and the existing Downloads workspace, preserving the active area context.
- Replaced the accumulated TODO lists as the product roadmap with one current
V1 completion board; historical sprint checklists remain retained as
implementation evidence.
## Sprint 231 Governed orthophoto release promotion (2026-07-17) ## Sprint 231 Governed orthophoto release promotion (2026-07-17)
- Added an operator-only `plan -> stage -> review -> apply` coordinator for one - Added an operator-only `plan -> stage -> review -> apply` coordinator for one
@@ -24,7 +24,7 @@ def test_map_first_explorer_is_the_default_product_flow() -> None:
assert "useMapThemeSelectionInsights" in workspace assert "useMapThemeSelectionInsights" in workspace
assert "datasetsApi.selectVectorFeatures" in read("frontend/src/hooks/useMapThemeSelectionInsights.ts") assert "datasetsApi.selectVectorFeatures" in read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
assert "activeSelectionResult" in workspace assert "activeSelectionResult" in workspace
assert "downloadActiveThemeSelection" in workspace assert "downloadActiveThemeResult" in workspace
assert "disabled={!activeSelectionResult}" in workspace assert "disabled={!activeSelectionResult}" in workspace
assert "bboxesEqual(mapSelectionBbox, selectedAreaBbox)" in workspace assert "bboxesEqual(mapSelectionBbox, selectedAreaBbox)" in workspace
@@ -0,0 +1,43 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_theme_overview_names_the_metric_instead_of_showing_a_bare_value() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
styles = read("frontend/src/styles/app.css")
assert 'className="geo-theme-result-value"' in workspace
assert "item.result.summary.metric_label" in workspace
assert ".geo-theme-result-value" in styles
def test_current_and_historical_results_are_downloadable() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "activeTheme.id}-analysis.json" in workspace
assert "activeTheme.id}-selection.geojson" in workspace
assert "application/geo+json" in workspace
assert "downloadTemporalComparison" in workspace
assert "Download vergelijking" in workspace
assert "Kopieer vergelijking" in workspace
def test_completed_analysis_hands_off_to_ai_and_downloads_responsively() -> None:
app = read("frontend/src/App.tsx")
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
styles = read("frontend/src/styles/app.css")
assert "onOpenAssistant: () => void" in workspace
assert "onOpenExports: () => void" in workspace
assert "Stel AI-vraag" in workspace
assert "Open downloads" in workspace
assert "onOpenAssistant={() => setActiveWorkspace('assistant')}" in app
assert "onOpenExports={() => setActiveWorkspace('exports')}" in app
assert ".geo-result-next-actions" in styles
assert ".geo-results-panel > .geo-result-next-actions" in styles
+38
View File
@@ -9839,3 +9839,41 @@ Next:
workspace so official/local edition, review identity, Dataset ID and hash workspace so official/local edition, review identity, Dataset ID and hash
integrity can be inspected without shell access. Keep all refresh actions integrity can be inspected without shell access. Keep all refresh actions
operator-only and outside the browser. operator-only and outside the browser.
## Sprint 232 - V1 user-flow completion (2026-07-17)
Implemented:
- Re-audited the live platform from the end-user entry point instead of
continuing operator release work. The normal path is now explicitly Map ->
area -> measurements/evolution -> AI question or download.
- Added the semantic metric label to every row in the 15-theme result summary.
This distinguishes, for example, a Waterinfo station level from mapped
surface-water area or watercourse length.
- Kept vector output as GeoJSON and added complete JSON downloads for governed
raster analyses and historical comparisons. Copy actions now copy the full
analysis response rather than an empty raster GeoJSON shell.
- Added direct post-analysis handoff buttons to `AI-vragen` and `Downloads`.
The selected Area or drawn bbox remains in shared App state.
- Added one current V1 completion board to `docs/TODO.md`; legacy checklists
remain preserved but no longer define product readiness.
Validation so far:
- Live pre-change acceptance selected the persisted Mol municipality Area and
completed all 15 PostGIS-backed themes. Representative results included
502.14 ha building footprint, 3,626.56 ha forest/green, 36,783 inhabitants,
3,223.5 ha agricultural use and 1,015.51 km roads.
- Live historical acceptance compared the official modern forest series from
2013 to 2025 over Mol: five observations, 3,723.17 ha to 3,626.56 ha,
absolute change -96.6 ha and relative change -2.6%.
- Ollama status and installed-model discovery were live. A grounded assistant
query returned HTTP 200; a separate `qwen3.5:4b` request completed in 13.01
seconds. Model answers remain advisory and source evidence remains primary.
- Focused end-user, temporal/Ollama and Sprint 232 tests pass: 38 tests.
- Frontend typecheck and production build pass.
- Complete readiness passes with 881 backend tests, 110 documented routes,
one Alembic head `202607160001`, frontend typecheck and production build.
Next:
- Run the complete release gate, deploy to Tower and repeat the same user flow
with visual checks of metric labels, raster/evolution downloads and both
post-analysis handoffs.
+35
View File
@@ -1,5 +1,40 @@
# GeoIntel TODO # GeoIntel TODO
## Actuele V1-productstatus
Dit is het enige actuele afwerkingsbord. De lange sprint- en
voorbereidingslijsten verderop blijven bewaard als historisch bewijs, maar zijn
geen open productroadmap meer.
- [x] Open automatisch de volledige Kempen-werkruimte met Mol als snel
selecteerbaar werkgebied; een technische project- of regioselectie is niet
vereist.
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
volledige werkgebied en analyseer alle 15 beschikbare thema's uit PostGIS.
- [x] Toon betekenisvolle eenheden en metrieklabels voor oppervlakte, lengte,
inwoners, hoogte, scenario's en stationsmetingen; objectaantallen zijn
ondersteunend.
- [x] Vergelijk beschikbare historische meetmomenten met een volledige
tijdreeks, absolute wijziging, procentuele wijziging en bronbeperking.
- [x] Download vectorresultaten als GeoJSON, rasteranalyses als JSON en
historische vergelijkingen als JSON; kopieren blijft voor alle drie mogelijk.
- [x] Ga vanuit een afgeronde analyse rechtstreeks naar de lokale
brongebonden Ollama-assistent of naar Downloads.
- [x] Gebruik de operationele lokale YOLO/PyTorch-keten alleen met persisted
detecties, GRB-QA en expliciete controlewaarschuwingen.
- [x] Draai de volledige applicatie in de beheersbare Unraid-container op poort
1202 met PostGIS, backend, frontend, Ollama-koppeling en modelconfiguratie.
Bewuste, niet-blokkerende grenzen:
- Watervolume blijft niet beschikbaar zolang geen gekoppelde bathymetrie,
bodemhoogte en onzekerheidsmodel bestaan. Dit is geospatiale correctheid,
geen onafgewerkte UI.
- Gebouwdetectie is operationeel maar blijft een controlekandidaat; verdere
training gebeurt pas na nieuwe, onafhankelijke reviewdata.
- Een echt SAM/YOLO-seg-model en een trainingsstudio blijven post-V1. De
persistente segmentatie- en QA-fundering blijft beschikbaar voor later.
## Map-first Mol explorer ## Map-first Mol explorer
- [x] Make the Mol geographic explorer the default workflow. - [x] Make the Mol geographic explorer the default workflow.
+8
View File
@@ -71,6 +71,14 @@ but is not required for the primary choose-theme, draw-area, read-result flow.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count. The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
Every cross-theme result now names the measured quantity next to the value, so
a station water level, mapped area and line length cannot appear as an
unexplained bare number. Current vector selections download as GeoJSON,
current raster analyses download as JSON and historical comparisons download
as JSON with their complete timeline and provenance. After a successful
analysis, compact next actions carry the same spatial context directly into
`AI-vragen` or `Downloads`.
For a rectangle in `Laatste toestand`, `Herken gebouwen` runs the complete For a rectangle in `Laatste toestand`, `Herken gebouwen` runs the complete
operational image path without opening the technical AI screen: bounded operational image path without opening the technical AI screen: bounded
official orthophoto acquisition, raster persistence, safe tiling, the active official orthophoto acquisition, raster persistence, safe tiling, the active
+2
View File
@@ -1079,6 +1079,8 @@ function App(): JSX.Element {
onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run} onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run}
onSelectOrthophotoProduct={mapOrthophotoAnalysis.setSelectedProductKey} onSelectOrthophotoProduct={mapOrthophotoAnalysis.setSelectedProductKey}
onClearQualityEvidence={clearQualityEvidenceGeoJson} onClearQualityEvidence={clearQualityEvidenceGeoJson}
onOpenAssistant={() => setActiveWorkspace('assistant')}
onOpenExports={() => setActiveWorkspace('exports')}
/> />
) : null} ) : null}
+62 -11
View File
@@ -625,8 +625,8 @@ function copyText(text: string): void {
fallbackCopyText(text) fallbackCopyText(text)
} }
function downloadJsonFile(filename: string, payload: unknown): void { function downloadJsonFile(filename: string, payload: unknown, contentType = 'application/json'): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/geo+json' }) const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
@@ -716,6 +716,8 @@ interface MapWorkspaceProps {
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean> onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onSelectOrthophotoProduct: (productKey: string) => void onSelectOrthophotoProduct: (productKey: string) => void
onClearQualityEvidence?: () => void onClearQualityEvidence?: () => void
onOpenAssistant: () => void
onOpenExports: () => void
} }
export function MapWorkspace({ export function MapWorkspace({
@@ -797,6 +799,8 @@ export function MapWorkspace({
onRunOrthophotoAnalysis, onRunOrthophotoAnalysis,
onSelectOrthophotoProduct, onSelectOrthophotoProduct,
onClearQualityEvidence, onClearQualityEvidence,
onOpenAssistant,
onOpenExports,
}: MapWorkspaceProps): JSX.Element { }: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false) const [advancedMode, setAdvancedMode] = useState(false)
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => { const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => {
@@ -1135,7 +1139,7 @@ export function MapWorkspace({
if (!selectedFeatureGeoJson) { if (!selectedFeatureGeoJson) {
return return
} }
downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson) downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson, 'application/geo+json')
} }
const copySelectedMapFeatureProperties = () => { const copySelectedMapFeatureProperties = () => {
@@ -1192,22 +1196,46 @@ export function MapWorkspace({
if (!mapSelectionResult) { if (!mapSelectionResult) {
return return
} }
downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson) downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson, 'application/geo+json')
} }
const copyAreaSelection = () => { const copyAreaSelection = () => {
copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
} }
const downloadActiveThemeSelection = () => { const downloadActiveThemeResult = () => {
if (!activeSelectionResult) { if (!activeSelectionResult) {
return return
} }
downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson) if (activeThemeDataset?.dataset_type === 'raster') {
downloadJsonFile(`${activeTheme.id}-analysis.json`, {
project_id: selectedProjectId,
area_id: selectedMapArea?.id ?? null,
area_name: selectedMapArea?.name ?? null,
theme: activeTheme,
dataset_id: activeThemeDataset.id,
dataset_name: activeThemeDataset.name,
source_name: activeThemeDataset.source_name,
result: activeSelectionResult,
})
return
}
downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson, 'application/geo+json')
} }
const copyActiveThemeSelection = () => { const copyActiveThemeResult = () => {
copyText(JSON.stringify(activeSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) copyText(JSON.stringify(activeSelectionResult ?? {}, null, 2))
}
const downloadTemporalComparison = () => {
if (!temporalComparison) {
return
}
downloadJsonFile(`${activeTheme.id}-evolution-${temporalComparison.earlier.observed_at.slice(0, 10)}-${temporalComparison.later.observed_at.slice(0, 10)}.json`, temporalComparison)
}
const copyTemporalComparison = () => {
copyText(JSON.stringify(temporalComparison ?? {}, null, 2))
} }
const saveAreaSelectionExport = () => { const saveAreaSelectionExport = () => {
@@ -1888,7 +1916,10 @@ export function MapWorkspace({
<strong>{theme.label}</strong> <strong>{theme.label}</strong>
<small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small> <small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small>
</span> </span>
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b> <span className="geo-theme-result-value">
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
</span>
</div> </div>
) )
})} })}
@@ -1930,8 +1961,28 @@ export function MapWorkspace({
{analysisMode === 'current' ? ( {analysisMode === 'current' ? (
<div className="geo-result-actions"> <div className="geo-result-actions">
<button className="secondary-action" disabled={!activeSelectionResult || activeThemeDataset?.dataset_type === 'raster'} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button> <button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeResult}>
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeSelection}>Kopieer gegevens</button> {activeThemeDataset?.dataset_type === 'raster' ? 'Download analyse' : 'Download GeoJSON'}
</button>
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeResult}>Kopieer gegevens</button>
</div>
) : null}
{analysisMode === 'evolution' && temporalComparison ? (
<div className="geo-result-actions">
<button className="secondary-action" type="button" onClick={downloadTemporalComparison}>Download vergelijking</button>
<button className="secondary-action" type="button" onClick={copyTemporalComparison}>Kopieer vergelijking</button>
</div>
) : null}
{activeSelectionResult || temporalComparison ? (
<div className="geo-result-next-actions" aria-label="Volgende stap">
<span>
<strong>Analyse klaar</strong>
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
</span>
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
<button className="secondary-action" type="button" onClick={onOpenExports}>Open downloads</button>
</div> </div>
) : null} ) : null}
</> </>
+36 -2
View File
@@ -6459,6 +6459,17 @@ section {
text-align: right; text-align: right;
} }
.geo-theme-result-value {
display: grid;
justify-items: end;
min-width: 8.5rem;
text-align: right;
}
.geo-theme-result-value small {
max-width: 13rem;
}
.geo-data-notice { .geo-data-notice {
margin: 0; margin: 0;
border-left: 3px solid #b17a31; border-left: 3px solid #b17a31;
@@ -6542,6 +6553,27 @@ section {
margin-top: auto; margin-top: auto;
} }
.geo-result-next-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.45rem;
align-items: center;
border-top: 1px solid #dfe7e3;
padding-top: 0.65rem;
}
.geo-result-next-actions > span {
display: grid;
gap: 0.12rem;
min-width: 0;
}
.geo-result-next-actions small {
color: #687570;
font-size: 0.66rem;
line-height: 1.35;
}
.geo-explorer-footer { .geo-explorer-footer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -6581,7 +6613,8 @@ section {
.geo-results-panel > .geo-panel-heading, .geo-results-panel > .geo-panel-heading,
.geo-results-panel > .error, .geo-results-panel > .error,
.geo-results-panel > .geo-data-notice, .geo-results-panel > .geo-data-notice,
.geo-results-panel > .geo-result-actions { .geo-results-panel > .geo-result-actions,
.geo-results-panel > .geo-result-next-actions {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -6661,7 +6694,8 @@ section {
} }
.geo-map-actions, .geo-map-actions,
.geo-result-actions { .geo-result-actions,
.geo-result-next-actions {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }