etter has ONE responsibility: Extract & Execute geographic filters from natural language queries.
- Layer 1: Parsing - Extract structured
GeoQueryfrom text ("north of Lausanne") - Layer 2: Resolution - Resolve "Lausanne" to a physical geometry using a datasource
- Layer 3: Spatial Operations - Transform that geometry using the spatial relation (e.g., generate a "north" sector)
- Subject/feature identification ("hiking", "restaurants")
- Attribute filtering ("with children", "vegetarian")
- Final search execution or result ranking (this is the parent app's job)
etter fits into a search pipeline:
flowchart LR
NL[/"text query"/]
LLMAGENT(["LLM model"])
OUT[/"geometry"/]
subgraph L1["etter.parser"]
direction TB
PARSER["GeoFilterParser"]
GQ[["GeoQuery"]]
PARSER --> GQ
end
subgraph L2["etter.datasource"]
direction TB
DS["GeoDataSource"]
GDS[("geo data")]
GEOM[["geometry"]]
DS --> GDS --> GEOM
end
subgraph L3["etter.spatial"]
SPATIAL["apply_spatial_relation()"]
end
NL --> PARSER
PARSER <-->|"prompt / response"| LLMAGENT
GQ -->|"location + type"| DS
GQ -->|"spatial relation + config"| SPATIAL
GEOM --> SPATIAL
SPATIAL --> OUT
Here's what happens when you do a query in the demo:
1. INPUT: "Hiking with children north of Lausanne"
↓
2. PARSER (Layer 1)
- Extracts: spatial_relation="north_of", reference_location="Lausanne" (None if no named location)
- Confidence: 0.95
- Buffer: inferred=True, distance placeholder (final distance set in Layer 3 from geometry area)
↓
3. DATASOURCE (Layer 2)
- Searches: name="Lausanne", type="settlement" (inferred)
- Finds: Point(6.63, 46.52) in WGS84
- Confidence: 1.0 (exact match)
↓
4. SPATIAL OPERATIONS (Layer 3)
- Geodesic area of Lausanne geometry → bracket → radius (e.g. 5 000 m for a city polygon)
- Centroid: (6.63, 46.52)
- Direction: North (0°)
- Creates: 90° sector polygon extending ~5km north
↓
5. OUTPUT: GeoJSON FeatureCollection
{
"type": "FeatureCollection",
"features": [
{ "id": "reference", "geometry": Point, ... }, // Lausanne point
{ "id": "search_area", "geometry": Polygon, ... } // North sector
]
}
Extracts intent from text using an LLM.
- Input: "near Bern"
- Output:
GeoQueryobject (Pydantic model) - Key Features:
- Multilingual support
- spatial relations: containment, buffer, directional, clipping
- Distance inference ("10 min walk" → 833m set as
explicit_distanceby LLM; geometry-area-based default applied in Layer 3 when no explicit distance) - Confidence scoring
Resolves location names to geometries.
- Interface:
GeoDataSourceProtocol (search(),get_by_id(),get_available_types(); returnslist[dict]- standard GeoJSON) - Type System: Each datasource declares its own list of available types via
get_available_types()- Types are organized in a semantic hierarchy (water, landforms, settlement, etc.)
- Supports fuzzy matching: query
type="water"matches lake, river, pond, spring, etc. - See
location_types.pyfor the standard type hierarchy
- Implementations:
SwissNames3DSource: Wraps swisstopo data (Shapefile/GDB). Handles:- Fuzzy/Exact search by name
- Type filtering with fuzzy matching (lake, city, canton, etc.)
- Coordinate conversion (CH1903+ → WGS84)
- ~80 grouped geographic types
SwissBoundaries3DSource: Wraps swisstopo swissBOUNDARIES3D data (Shapefile). Handles:- Administrative boundaries (cantons, municipalities, districts)
- Automatic concatenation of 3 boundary shapefiles from a directory
- Coordinate conversion (CH1903+ → WGS84)
- 3D → 2D geometry downgrading
IGNBDCartoSource: Wraps IGN BD-CARTO data (GeoPackage). Handles:- 14 thematic layers (administrative, hydrography, named places, protected areas)
- French article stripping for name normalization
- Coordinate conversion (Lambert-93 → WGS84)
PostGISDataSource: Generic DB-backed datasource for any PostGIS table. Handles:- Accepts a SQLAlchemy
Engineor a connection URL string (DB-agnostic) - ILIKE-based case-insensitive search with
pg_trgmfuzzy fallback - CRS reprojection via
ST_Transformwhen the stored CRS differs from WGS84 - Optional
type_mapfor normalizing raw DB type values to the etter hierarchy - No driver is bundled — the user provides it via the connection URL
(e.g.
postgresql+psycopg2://...)
- Accepts a SQLAlchemy
CompositeDataSource: Fan-out aggregator over multiple datasources
Transforms reference geometries into search areas.
-
Function:
apply_spatial_relation(geometry, relation, buffer_config, spatial_config=None, geometry_format="geojson") -
Operations:
- Containment: Passthrough (exact boundary)
- Buffer: Positive (expand), Negative (erode), Ring (donut)
- Directional: Angular sector wedges (e.g., North = 90° wedge)
- Clipping: Bbox half-plane intersection (e.g., northern half of a country)
-
Area-based distance inference: When
buffer_config.inferred=True(no explicit distance in the query),apply_spatial_relationcomputes the geodesic area of the reference geometry (viapyproj.Geod) and selects a distance from area-based brackets:Geometry area Proximity default Erosion default < 1 km² (point, station) 500 m −200 m 1–50 km² (town, small lake) 1 500 m −500 m 50–500 km² (city, medium region) 5 000 m −1 000 m ≥ 500 km² (canton, country) 15 000 m −2 000 m If
explicit_distanceis set (user stated "within 5km", "30 min walk", etc.), it always takes precedence. -
Technology: Uses
shapely+pyprojinternally, input is WGS84 GeoJSON; output format is configurable ("geojson"dict,"wkt"string, or"wkb"hex string).
GeoQuery(
spatial_relation=SpatialRelation(relation="north_of", ...),
reference_location=ReferenceLocation(name="Lausanne", ...), # None for attribute-only queries
buffer_config=BufferConfig(distance_m=10000, ...),
confidence_breakdown=...
)Standard GeoJSON dictionary structure:
{
"type": "Feature",
"id": "uuid-123",
"geometry": { "type": "Point", "coordinates": [6.63, 46.52] },
"properties": {
"name": "Lausanne",
"type": "city",
"confidence": 1.0
}
}Category (category=) |
Relations | Behavior |
|---|---|---|
containment |
in |
Exact geometry match |
buffer |
near, around, along |
Circular/Linear buffer; distance inferred from geometry area when not explicit |
buffer (one-sided) |
left_bank, right_bank |
Buffer on one side of a linear feature relative to flow direction |
buffer (ring) |
on_shores_of, bordering |
Buffer - Original (Donut); distance inferred from geometry area when not explicit |
buffer (erosion) |
in_the_heart_of |
Negative buffer (shrink); erosion depth inferred from geometry area when not explicit |
directional |
north_of, south_of, east_of, west_of, northeast_of, southeast_of, southwest_of, northwest_of |
90° Sector Wedge |
clipping |
northern_part_of, southern_part_of, eastern_part_of, western_part_of |
Bbox half-plane intersection (sub-area of reference) |
etter supports four query complexity levels through the query_type field in GeoQuery:
| Type | Status | Purpose | Example |
|---|---|---|---|
simple |
✅ Implemented | Single spatial relation + reference location | "north of Lausanne" |
compound |
📋 Planned | Multi-step or hierarchical spatial queries | "north of Lausanne AND within 10km of a lake" |
split |
📋 Planned | Queries that divide an area into regions | "areas of Switzerland between Lausanne and Geneva" |
boolean |
📋 Planned | AND/OR/NOT logical operations on spatial relations | "within 5km of Geneva AND north of Bern" |
Only simple queries are currently supported. A simple query has:
- One spatial relation (e.g., "north", "in", "near")
- One reference location (e.g., "Lausanne", a city, a canton) — queries without a named location raise
NoReferenceLocationError - Optional: Buffer distance configuration
Example flow:
Input: "restaurants in Geneva"
↓
GeoQuery(
query_type="simple",
spatial_relation=SpatialRelation(relation="in", ...),
reference_location=ReferenceLocation(name="Geneva", ...),
...
)
Compound queries would combine multiple spatial relations:
- "North of Lausanne AND within 10km of the lake"
- Requires: Multi-relation parsing, geometry intersection
- Datasource: Hierarchical location resolution
Split queries would divide areas by spatial relations:
- "Regions of Switzerland north of Bern"
- Requires: Area partitioning logic, polygon subdivision
Boolean queries would use explicit logical operators:
- "Within Geneva OR Bern, but not on lake shores"
- Requires: Union/Intersection/Difference operations on geometries
To support compound queries, three layers would need enhancement:
- Parser (Layer 1): Detect and structure multiple spatial relations
- Datasource (Layer 2): Support hierarchical/nested location resolution
- Spatial Operations (Layer 3): Combine geometries (intersection, union, difference)
The current single-relation architecture is intentionally simple to support Phase 1 requirements. The query_type field provides forward compatibility for future expansion.
etter uses a datasource-defined type system with semantic grouping and fuzzy matching.
Types are organized into 12 semantic categories to support fuzzy matching:
| Category | Examples |
|---|---|
| water | lake, river, pond, spring, waterfall, glacier, ditch, weir, dam |
| landforms | mountain, peak, hill, pass, valley, ridge, plain, rock_head, boulder, massif |
| mountain | mountain, peak |
| natural | cave, forest, nature_reserve, alpine_pasture |
| island | island, peninsula |
| administrative | country, canton, municipality, region, department, area, border_marker, arrondissement |
| settlement | city, town, village, hamlet, district |
| building | building, religious_building, tower, monument, fountain |
| transport | train_station, bus_stop, boat_stop, road, bridge, tunnel, exit, entrance_exit, junction, railway, railway_area, lift, loading_station, airport, heliport, ferry |
| amenity | restaurant, hospital, school, parking, park, swimming_pool, sports_facility, leisure_facility, zoo, camping, rest_area, standing_area, cemetery, fairground |
| infrastructure | power_plant, wastewater_treatment, waste_incineration, landfill, quarry |
| other | field_name, local_name, viewpoint, private_driving_area, correctional_facility, military_training_area, customs, historical_site, monastery, unknown |
-
Datasource Declaration: Each datasource declares available types via
get_available_types()source = SwissNames3DSource("data/") available_types = source.get_available_types() # → ["lake", "river", "city", "mountain", "peak", ...]
-
Fuzzy Matching: Type hints can be either concrete or categorical
results = source.search("Geneva", type="water") # Matches: lake, river, pond, etc. results = source.search("Geneva", type="lake") # Matches: only "lake" results = source.search("Geneva", type="settlement") # Matches: city, town, village, etc.
-
LLM Integration: The LLM is aware of the type hierarchy and uses it for better type inference
- Can suggest types from the hierarchy when parsing queries
- Understands categorical types for more flexible matching
When adding a new datasource (e.g., OpenStreetMap), implement the protocol:
class MyDataSource:
def get_available_types(self) -> list[str]:
"""Return concrete types this datasource can return."""
return ["lake", "river", "city", "restaurant", "hospital"]
def search(self, name: str, type: str | None = None, max_results: int = 10):
# Map native types to standard hierarchy
# Use location_types.get_matching_types(type) for fuzzy matching
passSee location_types.py for the complete type hierarchy and utilities.
etter/
├── parser.py # Layer 1: LLM Parser
├── models.py # Pydantic models for Layer 1
├── spatial_config.py # Spatial relation definitions
├── prompts.py # LLM prompts
├── validators.py # Validation pipeline
├── examples.py # Few-shot examples for the parser
├── exceptions.py # Exception hierarchy
├── geometry_format.py # Geometry format conversion (geojson/wkt/wkb)
├── datasources/ # Layer 2: Data Resolution
│ ├── protocol.py # GeoDataSource Protocol
│ ├── location_types.py # Type hierarchy & fuzzy matching
│ ├── swissnames3d.py # SwissNames3D Implementation (Shapefile)
│ ├── swissboundaries3d.py # SwissBoundaries3D Implementation (Shapefile)
│ ├── ign_bdcarto.py # IGN BD-CARTO Implementation (GeoPackage)
│ ├── postgis.py # PostGISDataSource (generic DB-backed)
│ └── composite.py # Fan-out aggregator
├── spatial.py # Layer 3: Geometry Transformation
└── __init__.py # Public exports
demo/
├── main.py # FastAPI demo server (file-based or PostGIS mode)
├── Dockerfile # Image for the FastAPI service
├── docker-compose.yml # PostGIS demo stack (see below)
├── etter-mcp-app/ # MCP client demo application
└── static/ # OpenLayers map UI
scripts/
├── extract_bdcarto.sh # Extract IGN 7z archive
└── load_data_postgis.py # Load shapefiles/gpkg into PostGIS
The demo/docker-compose.yml provides a fully containerised version of the
demo using PostGIS as the geodata backend.
| Service | Image / Build | Purpose |
|---|---|---|
postgis |
postgis/postgis:18-3.6 |
PostgreSQL 18 + PostGIS; stores data in /var/lib/postgresql |
data-loader |
demo/Dockerfile |
One-shot container; runs scripts/load_data_postgis.py then exits |
api |
demo/Dockerfile |
FastAPI server; starts after data-loader completes successfully |
Both datasets are loaded into PostGIS using the same unified schema:
CREATE TABLE public.swissnames3d (
id TEXT,
name TEXT NOT NULL,
type TEXT,
geom GEOMETRY(Geometry, 4326)
);
CREATE TABLE public.ign_bdcarto (
id TEXT,
name TEXT NOT NULL,
type TEXT,
geom GEOMETRY(Geometry, 4326)
);All geometries are stored in WGS84 (EPSG:4326). The loader reprojects from the native CRS (EPSG:2056 for SwissNames3D, EPSG:2154 for IGN BD-CARTO) at load time.
# 1. Download geodata (if not already present)
make download-data # SwissNames3D shapefiles → data/
make download-data-ign # IGN BD-CARTO gpkg files → data/bdcarto/
# 2. Set your API key and model
export LLM_API_KEY=sk-...
export LLM_MODEL=gpt-4o
# 3. Start the full stack
docker compose -f demo/docker-compose.yml up
# The API is available at http://localhost:8000| Variable | Default | Description |
|---|---|---|
ETTER_DB_URL |
— | SQLAlchemy connection URL; enables PostGIS mode in demo/main.py |
POSTGRES_USER |
etter |
PostgreSQL user |
POSTGRES_PASSWORD |
etter |
PostgreSQL password |
POSTGRES_DB |
geodata |
PostgreSQL database name |
SWISSNAMES3D_TABLE |
swissnames3d |
Target table for SwissNames3D |
IGN_BDCARTO_TABLE |
ign_bdcarto |
Target table for IGN BD-CARTO |
DB_SCHEMA |
public |
PostgreSQL schema |
demo/main.py auto-detects which mode to use:
- PostGIS mode — when
ETTER_DB_URLis set (used by docker-compose) - File mode — when
ETTER_DB_URLis absent (original behaviour; usesSWISSNAMES3D_PATH/IGN_BDCARTO_PATH)
To use PostGISDataSource outside the demo (e.g., in your own application):
pip install etter[postgis] # installs sqlalchemy + geoalchemy2
pip install psycopg2-binary # or your preferred driverfrom etter.datasources import PostGISDataSource
source = PostGISDataSource(
connection="postgresql+psycopg2://user:pass@localhost/mydb",
table="public.my_geodata",
)
results = source.search("Lausanne", type="city")- LLM: Provider, Model, Temperature
- Spatial: Default distances, buffer offsets
- Datasource (file mode): Path to SwissNames3D shapefiles / IGN BD-CARTO GeoPackages
- Datasource (PostGIS mode): SQLAlchemy connection URL + table names
All three layers are fully implemented and integrated:
- ✅ Layer 1 (Parser): Complete — Extracts spatial relations from natural language using LLM
- ✅ Layer 2 (Datasource): Complete — Multiple implementations:
SwissNames3DSource— swisstopo shapefiles → WGS84 GeoJSONIGNBDCartoSource— IGN BD-CARTO GeoPackages → WGS84 GeoJSONPostGISDataSource— generic PostGIS table → WGS84 GeoJSON (DB-agnostic)CompositeDataSource— fan-out aggregator
- ✅ Layer 3 (Spatial Operations): Complete — Transforms geometries using spatial relations (buffers, directional sectors, etc.)
- ✅ Integration: Full end-to-end workflow with demo API server (file mode and PostGIS mode)
The demo API at demo/main.py demonstrates the complete pipeline:
Query → Parser → Datasource → Spatial Ops → GeoJSON Result
- Complex query types:
compound(multi-step),split(area division),boolean(AND/OR/NOT) - Some edge cases in spatial operations