feat: add source-grounded evolution and Ollama assistant
This commit is contained in:
+17
-1
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import './styles/app.css'
|
||||
import './styles/premium.css'
|
||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||
import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel'
|
||||
import { DatasetPanel } from './components/datasets/DatasetPanel'
|
||||
import { SourceCatalogPanel } from './components/datasets/SourceCatalogPanel'
|
||||
import { DetectionLab } from './components/detection/DetectionLab'
|
||||
import { ExportCenter } from './components/exports/ExportCenter'
|
||||
import { ExportPreview } from './components/exports/ExportPreview'
|
||||
@@ -38,12 +40,13 @@ function isVectorDatasetType(datasetType: string): boolean {
|
||||
return datasetType === 'vector' || datasetType === 'geojson'
|
||||
}
|
||||
|
||||
type WorkspaceKey = 'overview' | 'data' | 'map' | 'analysis' | 'ai' | 'exports' | 'system'
|
||||
type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
|
||||
|
||||
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
|
||||
{ key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' },
|
||||
{ key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' },
|
||||
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
|
||||
{ key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' },
|
||||
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
|
||||
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
|
||||
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
|
||||
@@ -52,6 +55,7 @@ const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description:
|
||||
|
||||
const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
|
||||
{ label: 'Verkennen', keys: ['map', 'data'] },
|
||||
{ label: 'Vragen', keys: ['assistant'] },
|
||||
{ label: 'Analyseren', keys: ['analysis', 'ai'] },
|
||||
{ label: 'Afronden', keys: ['exports'] },
|
||||
{ label: 'Beheer', keys: ['overview', 'system'] },
|
||||
@@ -918,6 +922,7 @@ function App(): JSX.Element {
|
||||
|
||||
{activeWorkspace === 'data' ? (
|
||||
<div className="workspace-grid workspace-grid-data">
|
||||
<SourceCatalogPanel datasets={datasets} />
|
||||
<ProjectPanel
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
@@ -1075,6 +1080,17 @@ function App(): JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeWorkspace === 'assistant' ? (
|
||||
<div className="workspace-grid workspace-grid-assistant">
|
||||
<GeoAssistantPanel
|
||||
selectedProjectId={selectedProjectId}
|
||||
selectedAreaId={mapSelectionResult?.selection_area_id ?? (!mapSelectionBbox ? selectedArea?.id ?? null : null)}
|
||||
selectedAreaName={selectedArea?.name ?? null}
|
||||
selectionBbox={mapSelectionBbox}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeWorkspace === 'ai' ? (
|
||||
<div className="workspace-grid workspace-grid-ai">
|
||||
<DetectionLab
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { assistantApi } from '../services/api/assistant'
|
||||
import type {
|
||||
AssistantChatMessage,
|
||||
AssistantModelRead,
|
||||
AssistantQueryResponse,
|
||||
AssistantStatus,
|
||||
VectorSelectionBBox,
|
||||
} from '../types'
|
||||
|
||||
export interface GeoAssistantMessage extends AssistantChatMessage {
|
||||
id: string
|
||||
response?: AssistantQueryResponse
|
||||
}
|
||||
|
||||
interface UseGeoAssistantOptions {
|
||||
selectedProjectId: string | null
|
||||
selectedAreaId: string | null
|
||||
selectionBbox: VectorSelectionBBox | null
|
||||
}
|
||||
|
||||
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
|
||||
const [status, setStatus] = useState<AssistantStatus | null>(null)
|
||||
const [models, setModels] = useState<AssistantModelRead[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [messages, setMessages] = useState<GeoAssistantMessage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadModels = async () => {
|
||||
setLoadingModels(true)
|
||||
setError(null)
|
||||
try {
|
||||
const currentStatus = await assistantApi.status()
|
||||
setStatus(currentStatus)
|
||||
if (!currentStatus.enabled || !currentStatus.reachable) {
|
||||
setModels([])
|
||||
setSelectedModel('')
|
||||
return
|
||||
}
|
||||
const result = await assistantApi.models()
|
||||
setModels(result.items)
|
||||
setSelectedModel((current) => {
|
||||
if (current && result.items.some((model) => model.name === current)) return current
|
||||
if (result.default_model && result.items.some((model) => model.name === result.default_model)) return result.default_model
|
||||
return result.items[0]?.name ?? ''
|
||||
})
|
||||
} catch (requestError) {
|
||||
setStatus(null)
|
||||
setModels([])
|
||||
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}, [selectedProjectId])
|
||||
|
||||
const ask = async (question: string): Promise<boolean> => {
|
||||
const trimmed = question.trim()
|
||||
if (!selectedProjectId || !trimmed || !selectedModel) return false
|
||||
const userMessage: GeoAssistantMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed }
|
||||
setMessages((current) => [...current, userMessage])
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
|
||||
const result = await assistantApi.query(selectedProjectId, {
|
||||
question: trimmed,
|
||||
model: selectedModel,
|
||||
bbox: selectionBbox,
|
||||
area_id: selectedAreaId,
|
||||
history,
|
||||
})
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: result.answer, response: result },
|
||||
])
|
||||
return true
|
||||
} catch (requestError) {
|
||||
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'))
|
||||
return false
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
models,
|
||||
selectedModel,
|
||||
messages,
|
||||
loading,
|
||||
loadingModels,
|
||||
error,
|
||||
loadModels,
|
||||
ask,
|
||||
clear,
|
||||
setSelectedModel,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function useTemporalComparison(selectedProjectId: string | null) {
|
||||
earlierDatasetId: string,
|
||||
laterDatasetId: string,
|
||||
bbox: VectorSelectionBBox,
|
||||
areaId?: string,
|
||||
): Promise<TemporalComparisonResponse | null> => {
|
||||
if (!selectedProjectId) {
|
||||
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
|
||||
@@ -39,6 +40,7 @@ export function useTemporalComparison(selectedProjectId: string | null) {
|
||||
earlier_dataset_id: earlierDatasetId,
|
||||
later_dataset_id: laterDatasetId,
|
||||
bbox,
|
||||
area_id: areaId || null,
|
||||
preview_limit: 500,
|
||||
})
|
||||
setTemporalComparison(result)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiGet, apiPost } from './client'
|
||||
import type { AssistantModelsResponse, AssistantQueryRequest, AssistantQueryResponse, AssistantStatus } from '../../types'
|
||||
|
||||
export const assistantApi = {
|
||||
status: (): Promise<AssistantStatus> => apiGet<AssistantStatus>('/api/v1/assistant/status'),
|
||||
models: (): Promise<AssistantModelsResponse> => apiGet<AssistantModelsResponse>('/api/v1/assistant/models'),
|
||||
query: (projectId: string, payload: AssistantQueryRequest): Promise<AssistantQueryResponse> =>
|
||||
apiPost<AssistantQueryResponse>(`/api/v1/projects/${projectId}/assistant/query`, payload),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { areasApi } from './areas'
|
||||
export { analysisApi } from './analysis'
|
||||
export { assistantApi } from './assistant'
|
||||
export { datasetsApi } from './datasets'
|
||||
export { demoApi } from './demo'
|
||||
export { detectionApi } from './detection'
|
||||
|
||||
@@ -6212,6 +6212,81 @@ section {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-temporal-chart {
|
||||
margin-top: 0.7rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid #e1e8e5;
|
||||
border-radius: 5px;
|
||||
background: #fbfcfc;
|
||||
}
|
||||
|
||||
.geo-temporal-chart-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.geo-temporal-chart-heading > div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.geo-temporal-chart-heading span,
|
||||
.geo-temporal-chart-values span {
|
||||
color: #6a7773;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.geo-temporal-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
margin-top: 0.4rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.geo-temporal-chart svg line {
|
||||
stroke: #cbd6d2;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.geo-temporal-chart svg polyline {
|
||||
fill: none;
|
||||
stroke: #2676a8;
|
||||
stroke-width: 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.geo-temporal-chart svg circle {
|
||||
fill: #ffffff;
|
||||
stroke: #2676a8;
|
||||
stroke-width: 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.geo-temporal-chart svg text {
|
||||
fill: #6a7773;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.geo-temporal-chart-values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(108px, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.geo-temporal-chart-values > div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid #e1e8e5;
|
||||
}
|
||||
|
||||
.geo-temporal-chart-values strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.geo-change-counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -6553,3 +6628,327 @@ section {
|
||||
min-height: 24rem;
|
||||
}
|
||||
}
|
||||
|
||||
.source-catalog-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.source-catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.source-catalog-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.45rem 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #dfe7e4;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfc;
|
||||
}
|
||||
|
||||
.source-catalog-card > div {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-catalog-card span,
|
||||
.source-catalog-card p,
|
||||
.source-opportunity-list span,
|
||||
.source-opportunity-list p {
|
||||
margin: 0;
|
||||
color: #66746f;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.source-catalog-card b {
|
||||
color: #286646;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.source-catalog-card p {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.source-opportunity-list {
|
||||
margin-top: 0.8rem;
|
||||
border-top: 1px solid #dfe7e4;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.source-opportunity-list > summary {
|
||||
cursor: pointer;
|
||||
color: #34433e;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.source-opportunity-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.source-opportunity-list article {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8e6;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.source-opportunity-list article > div {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.source-opportunity-list a {
|
||||
width: fit-content;
|
||||
color: #17638a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.source-catalog-grid,
|
||||
.source-opportunity-list > div {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.source-catalog-grid,
|
||||
.source-opportunity-list > div {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-grid-assistant {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.geo-assistant-panel {
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.assistant-context-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.25fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-top: 1rem;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid #dfe7e4;
|
||||
border-radius: 6px;
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
.assistant-context-strip > div,
|
||||
.assistant-context-strip label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assistant-context-strip span,
|
||||
.assistant-composer label,
|
||||
.assistant-message > span {
|
||||
color: #66746f;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.assistant-context-strip select {
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid #cfdad6;
|
||||
border-radius: 5px;
|
||||
padding: 0 0.65rem;
|
||||
background: #ffffff;
|
||||
color: #26332f;
|
||||
}
|
||||
|
||||
.assistant-suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.assistant-suggestions button {
|
||||
min-height: 3.5rem;
|
||||
border: 1px solid #d8e3df;
|
||||
border-radius: 5px;
|
||||
padding: 0.65rem;
|
||||
background: #ffffff;
|
||||
color: #34433e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assistant-suggestions button:hover:not(:disabled) {
|
||||
border-color: #7fa99a;
|
||||
background: #f4f9f7;
|
||||
}
|
||||
|
||||
.assistant-conversation {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-height: 22rem;
|
||||
max-height: 52vh;
|
||||
margin-top: 0.8rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dfe7e4;
|
||||
border-radius: 6px;
|
||||
padding: 0.9rem;
|
||||
background: #f7f9f8;
|
||||
}
|
||||
|
||||
.assistant-empty-state {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
max-width: 34rem;
|
||||
color: #66746f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assistant-empty-state p {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
width: min(82%, 48rem);
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid #dfe7e4;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-message-user {
|
||||
justify-self: end;
|
||||
border-color: #c9ddd5;
|
||||
background: #eef6f3;
|
||||
}
|
||||
|
||||
.assistant-message p {
|
||||
margin: 0;
|
||||
color: #26332f;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-message details {
|
||||
margin-top: 0.3rem;
|
||||
border-top: 1px solid #e3e9e7;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
.assistant-message summary {
|
||||
cursor: pointer;
|
||||
color: #56655f;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.assistant-evidence-grid span {
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: #f1f5f3;
|
||||
color: #596761;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.assistant-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: #53645e;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.assistant-thinking > span {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border: 2px solid #b9cbc4;
|
||||
border-top-color: #347950;
|
||||
border-radius: 50%;
|
||||
animation: assistant-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes assistant-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.assistant-composer {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.assistant-composer textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid #cfdad6;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
color: #26332f;
|
||||
font: inherit;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.assistant-composer > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.assistant-composer > div p {
|
||||
margin: 0 auto 0 0;
|
||||
color: #66746f;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.assistant-context-strip,
|
||||
.assistant-suggestions {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.assistant-context-strip > button {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.assistant-context-strip,
|
||||
.assistant-suggestions,
|
||||
.assistant-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-composer > div {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +394,7 @@ export interface TemporalComparisonRequest {
|
||||
earlier_dataset_id: string
|
||||
later_dataset_id: string
|
||||
bbox: VectorSelectionBBox
|
||||
area_id?: string | null
|
||||
preview_limit?: number
|
||||
}
|
||||
|
||||
@@ -405,6 +406,7 @@ export interface TemporalDatasetRef {
|
||||
}
|
||||
|
||||
export interface TemporalMetricComparison {
|
||||
metric_key: string
|
||||
label: string
|
||||
unit: string
|
||||
aggregation_method: string
|
||||
@@ -413,6 +415,21 @@ export interface TemporalMetricComparison {
|
||||
absolute_change: number
|
||||
percent_change?: number | null
|
||||
is_estimate: boolean
|
||||
warning?: string | null
|
||||
}
|
||||
|
||||
export interface TemporalObservationMetric {
|
||||
metric_key: string
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
aggregation_method: string
|
||||
is_estimate: boolean
|
||||
}
|
||||
|
||||
export interface TemporalObservation {
|
||||
dataset: TemporalDatasetRef
|
||||
metrics: TemporalObservationMetric[]
|
||||
}
|
||||
|
||||
export interface TemporalObjectChanges {
|
||||
@@ -428,7 +445,10 @@ export interface TemporalComparisonResponse {
|
||||
earlier: TemporalDatasetRef
|
||||
later: TemporalDatasetRef
|
||||
selection_bbox: VectorSelectionBBox
|
||||
selection_area_id?: string | null
|
||||
metric: TemporalMetricComparison
|
||||
metrics: TemporalMetricComparison[]
|
||||
timeline: TemporalObservation[]
|
||||
object_changes: TemporalObjectChanges
|
||||
geojson: GeoJSON.FeatureCollection
|
||||
warnings: string[]
|
||||
@@ -521,6 +541,74 @@ export interface SystemCapabilitiesResponse {
|
||||
providers: ProviderCapability[]
|
||||
}
|
||||
|
||||
export interface AssistantModelRead {
|
||||
name: string
|
||||
size_bytes?: number | null
|
||||
parameter_size?: string | null
|
||||
quantization_level?: string | null
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
export interface AssistantModelsResponse {
|
||||
items: AssistantModelRead[]
|
||||
total: number
|
||||
default_model?: string | null
|
||||
}
|
||||
|
||||
export interface AssistantStatus {
|
||||
enabled: boolean
|
||||
reachable: boolean
|
||||
status: string
|
||||
base_url: string
|
||||
default_model?: string | null
|
||||
model_count: number
|
||||
limitation_message: string
|
||||
}
|
||||
|
||||
export interface AssistantChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface AssistantQueryRequest {
|
||||
question: string
|
||||
model?: string | null
|
||||
bbox?: VectorSelectionBBox | null
|
||||
area_id?: string | null
|
||||
history?: AssistantChatMessage[]
|
||||
}
|
||||
|
||||
export interface AssistantContextMetric {
|
||||
theme: string
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
source: string
|
||||
dataset_id: string
|
||||
observed_at?: string | null
|
||||
is_estimate: boolean
|
||||
}
|
||||
|
||||
export interface AssistantTemporalSeries {
|
||||
temporal_series_key: string
|
||||
label: string
|
||||
source: string
|
||||
first_year: number
|
||||
last_year: number
|
||||
observation_count: number
|
||||
}
|
||||
|
||||
export interface AssistantQueryResponse {
|
||||
answer: string
|
||||
model: string
|
||||
scope_label: string
|
||||
context_metrics: AssistantContextMetric[]
|
||||
temporal_series: AssistantTemporalSeries[]
|
||||
source_dataset_ids: string[]
|
||||
warnings: string[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface ProviderCapabilitiesResponse {
|
||||
providers: ProviderCapability[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user