BRKMYR/AI Builder Portfolio/Kartograf

Kartograf

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

View the repository →

At a glance

What

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.

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.

Responses captured from the running server with qwen3:8b, thinking off. Raw JSON: query_road_km.json · query_pharmacies.json · query_construction.json · meta.json · eval_summary.md


Problem

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.

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

REQUEST PATH · ONE QUESTION QUESTION LOCAL MODEL QWEN3 OR LLAMA SQL GUARD DUCKDB ANSWER MODEL'S LAST TOTAL KARTOGRAF DERIVES CHART MAP LAYER SQL LIST
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.
EVAL BASELINE · 20 QUESTIONS QWEN3 8B ENGLISH9 / 10 GERMAN6 / 10 REFUSALS4 / 4 LLAMA 3.1 8B ENGLISH6 / 10 GERMAN6 / 10 REFUSALS3 / 4 15 / 20 12 / 20
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.

Solution architecture

What it stands on, and what is new.

Inputs and dataopen map data
Overture transportationroad segments, release 2026-09-23.1. ODbL, © OpenStreetMap contributors and Overture Maps Foundation
Overture places97,402 points of interest. Sources under CDLA Permissive 2.0, Apache 2.0 (Foursquare) and CC0 (AllThePlaces)
Overture fetcherDuckDB over S3, row-group pruning, footer-only cost estimate, Frankfurt preset
roads and places as tables, short names
Models and algorithmswhat writes the SQL
Qwen3 8BApache 2.0, via Ollama, thinking off in the web app
Llama 3.1 8BLlama 3.1 Community License, via Ollama. Weights not redistributed
tool loop + promptone run_sql tool, spatial recipes, fix-and-retry rule, Frankfurt data note
SQL, one statement at a time
Engineown code, Python
read-only guardsingle SELECT, no file or URL readers, external access off
chart and map derivationbreakdown by class or category from the model's filter, bound-parameter highlights
eval harnessreference SQL, number, set and refusal checks, JSONL per model
DuckDB + spatialMIT. In-memory tables, ST_Distance_Sphere
answer, chart, map rows, SQL list
Outputs and interfaceswhere a reader lands
web appask bar, answer thread, clickable charts, keyboard, localhost only
kepler.gl 3.2.6MIT, loaded as a browser bundle with React 18. CARTO Positron basemap
Streamlit workbenchtables, release statistics, query log, map tab
Built in this projectThird party

Sources: Overture transportation data is ODbL and requires "© OpenStreetMap contributors"; the places theme combines sources under CDLA Permissive 2.0, Apache 2.0 and CC0 1.0 (docs.overturemaps.org/attribution). Extracts stay out of the repository and are rebuilt with the fetcher. Qwen3 is Apache 2.0. Llama 3.1 is under the Llama 3.1 Community License; the project calls a locally installed model and ships no weights. DuckDB, kepler.gl, React, Redux and styled-components are MIT, MapLibre is BSD 3, pandas and Altair are BSD 3, Streamlit and pydeck are Apache 2.0. Basemap © CARTO, © OpenStreetMap contributors. Inter Tight and IBM Plex Mono are SIL Open Font License. Everything else is this project, MIT. Independent project, not affiliated with Overture Maps Foundation, OpenStreetMap, Meta or Alibaba.

Why it matters

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.

Metrics, KPIs, evals

Numbers from the baseline eval and the captures above.

KPIValueWhat 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.