Ask a city map a question in plain English or German. A language model running on the laptop writes spatial SQL over open Overture Maps data for Frankfurt, a read-only database runs it, and the answer comes back with a chart, the matching places on a kepler.gl map, and every SQL statement that produced it. A 20-question eval scores two local models against reference answers.
Local web app · Overture Frankfurt · 2 local models · 20-question eval · 88 tests
A map with a search bar. "How many kilometres of road does Frankfurt have?" returns 1,770.85 km, a bar chart by road class, and the two SQL statements behind it. Questions about places near a landmark draw those places on the map and fly to them. Clicking a bar in the chart highlights exactly those roads or places.
Why it matters
Map data answers real questions, but asking it takes GIS skills, and a chatbot without tools invents plausible numbers. Agents over data platforms are where AI products are heading. This project shows the pattern at small scale, with the part that usually goes missing: an eval that says how often the agent is right.
How it was built
Python with DuckDB and its spatial extension, Ollama for the local models (Qwen3 8B and Llama 3.1 8B), and a single-page front end on kepler.gl 3.2.6 with no build step. Overture data is read straight from its public S3 bucket. 88 tests run offline without a model, on GitHub Actions for every push.
Recording · two questions in real time · qwen3:8b on a laptop
43 seconds in real time, no cuts after the map has loaded. "How many kilometres of road does Frankfurt have?" returns the total and a chart by road class in 8 seconds; clicking motorway highlights those 767 segments. "How many pharmacies are within 1 km of the main station?" draws the 13 results and flies the map there. Recorded in a scripted browser session against the local server, with the model already loaded.
Map data answers real questions. Asking it takes a specialist.
Open map data is now good and free: Overture Maps publishes roads, places, buildings and boundaries for the whole world every month. The people with the questions (city planners, retail site scouts, logistics teams, journalists) mostly cannot write spatial SQL, and a GIS analyst has a queue.
A language model closes the gap only if it is grounded. Without tools it produces a confident number with no source. With a database tool it can still filter wrongly and present the result as fact. The product question is not whether a model can write SQL, but how a user can see when it did not.
What it does
One question in, an answer with its evidence out.
The web app loads a Frankfurt extract of Overture roads and places into an in-memory DuckDB database and shows it on a kepler.gl map. A question typed into the ask bar goes to a local model with one tool, run_sql. The answer arrives with a chart, a map layer when places are involved, and the list of SQL statements, each marked as written by the model or added by Kartograf. A second surface, a Streamlit workbench, holds the data tables, release statistics and the query log.
Answers plain questions in English or German through a tool-calling loop with one read-only SQL tool, using a local model (Qwen3 8B with thinking off by default, Llama 3.1 8B selectable). llm.py, server.py
Derives a chart from the model's own query: a simple total over roads or places is rerun grouped by road class or place category with the model's filter unchanged, and places matching a filter are listed for the map. server.py derived_queries, tests/test_server.py
Highlights one chart bar on the map when clicked, through a whitelisted column and a bound SQL parameter, and flies the map to the result. server.py highlight_layer, web/index.html
Fetches any area from the public Overture bucket in one pass with Parquet row-group pruning, with a footer-only cost estimate first: about 40 MB for Frankfurt roads and places. fetch_overture.py --places --estimate
Scores models on 20 questions with reference SQL, in English and German, including four the data cannot answer. run_evals.py, evals/questions.yaml
Renders the map in a theme built for this site: Inter Tight, a warm monochrome palette, base data in light greys and ink only for answers. web/kartograf-theme.js
Solution
Give the model one tool. Show everything it did with it.
The model sees the table schema, three sample rows per table and a short data note, and has one tool. Every statement passes a read-only guard (a single SELECT, no file or network readers) and runs in DuckDB with external access disabled. Spatial know-how sits in the prompt as recipes, for example that distances on longitude and latitude need ST_Distance_Sphere in metres, rather than in extra tools that would hide the logic.
One design choice was measured rather than assumed. Asking the 8B model to also write the chart query made its first query worse: Llama 3.1 started emitting failing SQL and invented a road total of 2,312 km. So the chart moved to the server. It reuses the model's filter verbatim, which means the chart explains the model's number, including when the number is wrong. In one test Llama counted 5,193 "pharmacies" near the station, and the derived chart showed at a glance that it had counted every business.
Result
Runnable SQL every time. Right answers 12 to 15 times in 20.
On the 20-question baseline, both models produced SQL that ran for all 16 answerable questions. Qwen3 8B answered 15 correctly and Llama 3.1 8B 12. The largest failure class is guessed category values: the models wrote 'permanently closed' or 'dauerhaft geschlossen' instead of 'permanently_closed' and reported 0 without noticing. German costs both models: each scored 6 of 10 there. Llama invented one answer, counting engineering firms as 346 construction sites. One failure was mine, not the models': a loader bug made is_named = TRUE match nothing, so both scores are one too low until the next run.
Qwen3 8B correct
15 / 20
Llama 3.1 8B correct
12 / 20
Valid SQL
16 / 16
Web answer, warm
7 to 9 s
The model writes the answer query through one guarded tool. Kartograf then derives a breakdown and a map query from the model's last simple total, reusing its filter, and lists every statement with who wrote it.Baseline by slice. Bars are the share correct. English is where the models differ; German drops both to 6 of 10, mostly on mapping words like "Grundschulen" to Overture's English category values. Median latency in the eval: Llama 8.9 s, Qwen3 with thinking on 101 s.
An agent over a data platform is only as good as what it lets you check.
Natural-language access to data platforms is becoming a standard feature, and most demos stop at the answer. This project puts the weight on the parts a product team has to own: one narrow tool with a guard, every statement visible, a chart that is derived from the model's own query so that wrong filters show, and an eval with reference answers that turns "it seems to work" into 12 and 15 out of 20. It runs entirely on a laptop, which makes the same pattern usable on data that may not leave the building.
In five years
Most map and data platforms will be queried through agents as often as through dashboards, with standard protocols connecting the two. The deciding features will be the ones here: visible queries, refusals when the data has no answer, and evals run on every model change. A governed interface for decisions and an agent channel for questions will sit on the same query layer.
How it was built
A data chat prototype, then two days to agent, map and eval.
Python
3,095lines
Tests
88offline
Commits
13
Build span
25days
Active days
4
Runtime deps
8
Product direction, design decisions and review by hand. Implementation with Claude Code agents. The prototype was kept outside version control; git history starts with its import, followed by the agent, map, eval and web app. The web front end adds 464 lines of HTML and JavaScript. Python lines include 626 lines of tests, which run offline without a model. Measured from the repository.
The starting point was a small Streamlit app that let a model query CSV and GeoJSON files through one SQL tool, with an Overture fetcher for German road statistics. It then became Kartograf: a local Llama as the default, a Frankfurt preset with the places theme (whose category field had moved to taxonomy.primary since earlier releases), a kepler.gl map, and the eval harness. The first full eval run surfaced the loader bug that cost both models a point. The web app followed: the ask bar, server-side chart derivation after the prompt-based version made Llama worse, Qwen3 with thinking off as the default after it answered correctly in about a tenth of the time, and the paper-map styling.
Algorithms and mathGeodesic lengths, spherical distance queries, Parquet row-group pruning from footer statistics, and a rule-based derivation of breakdown queries. No training and no learned component of its own.
Systems integrationOllama tool calling with two model families, DuckDB spatial over public S3, kepler.gl as a browser bundle on React 18 without a build step (including a dispatch that React's asynchronous rendering silently dropped), a threaded local API, and Streamlit.
Data and domainOverture schemas and their changes, licences per theme, road class semantics, why Overture kilometres exceed official route lengths, and data quirks such as misplaced and duplicate station names.
Evaluation rigor20 bilingual questions with reference SQL that survive a new release, number, set and refusal checks, two models, and a failure taxonomy. One run per model, a small set and a keyword-based refusal check.
Interface and productA full-screen map with a centred ask bar, an answer thread, animated and clickable charts, map highlights, keyboard control and a custom kepler theme, plus a Streamlit workbench.
Difficulty 17 of 25 · AdvancedRelative to one competent engineer building it alone
Success criteria
When this counts as working.
Every answer shows every SQL statement behind it, marked as written by the model or by Kartograf. No number without a visible query.
No statement can write, read files or reach the network, whatever the model emits.
Questions the data cannot answer get a refusal, not a number. Met by Qwen3 in the eval (4 of 4), not yet reliably in the web app.
At least 18 of 20 eval questions correct with a local 8B model, in both languages.
An answer in under 15 seconds on a 16 GB laptop, with no data leaving the machine.
Metrics, KPIs, evals
Numbers from the baseline eval and the captures above.
KPI
Value
What it means
Correct answers
15 / 20 · 12 / 20
Qwen3 8B and Llama 3.1 8B on the baseline set. Number answers pass within the stated tolerance of the reference SQL result; sets must name every expected value.
Valid SQL
16 / 16
Both models produced at least one statement that ran for every answerable question. The errors are in meaning, not syntax.
Refusals
4 / 4 · 3 / 4
Accidents, speed limits, construction sites and rents are not in the data. Llama answered one with 346 invented construction sites.
German vs English
6 vs 9 · 6 vs 6
Correct out of 10 per language. German questions use everyday words that the model has to map to English category values.
Latency
7 to 9 s
Qwen3 8B with thinking off in the web app, on an Apple M4 with 16 GB, once the model is loaded. The first question after start took 27 s (the capture above). With thinking on, the eval median was 101 s; Llama's was 8.9 s.
Tests
88 / 0
Loader, SQL guard, tool loop with a fake model, eval scorer, chart and map derivation, highlight whitelist, rate limiter, query timeout and the kepler page builder, in under 3 s. They run on GitHub Actions for Python 3.11 and 3.12 on every push. No browser tests yet.
What this does not do yet.
Refuse reliably in the web app. Asked for construction sites, Qwen3 searched a category, found none and answered "0 construction sites" instead of saying the data has no such layer. See query_construction.json.
Know the category values. Guessed values are the largest failure class; the fix (listing distinct values in the prompt) is the next change to measure.
Find landmarks by name. Distance questions need coordinates, because the places data holds misplaced and duplicate names.
Run anywhere but a laptop. The web app is a local server for one user, and the published page shows captures, not a live demo.
Cover residential streets or other cities by default. The extract holds five classified road classes for one Frankfurt rectangle; the fetcher takes any bounding box.
Report variance. Each model ran the eval once; repeated runs and a larger set would give error bars.