Skip to content

Latest commit

 

History

History
494 lines (382 loc) · 18.5 KB

File metadata and controls

494 lines (382 loc) · 18.5 KB

etter Architecture

Core Principle

etter has ONE responsibility: Extract & Execute geographic filters from natural language queries.

What etter Does ✅

  • Layer 1: Parsing - Extract structured GeoQuery from 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)

What etter Does NOT Do ❌

  • Subject/feature identification ("hiking", "restaurants")
  • Attribute filtering ("with children", "vegetarian")
  • Final search execution or result ranking (this is the parent app's job)

Integration Pattern

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
Loading

Complete Example Workflow

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
     ]
   }

1. GeoFilterParser (Layer 1)

Extracts intent from text using an LLM.

  • Input: "near Bern"
  • Output: GeoQuery object (Pydantic model)
  • Key Features:
    • Multilingual support
    • spatial relations: containment, buffer, directional, clipping
    • Distance inference ("10 min walk" → 833m set as explicit_distance by LLM; geometry-area-based default applied in Layer 3 when no explicit distance)
    • Confidence scoring

2. GeoDataSource (Layer 2)

Resolves location names to geometries.

  • Interface: GeoDataSource Protocol (search(), get_by_id(), get_available_types(); returns list[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.py for 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 Engine or a connection URL string (DB-agnostic)
      • ILIKE-based case-insensitive search with pg_trgm fuzzy fallback
      • CRS reprojection via ST_Transform when the stored CRS differs from WGS84
      • Optional type_map for 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://...)
    • CompositeDataSource: Fan-out aggregator over multiple datasources

3. Spatial Operations (Layer 3)

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_relation computes the geodesic area of the reference geometry (via pyproj.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_distance is set (user stated "within 5km", "30 min walk", etc.), it always takes precedence.

  • Technology: Uses shapely + pyproj internally, input is WGS84 GeoJSON; output format is configurable ("geojson" dict, "wkt" string, or "wkb" hex string).


Data Models

GeoQuery (Parse Result)

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=...
)

GeoJSON Feature (Resolution Result)

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
  }
}

Spatial Relations

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)

Query Types

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"

Current Implementation (Phase 1)

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", ...),
    ...
)

Future Query Types (Phase 2+)

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

Architecture Impact

To support compound queries, three layers would need enhancement:

  1. Parser (Layer 1): Detect and structure multiple spatial relations
  2. Datasource (Layer 2): Support hierarchical/nested location resolution
  3. 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.


Type System & Hierarchy

etter uses a datasource-defined type system with semantic grouping and fuzzy matching.

Type Hierarchy

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

How It Works

  1. 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", ...]
  2. 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.
  3. 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

Defining Types for a New Datasource

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
        pass

See location_types.py for the complete type hierarchy and utilities.


Project Structure

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

PostGIS Demo Stack

The demo/docker-compose.yml provides a fully containerised version of the demo using PostGIS as the geodata backend.

Services

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

Normalized Table Schema

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.

Quick Start

# 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

Environment Variables

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 Mode Selection

demo/main.py auto-detects which mode to use:

  • PostGIS mode — when ETTER_DB_URL is set (used by docker-compose)
  • File mode — when ETTER_DB_URL is absent (original behaviour; uses SWISSNAMES3D_PATH / IGN_BDCARTO_PATH)

Installing the PostGIS Extra

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 driver
from etter.datasources import PostGISDataSource

source = PostGISDataSource(
    connection="postgresql+psycopg2://user:pass@localhost/mydb",
    table="public.my_geodata",
)
results = source.search("Lausanne", type="city")

Configuration

  • 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

Implementation Status

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 GeoJSON
    • IGNBDCartoSource — IGN BD-CARTO GeoPackages → WGS84 GeoJSON
    • PostGISDataSource — 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

Not Yet Implemented

  • Complex query types: compound (multi-step), split (area division), boolean (AND/OR/NOT)
  • Some edge cases in spatial operations