feat: add source-grounded evolution and Ollama assistant
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 06:45:56 +02:00
parent 0baa9b069c
commit beacdf2560
37 changed files with 2246 additions and 58 deletions
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { useGeoAssistant } from '../../hooks/useGeoAssistant'
import type { VectorSelectionBBox } from '../../types'
interface GeoAssistantPanelProps {
selectedProjectId: string | null
selectedAreaId: string | null
selectedAreaName: string | null
selectionBbox: VectorSelectionBBox | null
}
const SUGGESTIONS = [
'Vat de belangrijkste gebiedsmetingen samen.',
'Hoe evolueerden bevolking en bosoppervlakte?',
'Welke gegevens ontbreken nog voor een volledige wateranalyse?',
'Welke officiële bronnen zijn voor dit gebied beschikbaar?',
]
export function GeoAssistantPanel({
selectedProjectId,
selectedAreaId,
selectedAreaName,
selectionBbox,
}: GeoAssistantPanelProps): JSX.Element {
const [question, setQuestion] = useState('')
const {
status,
models,
selectedModel,
messages,
loading,
loadingModels,
error,
loadModels,
ask,
clear,
setSelectedModel,
} = useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox })
const submitQuestion = async (value: string) => {
if (await ask(value)) setQuestion('')
}
const scopeLabel = selectedAreaId
? selectedAreaName ?? 'Volledig geselecteerd gebied'
: selectionBbox
? 'Getekende kaartselectie'
: selectedAreaName ?? 'Volledig werkgebied'
const ready = Boolean(selectedProjectId && status?.reachable && selectedModel)
return (
<section className="workspace-panel geo-assistant-panel" data-testid="geo-assistant-panel">
<div className="panel-title-row">
<div>
<span className="section-kicker">Lokale AI · Ollama</span>
<h2>Vraag GeoIntel</h2>
<p className="muted">Stel vragen over de gemeten kaartgegevens en officiële tijdreeksen van het actieve gebied.</p>
</div>
<span className={status?.reachable ? 'status-badge status-badge-ready' : 'status-badge'}>
{status?.reachable ? 'Lokaal verbonden' : loadingModels ? 'Verbinden…' : 'Niet bereikbaar'}
</span>
</div>
<div className="assistant-context-strip">
<div>
<span>Context</span>
<strong>{scopeLabel}</strong>
</div>
<label>
<span>Ollama-model</span>
<select value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)} disabled={loadingModels || models.length === 0}>
{models.length === 0 ? <option value="">Geen model beschikbaar</option> : null}
{models.map((model) => (
<option value={model.name} key={model.name}>
{model.name}{model.parameter_size ? ` · ${model.parameter_size}` : ''}
</option>
))}
</select>
</label>
<button type="button" className="secondary-action" onClick={() => void loadModels()} disabled={loadingModels}>
Verbinding vernieuwen
</button>
</div>
{!selectedProjectId ? (
<div className="result-state result-state-empty">
<strong>Geen werkgebied actief.</strong>
<p>Open eerst de regionale werkruimte of een project.</p>
</div>
) : null}
{status && !status.reachable ? (
<div className="result-state result-state-error">
<strong>Ollama is niet bereikbaar.</strong>
<p>{status.limitation_message}</p>
</div>
) : null}
<div className="assistant-suggestions" aria-label="Voorbeeldvragen">
{SUGGESTIONS.map((suggestion) => (
<button type="button" key={suggestion} onClick={() => void submitQuestion(suggestion)} disabled={!ready || loading}>
{suggestion}
</button>
))}
</div>
<div className="assistant-conversation" aria-live="polite">
{messages.length === 0 ? (
<div className="assistant-empty-state">
<strong>Begin bij een concrete gebiedsvraag</strong>
<p>GeoIntel stuurt alleen samengevatte, persistente GIS-metingen en broninformatie naar het lokale model.</p>
</div>
) : null}
{messages.map((message) => (
<article className={`assistant-message assistant-message-${message.role}`} key={message.id}>
<span>{message.role === 'user' ? 'Jij' : 'GeoIntel'}</span>
<p>{message.content}</p>
{message.response ? (
<details>
<summary>Gebruikte gegevens</summary>
<div className="assistant-evidence-grid">
<span>{message.response.context_metrics.length} metriek</span>
<span>{message.response.temporal_series.length} tijdreeksen</span>
<span>{message.response.source_dataset_ids.length} brondatasets</span>
<span>{message.response.scope_label}</span>
</div>
{message.response.warnings.map((warning) => <p className="geo-data-notice" key={warning}>{warning}</p>)}
</details>
) : null}
</article>
))}
{loading ? (
<div className="assistant-thinking" role="status">
<span />
<strong>Lokale gegevens worden samengevat en beantwoord…</strong>
</div>
) : null}
</div>
{error ? <p className="error">{error}</p> : null}
<form
className="assistant-composer"
onSubmit={(event) => {
event.preventDefault()
void submitQuestion(question)
}}
>
<label htmlFor="geo-assistant-question">Vraag over het actieve gebied</label>
<textarea
id="geo-assistant-question"
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="Bijvoorbeeld: hoeveel bos verdween er sinds 2013?"
maxLength={2000}
rows={3}
disabled={!ready || loading}
/>
<div>
<p>Antwoorden zijn lokaal gegenereerd. Controleer beslissingen altijd tegen de vermelde brondata.</p>
<button type="button" className="secondary-action" onClick={clear} disabled={messages.length === 0 || loading}>Wis gesprek</button>
<button type="submit" className="primary-action" disabled={!ready || loading || question.trim().length < 2}>Stel vraag</button>
</div>
</form>
</section>
)
}
@@ -0,0 +1,134 @@
import type { DatasetCreateResponse } from '../../types'
interface SourceCatalogPanelProps {
datasets: DatasetCreateResponse[]
}
const THEME_LABELS: Record<string, string> = {
buildings: 'Bebouwing',
population: 'Bevolking',
forest: 'Bos',
water: 'Water',
roads: 'Wegen en transport',
parcels: 'Percelen',
}
const AVAILABLE_SOURCES = [
{
name: 'Historische orthofoto’s',
owner: 'Digitaal Vlaanderen',
coverage: '1971 en 1979-1990; aanvullende jaargangen bestaan afzonderlijk',
value: 'Visuele evolutie en toekomstige beeldvergelijking',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/orthofotomozaiek-kleinschalig-zomeropnamen',
},
{
name: 'Biologische Waarderingskaart / Natura 2000',
owner: 'INBO',
coverage: 'Toestand 2025',
value: 'Natuurwaarde, biotopen en habitattypen',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/biologische-waarderingskaart-en-natura-2000-habitatkaart-toestand-2025',
},
{
name: 'Landbouwgebruikspercelen',
owner: 'Agentschap Landbouw en Zeevisserij',
coverage: 'Jaarlijkse bestanden',
value: 'Landbouwoppervlakte, teelten en perceelevolutie',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/open-geodata-landbouwgebruikspercelen',
},
{
name: 'Gebouwen- en adressenregister',
owner: 'Digitaal Vlaanderen',
coverage: 'Continu geactualiseerd',
value: 'Gebouwstatus, levensloop en adressen als aanvulling op GRB',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/gebouwen-en-adressenregister',
},
{
name: 'Digitaal Hoogtemodel Vlaanderen II',
owner: 'Digitaal Vlaanderen',
coverage: 'LiDAR-opname 2013-2015, DTM/DSM 1 m en 5 m',
value: 'Hoogte, reliëf, helling en afstroming; geen waterdiepte',
url: 'https://www.vlaanderen.be/digitaal-vlaanderen/onze-diensten-en-platformen/earth-observation-data-science-eodas/het-digitaal-hoogtemodel/digitaal-hoogtemodel-vlaanderen-ii',
},
{
name: 'Waterinfo en VMM-metingen',
owner: 'Vlaamse Milieumaatschappij',
coverage: 'Meetpunten en tijdreeksen voor waterstand, debiet en neerslag',
value: 'Hydrologische toestand; geen gebiedsdekkend watervolume zonder bodemprofiel',
url: 'https://waterinfo.vlaanderen.be/',
},
]
function datasetTheme(dataset: DatasetCreateResponse): string | null {
const configured = String(dataset.source_metadata?.['theme'] ?? dataset.reference_layer_name ?? '').toLowerCase()
if (configured === 'built' || configured === 'building') return 'buildings'
if (configured === 'transport' || configured === 'road') return 'roads'
if (configured in THEME_LABELS) return configured
return null
}
export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.Element {
const ready = datasets.filter((dataset) => dataset.status === 'ready')
const themes = Object.keys(THEME_LABELS).map((theme) => {
const matches = ready.filter((dataset) => datasetTheme(dataset) === theme)
const temporal = matches.filter((dataset) => dataset.temporal_series_key && dataset.observed_at)
const years = temporal.map((dataset) => new Date(dataset.observed_at as string).getUTCFullYear())
const latest = [...matches].sort(
(left, right) => new Date(right.observed_at ?? right.imported_at ?? 0).getTime() - new Date(left.observed_at ?? left.imported_at ?? 0).getTime(),
)[0]
return {
theme,
label: THEME_LABELS[theme],
datasetCount: matches.length,
temporalCount: temporal.length,
firstYear: years.length ? Math.min(...years) : null,
lastYear: years.length ? Math.max(...years) : null,
source: latest?.source_name ?? latest?.source ?? null,
}
})
return (
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
<div className="panel-title-row">
<div>
<span className="section-kicker">Broninventaris</span>
<h2>Wat is werkelijk beschikbaar?</h2>
<p className="muted">Ingeladen bronnen staan direct klaar voor de kaart. Andere officiële bronnen worden pas gebruikt na een gecontroleerde import.</p>
</div>
<span className="status-badge status-badge-ready">{ready.length} datasets klaar</span>
</div>
<div className="source-catalog-grid">
{themes.map((theme) => (
<article className="source-catalog-card" key={theme.theme}>
<div>
<strong>{theme.label}</strong>
<span>{theme.source ? theme.source.replaceAll('_', ' ') : 'Nog niet ingeladen'}</span>
</div>
<b>{theme.datasetCount > 0 ? 'Beschikbaar' : 'Ontbreekt'}</b>
<p>
{theme.temporalCount >= 2 && theme.firstYear && theme.lastYear
? `${theme.temporalCount} officiële meetmomenten · ${theme.firstYear}-${theme.lastYear}`
: 'Alleen de huidige toestand is vergelijkbaar beschikbaar.'}
</p>
</article>
))}
</div>
<details className="source-opportunity-list">
<summary>Officiële bronnen die hierna kunnen worden ingeladen</summary>
<div>
{AVAILABLE_SOURCES.map((source) => (
<article key={source.name}>
<div>
<strong>{source.name}</strong>
<span>{source.owner} · {source.coverage}</span>
</div>
<p>{source.value}</p>
<a href={source.url} target="_blank" rel="noreferrer">Bekijk officiële bron</a>
</article>
))}
</div>
</details>
</section>
)
}
+23 -2
View File
@@ -5,6 +5,7 @@ import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -860,7 +861,7 @@ export function MapWorkspace({
setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)]
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox))
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
}
await Promise.all(tasks)
}
@@ -869,7 +870,8 @@ export function MapWorkspace({
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
return
}
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox)
const areaId = selectedAreaBbox && bboxesEqual(mapSelectionBbox, selectedAreaBbox) ? selectedMapAreaId : undefined
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox, areaId)
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
@@ -1324,6 +1326,25 @@ export function MapWorkspace({
<strong>{temporalComparison.metric.is_estimate ? 'Ruimtelijke schatting' : 'Exact'}</strong>
</div>
</div>
<TemporalTrendChart
timeline={temporalComparison.timeline ?? []}
metricKey={temporalComparison.metric.metric_key}
/>
{(temporalComparison.metrics ?? []).filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key).length > 0 ? (
<div className="geo-supporting-metrics" aria-label="Aanvullende historische metingen">
{(temporalComparison.metrics ?? [])
.filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key)
.map((metric) => (
<div key={metric.metric_key}>
<span>{metric.label}</span>
<strong>
{metric.absolute_change >= 0 ? '+' : ''}
{formatTemporalMetric(metric.absolute_change, metric.unit)}
</strong>
</div>
))}
</div>
) : null}
{temporalComparison.object_changes.available ? (
<div className="geo-change-counts" aria-label="Objectwijzigingen">
<span><strong>{temporalComparison.object_changes.added_count ?? 0}</strong> nieuw</span>
@@ -0,0 +1,76 @@
import type { TemporalObservation } from '../../types'
interface TemporalTrendChartProps {
timeline: TemporalObservation[]
metricKey: string
}
const WIDTH = 560
const HEIGHT = 150
const PADDING_X = 34
const PADDING_Y = 24
function formatValue(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function TemporalTrendChart({ timeline, metricKey }: TemporalTrendChartProps): JSX.Element | null {
const observations = timeline.flatMap((observation) => {
const metric = observation.metrics.find((item) => item.metric_key === metricKey)
return metric ? [{ observation, metric }] : []
})
if (observations.length < 2) {
return null
}
const values = observations.map((item) => item.metric.value)
const minimum = Math.min(...values)
const maximum = Math.max(...values)
const range = maximum - minimum
const innerWidth = WIDTH - PADDING_X * 2
const innerHeight = HEIGHT - PADDING_Y * 2
const points = observations.map((item, index) => {
const x = PADDING_X + (index / (observations.length - 1)) * innerWidth
const normalized = range === 0 ? 0.5 : (item.metric.value - minimum) / range
const y = HEIGHT - PADDING_Y - normalized * innerHeight
return { ...item, x, y }
})
const pointString = points.map((point) => `${point.x},${point.y}`).join(' ')
const label = `${points[0].metric.label}: ${formatValue(points[0].metric.value, points[0].metric.unit)} tot ${formatValue(
points[points.length - 1].metric.value,
points[points.length - 1].metric.unit,
)}`
return (
<div className="geo-temporal-chart" aria-label={label}>
<div className="geo-temporal-chart-heading">
<div>
<span>Volledige tijdreeks</span>
<strong>{points[0].metric.label}</strong>
</div>
<span>{points.length} meetmomenten</span>
</div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label={label} preserveAspectRatio="none">
<line x1={PADDING_X} x2={WIDTH - PADDING_X} y1={HEIGHT - PADDING_Y} y2={HEIGHT - PADDING_Y} />
<polyline points={pointString} />
{points.map((point) => (
<g key={point.observation.dataset.id}>
<circle cx={point.x} cy={point.y} r="4" />
<text x={point.x} y={HEIGHT - 6} textAnchor="middle">
{new Date(point.observation.dataset.observed_at).getUTCFullYear()}
</text>
</g>
))}
</svg>
<div className="geo-temporal-chart-values">
{points.map((point) => (
<div key={point.observation.dataset.id}>
<span>{new Date(point.observation.dataset.observed_at).getUTCFullYear()}</span>
<strong>{formatValue(point.metric.value, point.metric.unit)}</strong>
</div>
))}
</div>
</div>
)
}