feat: wire GenericHttpAdapter into geocoder enrichment (v0.15.0 PR2.5) (#123)

Set enrichment_locations = [("latitude", "longitude")] and surface
extracted coords as top-level data keys so apply_enrichment can geocode
generic_http events; non-Point geometries and coordless items degrade
gracefully to region unknown.

Co-authored-by: Ubuntu <zvx@cortex.echo6.co>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-01 11:30:35 -06:00 committed by GitHub
commit dfcdda5ecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 172 additions and 6 deletions

View file

@ -191,7 +191,10 @@ class GenericHttpAdapter(SourceAdapter):
default_cadence_s = 300
data_class = "event"
wizard_order = None # not in the setup wizard; created via operator GUI
enrichment_locations = []
# Generic instances surface lat/lon into event.data so the supervisor's
# geocoder enrichment can reach them. Coordless instances degrade to
# region unknown, which is fine — apply_enrichment handles it gracefully.
enrichment_locations = [("latitude", "longitude")]
bypass_bbox_filter = False
operator_creatable = True # GUI allows operators to create multiple instances
@ -365,6 +368,8 @@ class GenericHttpAdapter(SourceAdapter):
# --- geo ---
geo_kwargs: dict[str, Any] = {}
lat_val: float | None = None
lon_val: float | None = None
# Try GeoJSON geometry first.
if s.geometry_path:
@ -372,14 +377,15 @@ class GenericHttpAdapter(SourceAdapter):
if isinstance(geom, dict):
geo_kwargs["geometry"] = geom
# Also set centroid when geometry is a simple Point.
# Non-Point geometries (LineString, Polygon) have no single
# representative point; lat_val/lon_val stay None → region unknown.
if geom.get("type") == "Point":
coords = geom.get("coordinates") or []
if len(coords) >= 2:
try:
geo_kwargs["centroid"] = (
float(coords[0]),
float(coords[1]),
)
lon_val = float(coords[0])
lat_val = float(coords[1])
geo_kwargs["centroid"] = (lon_val, lat_val)
except (TypeError, ValueError):
pass
@ -389,7 +395,9 @@ class GenericHttpAdapter(SourceAdapter):
raw_lon = _dig(item, s.lon_path)
if raw_lat is not None and raw_lon is not None:
try:
geo_kwargs["centroid"] = (float(raw_lon), float(raw_lat))
lat_val = float(raw_lat)
lon_val = float(raw_lon)
geo_kwargs["centroid"] = (lon_val, lat_val)
except (TypeError, ValueError):
pass
@ -402,6 +410,14 @@ class GenericHttpAdapter(SourceAdapter):
if title is not None:
data["title"] = title
# Surface coordinates into data so the geocoder enrichment can reach them.
# Coordless instances degrade to region unknown — no special-casing needed.
# Written BEFORE field_mappings so an explicit operator mapping to
# latitude/longitude (rare) takes precedence.
if lat_val is not None and lon_val is not None:
data["latitude"] = lat_val
data["longitude"] = lon_val
for fm in s.field_mappings:
data[fm.dest_key] = _dig(item, fm.source_path)

View file

@ -664,3 +664,153 @@ class TestInstanceName:
assert events[0].adapter == "my_source"
await adapter.shutdown()
# ---------------------------------------------------------------------------
# Geocoder enrichment wiring
# ---------------------------------------------------------------------------
class TestEnrichmentLocations:
def test_enrichment_locations_declared(self):
assert GenericHttpAdapter.enrichment_locations == [("latitude", "longitude")]
@pytest.mark.asyncio
async def test_geojson_point_writes_latlon_to_data(
self, temp_db_path, mock_config_store
):
"""GeoJSON Point item: data["latitude"]/["longitude"] == geometry coords,
and geo.centroid == (lon, lat)."""
config = make_config(
domain="wx",
extra_settings={
"id_path": "id",
"geometry_path": "geometry",
},
)
adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path)
await adapter.startup()
fixture = {
"features": [
{
"id": "enrich-point-1",
"geometry": {"type": "Point", "coordinates": [-116.2, 43.7]},
}
]
}
with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf:
mf.return_value = fixture
events = [e async for e in adapter.poll()]
assert len(events) == 1
e = events[0]
# lat = coords[1], lon = coords[0]
assert e.data["latitude"] == 43.7
assert e.data["longitude"] == -116.2
assert e.geo.centroid == (-116.2, 43.7)
await adapter.shutdown()
@pytest.mark.asyncio
async def test_lat_lon_path_writes_latlon_to_data(
self, temp_db_path, mock_config_store
):
"""lat_path/lon_path item: data["latitude"]/["longitude"] populated."""
config = make_config(
domain="fire",
extra_settings={
"items_path": "alerts",
"id_path": "uid",
"geometry_path": "",
"lat_path": "lat",
"lon_path": "lon",
},
)
adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path)
await adapter.startup()
fixture = {
"alerts": [
{"uid": "enrich-latlon-1", "lat": 43.5, "lon": -116.1},
]
}
with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf:
mf.return_value = fixture
events = [e async for e in adapter.poll()]
assert len(events) == 1
e = events[0]
assert e.data["latitude"] == 43.5
assert e.data["longitude"] == -116.1
await adapter.shutdown()
@pytest.mark.asyncio
async def test_non_point_geometry_no_latlon_in_data(
self, temp_db_path, mock_config_store
):
"""Non-Point geometry (LineString/Polygon) has no representative point;
latitude/longitude must NOT appear in data degrades to region unknown."""
config = make_config(
domain="wx",
extra_settings={
"id_path": "id",
"geometry_path": "geometry",
},
)
adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path)
await adapter.startup()
fixture = {
"features": [
{
"id": "enrich-linestring-1",
"geometry": {
"type": "LineString",
"coordinates": [[-116.0, 43.0], [-115.0, 44.0]],
},
}
]
}
with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf:
mf.return_value = fixture
events = [e async for e in adapter.poll()]
assert len(events) == 1
e = events[0]
assert "latitude" not in e.data
assert "longitude" not in e.data
await adapter.shutdown()
@pytest.mark.asyncio
async def test_coordless_item_no_latlon_in_data(
self, temp_db_path, mock_config_store
):
"""Item with no geometry and no lat/lon paths: latitude/longitude absent
from data will degrade to region unknown."""
config = make_config(
domain="wx",
extra_settings={
"id_path": "id",
"geometry_path": "geometry",
},
)
adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path)
await adapter.startup()
fixture = {
"features": [
{"id": "enrich-coordless-1", "geometry": None},
]
}
with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf:
mf.return_value = fixture
events = [e async for e in adapter.poll()]
assert len(events) == 1
e = events[0]
assert "latitude" not in e.data
assert "longitude" not in e.data
await adapter.shutdown()