GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
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
|
|
reviewLocked?: boolean
|
|
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
|
|
}
|
|
|
|
function formatScore(value: number | null | undefined): string {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
|
|
}
|
|
|
|
export function DetectionReviewPanel({
|
|
projectId,
|
|
qualityCheckId,
|
|
reviewLocked = false,
|
|
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>{reviewLocked ? 'Beoordelingen bekijken' : 'Fouten controleren'}</h3>
|
|
<p className="muted">
|
|
{reviewLocked
|
|
? 'De demo toont het bestaande kwaliteitsbewijs zonder beoordelingen te wijzigen.'
|
|
: '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}
|
|
|
|
{queue?.summary.reviewed_metrics ? (
|
|
<div className="detection-review-adjudicated" aria-label="Score na beoordeling">
|
|
<div className="summary-grid">
|
|
<div>
|
|
<span>Precisie zoals gemeten</span>
|
|
<strong>{formatScore(queue.summary.reviewed_metrics.raw.precision)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Precisie na uw beoordeling</span>
|
|
<strong>{formatScore(queue.summary.reviewed_metrics.adjudicated.precision)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Herkenningsgraad na beoordeling</span>
|
|
<strong>{formatScore(queue.summary.reviewed_metrics.adjudicated.recall)}</strong>
|
|
</div>
|
|
</div>
|
|
<p className="geo-data-notice">
|
|
{queue.summary.reviewed_metrics.review_complete
|
|
? `Alle bevindingen zijn beoordeeld. ${queue.summary.reviewed_metrics.false_positive_breakdown.exonerated} onterecht gevonden objecten en ${queue.summary.reviewed_metrics.false_negative_breakdown.exonerated} gemiste objecten bleken een hiaat in de referentielaag, niet een modelfout.`
|
|
: `Nog ${queue.summary.reviewed_metrics.false_positive_breakdown.unreviewed + queue.summary.reviewed_metrics.false_negative_breakdown.unreviewed} bevindingen onbeoordeeld. De precisie ligt tussen ${formatScore(queue.summary.reviewed_metrics.pessimistic.precision)} en ${formatScore(queue.summary.reviewed_metrics.optimistic.precision)}; de getoonde waarde rekent elke onbeoordeelde bevinding nog als modelfout.`}
|
|
</p>
|
|
</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}
|
|
disabled={reviewLocked}
|
|
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] ?? ''}
|
|
readOnly={reviewLocked}
|
|
onChange={(event) => setDraftNotes((current) => ({ ...current, [key]: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<button type="button" className="primary-action" disabled={reviewLocked || 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}`
|
|
}
|