Add governed source freshness audit
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-16 15:05:42 +02:00
parent 2723043e3d
commit 1e0e7b6499
22 changed files with 1148 additions and 0 deletions
+7
View File
@@ -4,6 +4,13 @@ React + TypeScript + MapLibre workbench for regional geographic analysis.
The persisted `Kempen Regional Workbench` is the automatic operational data context. Its datasets are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Regional population and modern forest snapshots use the same current/evolution flow as Mol, while explicit project selection stays available under advanced management.
The Status workspace includes one compact `Actualiteit en versiecontrole`
surface. It separates sources that are current, due for a catalogue review,
require local integrity review or are local artifacts. Only attention items are
expanded by default; all source detail remains available through disclosure.
The refresh button reruns the local read-only audit and never downloads or
replaces source data.
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its current coverage-aligned benchmark is approximately precision 0.614, recall 0.606 and F1 0.607 over seven positive AOIs, with zero detections in all three pure-empty controls. A reviewed challenger remains inactive because it produced two detections in empty Postel forest.
+9
View File
@@ -14,6 +14,7 @@ import { AreaPanel } from './components/project/AreaPanel'
import { ProjectPanel } from './components/project/ProjectPanel'
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
import { SourceFreshnessPanel } from './components/status/SourceFreshnessPanel'
import type { DatasetCreateResponse } from './types'
import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
@@ -31,6 +32,7 @@ import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { useSourceFreshness } from './hooks/useSourceFreshness'
import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap'
import { useViewportVectorLayer } from './hooks/useViewportVectorLayer'
import { getDatasetDisplayName } from './lib/datasetDisplay'
@@ -92,6 +94,7 @@ function App(): JSX.Element {
setProjectForm,
setAreaForm,
} = useProjectWorkspace()
const sourceFreshness = useSourceFreshness(selectedProjectId)
const selectProject = (projectId: string) => {
setMapContentMode('dataset')
setSelectedProjectId(projectId)
@@ -852,6 +855,12 @@ function App(): JSX.Element {
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
/>
<SourceFreshnessPanel
report={sourceFreshness.report}
loading={sourceFreshness.loading}
error={sourceFreshness.error}
onRefresh={() => { void sourceFreshness.refresh() }}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
@@ -0,0 +1,96 @@
import type { SourceFreshnessItem, SourceFreshnessReport, SourceFreshnessStatus } from '../../types'
interface SourceFreshnessPanelProps {
report: SourceFreshnessReport | null
loading: boolean
error: string | null
onRefresh: () => void
}
function statusLabel(status: SourceFreshnessStatus): string {
if (status === 'current') return 'actueel'
if (status === 'due') return 'controle nodig'
if (status === 'review_required') return 'nakijken'
return 'lokaal'
}
function formatDate(value?: string | null): string {
if (!value) return 'geen datum'
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
}
function integrityIssueCount(item: SourceFreshnessItem): number {
return Object.values(item.integrity).reduce((total, value) => total + value, 0)
}
function SourceRow({ item }: { item: SourceFreshnessItem }): JSX.Element {
const issueCount = integrityIssueCount(item)
return (
<div className={`source-freshness-row source-freshness-row-${item.status}`}>
<div className="source-freshness-main">
<div>
<strong>{item.display_name}</strong>
<span>{item.dataset_count} datasets · {item.version_count} versies</span>
</div>
<span className="source-freshness-status">{statusLabel(item.status)}</span>
</div>
<p>{item.reason}</p>
<div className="source-freshness-meta">
<span>Laatste import: {formatDate(item.latest_imported_at)}</span>
{item.latest_source_version ? <span>Editie: {item.latest_source_version}</span> : null}
{item.historical_series ? <span>Historische reeks</span> : null}
{issueCount ? <span>{issueCount} integriteitsafwijking{issueCount === 1 ? '' : 'en'}</span> : null}
</div>
</div>
)
}
export function SourceFreshnessPanel({ report, loading, error, onRefresh }: SourceFreshnessPanelProps): JSX.Element {
const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? []
const summary = report?.summary
return (
<section className="source-freshness-panel" aria-label="Bronactualiteit en versie-integriteit">
<div className="source-freshness-header">
<div>
<p className="eyebrow">Bronbeheer</p>
<h2>Actualiteit en versiecontrole</h2>
<p>Controleert lokale publicaties, versies en bestanden zonder externe bronnen automatisch te wijzigen.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefresh} disabled={loading}>
{loading ? 'Controleren...' : 'Opnieuw controleren'}
</button>
</div>
{error ? <p className="inline-error">{error}</p> : null}
{!report && !error ? <p className="empty-state">{loading ? 'Bronstatus wordt gecontroleerd...' : 'Geen projectbronstatus beschikbaar.'}</p> : null}
{report && summary ? (
<>
<div className="source-freshness-summary" aria-label="Samenvatting broncontrole">
<span><strong>{summary.current_count}</strong> actueel</span>
<span><strong>{summary.due_count}</strong> controle nodig</span>
<span><strong>{summary.review_required_count}</strong> nakijken</span>
<span><strong>{summary.integrity_issue_count}</strong> integriteitsfouten</span>
</div>
{attentionItems.length ? (
<div className="source-freshness-attention" aria-label="Bronnen die aandacht vragen">
{attentionItems.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
) : (
<p className="source-freshness-ok">Alle externe bronpublicaties hebben geldige lokale versie-evidentie.</p>
)}
<details className="source-freshness-details">
<summary>
<span>Alle {summary.source_count} bronnen bekijken</span>
<strong>gecontroleerd {formatDate(report.generated_at)}</strong>
</summary>
<div className="source-freshness-list">
{report.items.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
<p className="source-freshness-limitation">{report.limitations.join(' ')}</p>
</details>
</>
) : null}
</section>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { datasetsApi } from '../services/api'
import type { SourceFreshnessReport } from '../types'
export function useSourceFreshness(selectedProjectId: string | null) {
const [report, setReport] = useState<SourceFreshnessReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestSequence = useRef(0)
const refresh = useCallback(async () => {
const requestId = ++requestSequence.current
if (!selectedProjectId) {
setReport(null)
setError(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const nextReport = await datasetsApi.sourceFreshness(selectedProjectId)
if (requestSequence.current === requestId) {
setReport(nextReport)
}
} catch (caught) {
if (requestSequence.current === requestId) {
setError(caught instanceof Error ? caught.message : 'De broncontrole kon niet worden geladen.')
}
} finally {
if (requestSequence.current === requestId) {
setLoading(false)
}
}
}, [selectedProjectId])
useEffect(() => {
void refresh()
return () => {
requestSequence.current += 1
}
}, [refresh])
return { report, loading, error, refresh }
}
+3
View File
@@ -27,6 +27,7 @@ import type {
ThematicRasterAcquireRequest,
ThematicRasterProductRead,
ThematicRasterSelectionResponse,
SourceFreshnessReport,
} from '../../types'
const DATASET_PAGE_SIZE = 200
@@ -58,6 +59,8 @@ async function listProjectDatasets(projectId: string): Promise<DatasetListRespon
export const datasetsApi = {
list: listProjectDatasets,
sourceFreshness: (projectId: string): Promise<SourceFreshnessReport> =>
apiGet<SourceFreshnessReport>(`/api/v1/projects/${projectId}/datasets/source-freshness`),
upload: (
projectId: string,
payload: {
+167
View File
@@ -821,6 +821,173 @@ details.ai-lab-model-surface > summary strong {
-webkit-line-clamp: 2;
}
.source-freshness-panel {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
background: #ffffff;
}
.source-freshness-header {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
}
.source-freshness-header h2 {
margin: 0;
font-size: 1.05rem;
}
.source-freshness-header p:last-child {
max-width: 52rem;
margin: 0.3rem 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.source-freshness-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #f8fafb;
}
.source-freshness-summary span {
padding: 0.7rem 1rem;
border-right: 1px solid var(--line);
color: var(--muted);
font-size: 0.75rem;
}
.source-freshness-summary span:last-child {
border-right: 0;
}
.source-freshness-summary strong {
display: block;
margin-bottom: 0.15rem;
color: #24333d;
font-size: 1.05rem;
}
.source-freshness-attention,
.source-freshness-list {
display: grid;
}
.source-freshness-row {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
}
.source-freshness-row:last-child {
border-bottom: 0;
}
.source-freshness-row-review_required {
box-shadow: inset 3px 0 0 #b84e4e;
}
.source-freshness-row-due {
box-shadow: inset 3px 0 0 #ca7a18;
}
.source-freshness-row-current {
box-shadow: inset 3px 0 0 #2d9272;
}
.source-freshness-main {
display: flex;
gap: 1rem;
align-items: start;
justify-content: space-between;
}
.source-freshness-main > div {
display: grid;
gap: 0.15rem;
}
.source-freshness-main strong {
color: #23313b;
font-size: 0.86rem;
}
.source-freshness-main span,
.source-freshness-row p,
.source-freshness-meta {
color: var(--muted);
font-size: 0.75rem;
}
.source-freshness-row p {
margin: 0.4rem 0;
}
.source-freshness-status {
flex: 0 0 auto;
color: #33424d !important;
font-weight: 750;
}
.source-freshness-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
}
.source-freshness-ok,
.source-freshness-limitation {
margin: 0;
padding: 0.8rem 1rem;
color: var(--muted);
font-size: 0.78rem;
}
.source-freshness-details {
border-top: 1px solid var(--line);
}
.source-freshness-details > summary {
display: flex;
cursor: pointer;
gap: 1rem;
justify-content: space-between;
padding: 0.72rem 1rem;
color: #33424d;
font-size: 0.78rem;
font-weight: 700;
}
.source-freshness-list {
max-height: 30rem;
overflow: auto;
border-top: 1px solid var(--line);
}
@media (max-width: 760px) {
.source-freshness-header {
align-items: stretch;
flex-direction: column;
}
.source-freshness-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.source-freshness-summary span:nth-child(2) {
border-right: 0;
}
.source-freshness-summary span:nth-child(-n + 2) {
border-bottom: 1px solid var(--line);
}
}
.workflow-guidance-panel {
border: 1px solid var(--line);
border-left: 1px solid var(--line);
+46
View File
@@ -663,6 +663,52 @@ export interface DatasetListResponse {
offset: number
}
export type SourceFreshnessStatus = 'current' | 'due' | 'review_required' | 'local'
export interface SourceIntegritySummary {
missing_version_count: number
checksum_mismatch_count: number
missing_storage_file_count: number
size_mismatch_count: number
}
export interface SourceFreshnessItem {
source_name: string
display_name: string
dataset_count: number
ready_count: number
version_count: number
latest_imported_at?: string | null
latest_observed_at?: string | null
latest_source_version?: string | null
refresh_policy: 'rolling_snapshot' | 'annual_release' | 'edition' | 'scenario' | 'archive' | 'local'
review_interval_days?: number | null
next_review_at?: string | null
status: SourceFreshnessStatus
historical_series: boolean
auto_refresh_supported: false
reason: string
recommended_action: string
integrity: SourceIntegritySummary
}
export interface SourceFreshnessReport {
project_id: string
generated_at: string
summary: {
source_count: number
dataset_count: number
current_count: number
due_count: number
review_required_count: number
local_count: number
sources_with_integrity_issues: number
integrity_issue_count: number
}
items: SourceFreshnessItem[]
limitations: string[]
}
export interface GeojsonEnvelopeResponse {
data: object
}