Getting Started
What is etter?
etter (/ˈɛtɐ/, Swiss German) — the boundary marking the edge of a village or commune.
etter transforms natural language location queries into structured geographic filters. It uses LLMs to understand multilingual queries and extract spatial relationships, returning typed Pydantic models your application can act on.
Key principle: etter has one responsibility — extract the geographic filter. It does not identify features, filter attributes, or execute searches.
Installation
pip install etterWith PostGIS datasource support:
pip install etter[postgis]Quick Start
from langchain.chat_models import init_chat_model
from etter import GeoFilterParser
import os
llm = init_chat_model(model="gpt-4o", temperature=0, api_key=os.getenv("LLM_API_KEY"))
parser = GeoFilterParser(llm=llm)
result = parser.parse("north of Lausanne")
print(result.spatial_relation.relation) # "north_of"
print(result.reference_location.name if result.reference_location else None) # "Lausanne"
print(result.buffer_config.distance_m) # 10000
print(result.confidence_breakdown.overall) # 0.95See GeoFilterParser for the full constructor signature and options.
Confidence and Strict Mode
By default etter warns on low confidence. Use strict_mode=True to raise instead:
# Lenient: emits LowConfidenceWarning below threshold
parser = GeoFilterParser(llm=llm, confidence_threshold=0.6, strict_mode=False)
# Strict: raises LowConfidenceError below threshold
parser = GeoFilterParser(llm=llm, confidence_threshold=0.8, strict_mode=True)See GeoQuery for a full description of all output fields.
Async parsing
Inside an event loop (e.g. a FastAPI handler), use the async counterpart aparse so the LLM call does not block other requests:
geo_query = await parser.aparse("north of Lausanne")aparse returns the same GeoQuery as parse; it differs only in that it awaits ainvoke on the underlying LLM. See aparse.
Streaming
For responsive UIs, use parse_stream to receive reasoning events in real time:
async for event in parser.parse_stream("5km north of Lausanne"):
if event["type"] == "reasoning":
print(event["content"]) # e.g. "Identified relation: north_of"
elif event["type"] == "data-response":
geo_query = event["content"] # raw dict (GeoQuery fields)
elif event["type"] == "error":
raise RuntimeError(event["content"])See parse_stream for all event types.
Custom Spatial Relations
Register additional relations beyond the 21 built-ins:
from etter import SpatialRelationConfig, RelationConfig
config = SpatialRelationConfig()
config.register_relation(RelationConfig(
name="close_to",
category="buffer",
description="Very close proximity",
default_distance_m=1000,
buffer_from="center",
ring_only=False, # Exclude reference feature for ring buffers
side=None, # "left" or "right" for one-sided buffers
sector_angle_degrees=None, # For custom directional sectors
direction_angle_degrees=None, # Direction in degrees (0=N, 90=E, 180=S, 270=W)
))
parser = GeoFilterParser(llm=llm, spatial_config=config)See Spatial Relations for the full list of built-ins and configuration options.
Additional Instructions
Pass additional_instructions to inject caller-specific rules into the prompt without forking the default system prompt. The text is added as a system message after the main prompt and before the few-shot examples.
Typical uses: region-specific endonyms, domain aliases, or organization-specific place names.
parser = GeoFilterParser(
llm=llm,
additional_instructions=(
"This application serves Swiss users. "
"'Lac Léman' and 'Lake Geneva' both refer to the same body of water. "
"Prefer the French endonym when the query is in French."
),
)Composing With Your Own Schema
To extract your own fields from the same query in a single LLM call, nest GeoQuery in your output model, reuse etter's prompt, and validate the nested result with finalize_geo_query:
from pydantic import BaseModel
from etter import GeoQuery, SpatialRelationConfig, build_geo_prompt_template, finalize_geo_query
class Output(BaseModel):
geo: GeoQuery
language: str
config = SpatialRelationConfig()
prompt = build_geo_prompt_template(config, additional_instructions="Also return the ISO 639-1 code of the query language.")
result = llm.with_structured_output(Output).invoke(prompt.format_messages(query=query))
geo = finalize_geo_query(result.geo, config, query)finalize_geo_query runs the same validation and enrichment pipeline as GeoFilterParser and accepts the same confidence_threshold and strict_mode arguments. See build_geo_prompt_template and finalize_geo_query.
Building a LangChain Chain
build_geo_prompt_template returns a standard ChatPromptTemplate, so it composes with any LangChain Runnable. Because the prompt step consumes the input dict, use RunnablePassthrough.assign to keep the query around for finalize_geo_query:
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from etter import GeoQuery, SpatialRelationConfig, build_geo_prompt_template, finalize_geo_query
config = SpatialRelationConfig()
chain = RunnablePassthrough.assign(
geo=build_geo_prompt_template(config) | llm.with_structured_output(GeoQuery)
) | RunnableLambda(lambda d: finalize_geo_query(d["geo"], config, d["query"]))
geo = chain.invoke({"query": "Wanderungen rund um Zermatt"})This is equivalent to GeoFilterParser.parse, and the chain gets every Runnable method for free. batch runs the queries concurrently and can collect failures instead of raising on the first one:
results = chain.batch(
[{"query": "restaurants in Geneva"}, {"query": "vineyards below 600 m"}],
config={"max_concurrency": 4},
return_exceptions=True,
)
# [GeoQuery(...), NoReferenceLocationError(...)]ainvoke, abatch and batch_as_completed work the same way. If you only need concurrency, parser.parse_batch(queries, max_concurrency=4) and its async twin aparse_batch do the same without building a chain; a rate-limited call surfaces as LLMInvocationError (see Error Handling).
