diff --git a/src/central/enrichment/backends/navi.py b/src/central/enrichment/backends/navi.py
index be409ff..eaec11d 100644
--- a/src/central/enrichment/backends/navi.py
+++ b/src/central/enrichment/backends/navi.py
@@ -16,6 +16,7 @@ import logging
from typing import Any
import aiohttp
+from pydantic import BaseModel, ConfigDict, Field
from central.enrichment.geocoder import GEOCODER_FIELDS, all_null_bundle
@@ -30,9 +31,24 @@ _WARMUP_LAT = 43.6150
_WARMUP_LON = -116.2023
+class NaviBackendSettings(BaseModel):
+ """Settings for NaviBackend. Mirrors __init__ defaults exactly."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ base_url: str = Field(default=DEFAULT_BASE_URL, description="Navi /api/reverse base URL")
+ timeout_s: float = Field(default=10.0, description="Per-request timeout in seconds")
+ headers: dict[str, str] | None = Field(
+ default=None, description="Extra request headers (e.g. Authorization)"
+ )
+ warmup: bool = Field(default=True, description="Fire a warmup ping on construction")
+
+
class NaviBackend:
"""GeocoderBackend backed by the composed Navi /api/reverse endpoint."""
+ settings_schema = NaviBackendSettings
+
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
diff --git a/src/central/enrichment/backends/no_op.py b/src/central/enrichment/backends/no_op.py
index 77b0567..3b29f94 100644
--- a/src/central/enrichment/backends/no_op.py
+++ b/src/central/enrichment/backends/no_op.py
@@ -7,11 +7,23 @@ which satisfies the GeocoderBackend contract while resolving nothing.
from typing import Any
+from pydantic import BaseModel, ConfigDict
+
from central.enrichment.geocoder import all_null_bundle
+class NoOpBackendSettings(BaseModel):
+ """No-op backend takes no settings. extra='forbid' makes switching to
+ NoOpBackend while stale backend_settings (e.g. a base_url) remain a clean
+ ValidationError instead of a TypeError at construction."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
class NoOpBackend:
"""GeocoderBackend that resolves no fields."""
+ settings_schema = NoOpBackendSettings
+
async def reverse(self, lat: float, lon: float) -> dict[str, Any]:
return all_null_bundle()
diff --git a/src/central/enrichment/backends/nominatim.py b/src/central/enrichment/backends/nominatim.py
index 0b3a9cb..b2023b5 100644
--- a/src/central/enrichment/backends/nominatim.py
+++ b/src/central/enrichment/backends/nominatim.py
@@ -16,6 +16,7 @@ from typing import Any
from urllib.parse import urlencode
import aiohttp
+from pydantic import BaseModel, ConfigDict, Field
from central.enrichment.geocoder import all_null_bundle
@@ -25,6 +26,21 @@ DEFAULT_BASE_URL = "https://nominatim.openstreetmap.org"
DEFAULT_USER_AGENT = "central-enrichment/0.5 (https://github.com/zvx-echo6/central)"
+class NominatimBackendSettings(BaseModel):
+ """Settings for NominatimBackend. Mirrors __init__ defaults exactly."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ base_url: str = Field(default=DEFAULT_BASE_URL, description="Nominatim /reverse base URL")
+ user_agent: str = Field(
+ default=DEFAULT_USER_AGENT, description="User-Agent (public OSM requires one)"
+ )
+ rate_limit_per_sec: float = Field(
+ default=1.0, description="Outbound request cap; 0 disables (self-hosted)"
+ )
+ timeout_s: float = Field(default=10.0, description="Per-request timeout in seconds")
+
+
class NominatimBackend:
"""GeocoderBackend backed by an OSM Nominatim /reverse endpoint.
@@ -32,6 +48,8 @@ class NominatimBackend:
set it to 0 to disable for self-hosted instances.
"""
+ settings_schema = NominatimBackendSettings
+
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
diff --git a/src/central/enrichment/backends/photon.py b/src/central/enrichment/backends/photon.py
index 2e75814..7503aef 100644
--- a/src/central/enrichment/backends/photon.py
+++ b/src/central/enrichment/backends/photon.py
@@ -15,6 +15,7 @@ from typing import Any
from urllib.parse import urlencode
import aiohttp
+from pydantic import BaseModel, ConfigDict, Field
from central.enrichment.geocoder import all_null_bundle
@@ -23,9 +24,21 @@ logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://localhost:2322"
+class PhotonBackendSettings(BaseModel):
+ """Settings for PhotonBackend. Mirrors __init__ defaults exactly."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ base_url: str = Field(default=DEFAULT_BASE_URL, description="Photon /reverse base URL")
+ timeout_s: float = Field(default=10.0, description="Per-request timeout in seconds")
+ headers: dict[str, str] | None = Field(default=None, description="Extra request headers")
+
+
class PhotonBackend:
"""GeocoderBackend backed by a raw Photon /reverse endpoint."""
+ settings_schema = PhotonBackendSettings
+
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
diff --git a/src/central/enrichment/geocoder.py b/src/central/enrichment/geocoder.py
index ab75017..195a734 100644
--- a/src/central/enrichment/geocoder.py
+++ b/src/central/enrichment/geocoder.py
@@ -9,6 +9,8 @@ land in PR K.
import logging
from typing import Any, Protocol, runtime_checkable
+from pydantic import BaseModel
+
from central.enrichment.cache import EnrichmentCache
logger = logging.getLogger(__name__)
@@ -38,6 +40,12 @@ def all_null_bundle() -> dict[str, Any]:
class GeocoderBackend(Protocol):
"""The pluggable reverse-geocoding layer beneath GeocoderEnricher."""
+ # Pydantic model (extra='forbid') describing this backend's accepted
+ # settings. The supervisor validates config.enrichment.backend_settings
+ # against it before instantiating, turning a config/settings mismatch into
+ # a clean ValidationError instead of a constructor TypeError.
+ settings_schema: type[BaseModel]
+
async def reverse(self, lat: float, lon: float) -> dict[str, Any]:
"""Return canonical geocoder fields (see GEOCODER_FIELDS).
diff --git a/src/central/gui/form_descriptors.py b/src/central/gui/form_descriptors.py
index badfe60..adc76a4 100644
--- a/src/central/gui/form_descriptors.py
+++ b/src/central/gui/form_descriptors.py
@@ -66,6 +66,8 @@ def _type_to_widget_and_options(field_name: str, field_type: type) -> tuple[str,
return "text", None
if field_type is int:
return "number", None
+ if field_type is float:
+ return "number", None
if field_type is bool:
return "checkbox", None
if field_type is RegionConfig:
diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index 0481d10..e6bb580 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -1995,12 +1995,26 @@ async def streams_update(
# =============================================================================
-def _enrichment_fields(current: dict) -> list[FieldDescriptor]:
- """Field descriptors for the single-row EnrichmentConfig form (generic
- machinery — same describe_fields used by adapter pages)."""
+def _outer_enrichment_fields(current: dict) -> list[FieldDescriptor]:
+ """EnrichmentConfig form fields EXCEPT backend_settings — that one is
+ rendered as a per-backend