feat: add measured detection review loop
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 03:00:08 +02:00
parent 94ecd377b7
commit d22abe8e7b
27 changed files with 1578 additions and 29 deletions
+21 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -327,6 +327,12 @@ function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
function formatPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
@@ -445,6 +451,8 @@ interface MapWorkspaceProps {
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
orthophotoAnalysisQuality: DetectionQaResult | null
orthophotoAnalysisDetectionCount: number | null
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
@@ -518,6 +526,8 @@ export function MapWorkspace({
orthophotoAnalysisStatus,
orthophotoAnalysisError,
orthophotoAnalysisRunning,
orthophotoAnalysisQuality,
orthophotoAnalysisDetectionCount,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
@@ -1194,6 +1204,16 @@ export function MapWorkspace({
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
{orthophotoAnalysisQuality ? (
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
<div><span>Kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
<div><span>Precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
<div><span>Recall</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
<div><span>Fout</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')}</strong></div>
<div><span>Gemist</span><strong>{orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
</div>
) : null}
</div>
) : null}
@@ -0,0 +1,244 @@
import { useEffect, useState } from 'react'
import { qaApi } from '../../services/api'
import type {
DetectionEvidenceRole,
DetectionReviewDecision,
DetectionReviewList,
DetectionReviewRead,
} from '../../types'
import { formatError } from '../../lib/formatError'
interface DetectionReviewPanelProps {
projectId: string
qualityCheckId: string
onOpenEvidenceMap?: (qualityCheckId: string) => void
}
const ROLE_LABELS: Record<DetectionEvidenceRole, string> = {
false_positive: 'Onterecht gevonden',
false_negative: 'Gemist gebouw',
}
const DECISION_LABELS: Record<DetectionReviewDecision, string> = {
confirmed_model_false_positive: 'Bevestigde foutdetectie',
confirmed_model_false_negative: 'Bevestigd gemist gebouw',
reference_gap_or_change: 'Referentie ontbreekt of is verouderd',
qa_alignment_mismatch: 'Vormvergelijking is te streng',
imagery_obscured_or_uncertain: 'Luchtbeeld is onduidelijk',
uncertain: 'Verder onderzoek nodig',
unreviewed: 'Nog niet beoordeeld',
}
const ROLE_DECISIONS: Record<DetectionEvidenceRole, DetectionReviewDecision[]> = {
false_positive: [
'unreviewed',
'confirmed_model_false_positive',
'reference_gap_or_change',
'qa_alignment_mismatch',
'uncertain',
],
false_negative: [
'unreviewed',
'confirmed_model_false_negative',
'reference_gap_or_change',
'qa_alignment_mismatch',
'imagery_obscured_or_uncertain',
'uncertain',
],
}
function shortId(value: string): string {
return value.length > 18 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value
}
export function DetectionReviewPanel({
projectId,
qualityCheckId,
onOpenEvidenceMap,
}: DetectionReviewPanelProps): JSX.Element {
const [queue, setQueue] = useState<DetectionReviewList | null>(null)
const [loading, setLoading] = useState(false)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [roleFilter, setRoleFilter] = useState<'all' | DetectionEvidenceRole>('all')
const [statusFilter, setStatusFilter] = useState<'all' | 'reviewed' | 'unreviewed'>('unreviewed')
const [offset, setOffset] = useState(0)
const [draftDecisions, setDraftDecisions] = useState<Record<string, DetectionReviewDecision>>({})
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({})
const load = async () => {
setLoading(true)
setError(null)
try {
const result = await qaApi.listDetectionReviews(projectId, qualityCheckId, {
evidenceRole: roleFilter === 'all' ? undefined : roleFilter,
reviewed: statusFilter === 'all' ? undefined : statusFilter === 'reviewed',
limit: 50,
offset,
})
setQueue(result)
setDraftDecisions(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.decision])))
setDraftNotes(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.notes ?? ''])))
} catch (caught) {
setError(formatError(caught, 'De controlelijst kon niet worden geladen'))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [projectId, qualityCheckId, roleFilter, statusFilter, offset])
const save = async (item: DetectionReviewRead) => {
const key = reviewKey(item)
setSavingKey(key)
setError(null)
try {
await qaApi.upsertDetectionReview(projectId, qualityCheckId, {
evidence_role: item.evidence_role,
evidence_feature_id: item.evidence_feature_id,
decision: draftDecisions[key] ?? item.decision,
notes: draftNotes[key]?.trim() || null,
reviewed_by: 'operator',
})
await load()
} catch (caught) {
setError(formatError(caught, 'De beoordeling kon niet worden bewaard'))
} finally {
setSavingKey(null)
}
}
return (
<section className="detection-review-panel" aria-label="Handmatige controle van beeldanalyse">
<div className="panel-title-row">
<div>
<h3>Fouten controleren</h3>
<p className="muted">Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.</p>
</div>
<button type="button" className="secondary-action" onClick={() => onOpenEvidenceMap?.(qualityCheckId)}>
Op kaart bekijken
</button>
</div>
{queue ? (
<div className="detection-review-summary">
<div><span>Te beoordelen</span><strong>{queue.summary.total}</strong></div>
<div><span>Afgerond</span><strong>{queue.summary.reviewed}</strong></div>
<div><span>Resterend</span><strong>{queue.summary.remaining}</strong></div>
<div><span>Fout gevonden</span><strong>{queue.summary.false_positive_total}</strong></div>
<div><span>Gemist</span><strong>{queue.summary.false_negative_total}</strong></div>
</div>
) : null}
<div className="detection-review-filters">
<label>
Soort
<select value={roleFilter} onChange={(event) => {
setRoleFilter(event.target.value as typeof roleFilter)
setOffset(0)
}}>
<option value="all">Alles</option>
<option value="false_positive">Onterecht gevonden</option>
<option value="false_negative">Gemist gebouw</option>
</select>
</label>
<label>
Status
<select value={statusFilter} onChange={(event) => {
setStatusFilter(event.target.value as typeof statusFilter)
setOffset(0)
}}>
<option value="unreviewed">Nog te beoordelen</option>
<option value="reviewed">Beoordeeld</option>
<option value="all">Alles</option>
</select>
</label>
<button type="button" className="secondary-action" disabled={loading} onClick={() => void load()}>
{loading ? 'Laden...' : 'Vernieuwen'}
</button>
</div>
{error ? <p className="error" role="alert">{error}</p> : null}
{!loading && queue && queue.items.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen objecten in deze selectie</strong>
<p>Pas de filters aan of open de bewijslaag op de kaart.</p>
</div>
) : null}
<ol className="detection-review-list">
{queue?.items.map((item) => {
const key = reviewKey(item)
const decision = draftDecisions[key] ?? item.decision
return (
<li key={key} className="detection-review-item">
<div className="detection-review-item-heading">
<div>
<span>{ROLE_LABELS[item.evidence_role]}</span>
<strong>{item.class_name ?? 'gebouw'} / {shortId(item.evidence_feature_id)}</strong>
</div>
{typeof item.confidence === 'number' ? <span className="count-pill">{Math.round(item.confidence * 100)}% vertrouwen</span> : null}
</div>
<div className="detection-review-editor">
<label>
Beoordeling
<select
value={decision}
onChange={(event) => setDraftDecisions((current) => ({
...current,
[key]: event.target.value as DetectionReviewDecision,
}))}
>
{ROLE_DECISIONS[item.evidence_role].map((option) => (
<option key={option} value={option}>{DECISION_LABELS[option]}</option>
))}
</select>
</label>
<label>
Notitie
<input
type="text"
maxLength={2000}
placeholder="Waarom is dit correct, fout of onzeker?"
value={draftNotes[key] ?? ''}
onChange={(event) => setDraftNotes((current) => ({ ...current, [key]: event.target.value }))}
/>
</label>
<button type="button" className="primary-action" disabled={savingKey === key} onClick={() => void save(item)}>
{savingKey === key ? 'Bewaren...' : 'Beoordeling bewaren'}
</button>
</div>
</li>
)
})}
</ol>
{queue && queue.total > queue.limit ? (
<div className="detection-review-pagination" aria-label="Pagina's van de controlelijst">
<button
type="button"
className="secondary-action"
disabled={offset === 0 || loading}
onClick={() => setOffset((current) => Math.max(current - queue.limit, 0))}
>
Vorige
</button>
<span>{offset + 1}-{Math.min(offset + queue.limit, queue.total)} van {queue.total}</span>
<button
type="button"
className="secondary-action"
disabled={offset + queue.limit >= queue.total || loading}
onClick={() => setOffset((current) => current + queue.limit)}
>
Volgende
</button>
</div>
) : null}
</section>
)
}
function reviewKey(item: Pick<DetectionReviewRead, 'evidence_role' | 'evidence_feature_id'>): string {
return `${item.evidence_role}:${item.evidence_feature_id}`
}
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
import { DetectionReviewPanel } from './DetectionReviewPanel'
const CORE_METRIC_ORDER = [
'precision',
@@ -304,6 +305,13 @@ export function QualityResultsPanel({
</button>
</div>
</div>
{selectedQualityCheck.check_type === 'detections_vs_reference' && selectedProjectId ? (
<DetectionReviewPanel
projectId={selectedProjectId}
qualityCheckId={selectedQualityCheck.id}
onOpenEvidenceMap={onOpenEvidenceMap}
/>
) : null}
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
<div>
<span>Overeenkomende object-ID's</span>