# united-states-geocoder

Offline US city/state → lat/lng geocoder. Backed by the **US Census 2024 Gazetteer Places** file (public domain), stored in **SQLite**, served by a small **Fastify** API, with an optional **AI fallback** (Claude Haiku 4.5) for typos and aliases.

- ~32,000 incorporated places + Census-designated places across all 50 states + DC + PR
- Sub-millisecond exact lookups, ~10ms fuzzy fallback
- Zero external services required (AI fallback is opt-in)
- **Pluggable AI providers**: Anthropic, OpenAI, Google Gemini, or local Ollama
- React + Vite test UI bundled in the same project

<p align="center">
  <img src="docs/screenshot.png" alt="Test UI: city/state lookup with US map pin, fuzzy suggestions, AI fallback panel with token usage and cost" width="640" />
</p>

## Quick start

```sh
git clone <your-repo-url> united-states-geocoder
cd united-states-geocoder
npm install
npm run gazetteer:load          # one-time: build data/geocoder.sqlite (~32k rows)
npm run dev                     # API on :3030, UI on :5173 (proxies /api → API)
```

Open <http://127.0.0.1:5173>.

For production:

```sh
npm run build                   # builds the UI into dist/
npm start                       # serves API + built UI on :3030
```

## API

Interactive OpenAPI docs (Swagger UI) are served at **`/docs`**, and the raw
OpenAPI 3 spec at **`/docs/json`** — e.g. <http://127.0.0.1:3030/docs> when
running `npm start`.

### `GET /api/geocode`

| Param  | Required | Notes                                            |
| ------ | -------- | ------------------------------------------------ |
| `city` | yes      | Free-form city/town name                         |
| `state`| yes      | USPS code (`TX`) or full name (`Texas`)          |
| `useAi`| no       | `true` to enable AI fallback on a miss           |

Response (excerpt — full shape below):

```json
{
  "provider": "us-census-gazetteer",
  "found": true,
  "matchMethod": "exact",
  "elapsedMs": 1,
  "city": "Austin",
  "state": "TX",
  "latitude": 30.307182,
  "longitude": -97.755996,
  "displayName": "Austin, TX",
  "match": { "geoid": "4805000", "lsad": "25", "fullName": "Austin city", "...": "..." },
  "suggestions": [],
  "aiCorrection": null
}
```

### `GET /api/health`

```json
{
  "ok": true,
  "service": "united-states-geocoder",
  "dataset": "US Census 2024 Gazetteer Places",
  "ai": { "available": false, "model": null }
}
```

`ai.available` is `true` when `ANTHROPIC_API_KEY` is set in the environment.

## How matching works

1. **Exact** — input is normalized (lowercase, periods stripped, `St`/`Mt`/`Ft` expanded to `Saint`/`Mount`/`Fort`) and looked up against the SQLite index `(state_code, city_key)` and `(state_code, full_city_key)`. Sub-millisecond.
2. **Fuzzy** — on miss, computes Levenshtein distance against every place in that state (cached after first use). Returns up to 3 suggestions within edit distance 2. Picks `city` over `town` over `CDP` on ties. ~5–15 ms.
3. **AI fallback (optional)** — if `useAi=true` and exact + fuzzy both fail (or fuzzy is uncertain), the input + state's candidate list is sent to Claude Haiku 4.5. Only **high-confidence** AI matches auto-resolve; medium/low confidence picks come back in `suggestions` for the caller to choose. Catches typos beyond Levenshtein's reach plus aliases (`STL` → `St. Louis`, `Brooklyn, NY` → `New York`).

## AI fallback (optional)

The AI feature is **off by default**. It runs only when (a) the toggle is on and (b) gazetteer exact + fuzzy matching both miss. Set one provider's key (or run Ollama) and the UI toggle becomes enabled. If multiple keys are set, the first available wins (order: anthropic → openai → gemini → ollama); pin a specific one with `AI_PROVIDER=<name>`.

### Supported providers

| Provider | `AI_PROVIDER` value | Required env                                           | Default model            | Typical cost per call (7–9k input tokens) |
| -------- | ------------------- | ------------------------------------------------------ | ------------------------ | ----------------------------------------- |
| Anthropic| `anthropic`         | `ANTHROPIC_API_KEY` (+ optional `ANTHROPIC_MODEL`)     | `claude-haiku-4-5-20251001` | ~$0.007–$0.009 (Haiku 4.5)             |
| OpenAI   | `openai`            | `OPENAI_API_KEY` (+ optional `OPENAI_MODEL`)           | `gpt-4o-mini`            | ~$0.001–$0.002 (gpt-4o-mini)              |
| Gemini   | `gemini`            | `GEMINI_API_KEY` or `GOOGLE_API_KEY` (+ `GEMINI_MODEL`)| `gemini-2.0-flash`       | ~$0.001 (2.0 Flash)                       |
| Ollama   | `ollama`            | `OLLAMA_BASE_URL` and/or `OLLAMA_MODEL` (must be running locally) | `llama3.2`    | $0 (runs on your machine)                 |

Published pricing references — verify before relying on the rates baked into this repo:
- Anthropic: <https://docs.claude.com/en/docs/about-claude/pricing>
- OpenAI: <https://openai.com/api/pricing/>
- Gemini: <https://ai.google.dev/gemini-api/docs/pricing>

The response field `aiCorrection.pricingPerMTok` is what the cost in `aiCorrection.costUsd` was computed against; update the per-provider tables in `server/ai/<provider>.js` if those rates change.

### Notes per provider

- **Anthropic** uses native tool-use with a forced tool call. The system prompt is cached as an ephemeral 5-minute block.
- **OpenAI** uses Chat Completions function calling with `tool_choice` pinned. Cached input tokens (the 50%-off prompt cache, automatic ≥1024 tokens) are reported back in `usage.cache_read_input_tokens`.
- **Gemini** uses `functionCallingConfig.mode = "ANY"` with an allowed-functions filter. Schema null-unions are translated to Gemini's `nullable` style on load.
- **Ollama** uses `/api/chat` with the JSON-schema `format` parameter (Ollama ≥ 0.5). Reliability depends on the model — `llama3.2` is the safe default; `qwen2.5` and `mistral` also work well. No streaming, temperature 0.

## Smoke test the AI provider

After setting a provider's API key, verify the round-trip works:

```sh
npm run ai:smoke                                    # default test: "Hosuton, TX" → Houston
TEST_CITY="Brooklyn" TEST_STATE="NY" npm run ai:smoke   # custom input
```

Prints the active provider, model, what came back, token usage, and cost. Exit code: 0 = success, 1 = provider call failed, 2 = setup error (no DB or no provider configured).

## Refreshing the data

The Census publishes updated Gazetteer files annually. To refresh:

1. Download the latest `<year>_Gaz_place_national.zip` from <https://www.census.gov/geographies/reference-files/time-series/geo/gazetteer-files.html>
2. Replace `data/gazetteer-places.txt` with the new TSV
3. Run `npm run gazetteer:load`

## Configuration

All optional, via `.env`:

| Variable               | Default                          | Meaning                              |
| ---------------------- | -------------------------------- | ------------------------------------ |
| `PORT`                 | `3030`                           | API listen port                      |
| `HOST`                 | `127.0.0.1`                      | API listen host                      |
| `GEOCODER_DB_PATH`     | `data/geocoder.sqlite`           | SQLite file path                     |
| `GEOCODER_DATA_PATH`   | `data/gazetteer-places.txt`      | Source TSV for the loader            |
| `AI_PROVIDER`          | _auto-detect_                    | `anthropic`, `openai`, `gemini`, or `ollama` |
| `ANTHROPIC_API_KEY` / `ANTHROPIC_MODEL`         | _unset_ / `claude-haiku-4-5-20251001` | Anthropic provider config |
| `OPENAI_API_KEY` / `OPENAI_MODEL`               | _unset_ / `gpt-4o-mini`         | OpenAI provider config              |
| `GEMINI_API_KEY` / `GEMINI_MODEL`               | _unset_ / `gemini-2.0-flash`    | Gemini provider config (`GOOGLE_API_KEY` also accepted) |
| `OLLAMA_BASE_URL` / `OLLAMA_MODEL`              | `http://127.0.0.1:11434` / `llama3.2` | Ollama provider config       |
| `LOG_LEVEL`            | `info`                           | Fastify log level                    |

## Project layout

```
united-states-geocoder/
├── data/
│   ├── gazetteer-places.txt     # Census 2024 source (committed)
│   └── geocoder.sqlite          # built by `npm run gazetteer:load` (gitignored)
├── scripts/
│   └── load-gazetteer.js
├── server/
│   ├── ai/
│   │   ├── index.js             # orchestrator: picks active provider
│   │   ├── prompt.js            # shared system prompt + JSON schema
│   │   ├── anthropic.js         # Claude adapter
│   │   ├── openai.js            # GPT adapter
│   │   ├── gemini.js            # Gemini adapter
│   │   └── ollama.js            # local LLM via HTTP (no SDK)
│   ├── db.js                    # node:sqlite connection
│   ├── geocoder.js              # core lookup logic (exact + fuzzy + AI)
│   └── server.js                # Fastify; serves API + (in prod) UI bundle
└── ui/
    ├── index.html
    ├── vite.config.js
    └── src/
        ├── App.jsx              # geocode test form, results, cost display
        ├── main.jsx
        └── index.css
```

## Security note

This service has **no authentication**. Bind it to localhost (default) or put it behind a reverse proxy / auth layer if you expose it publicly — otherwise anyone hitting `/api/geocode?useAi=true` can run up your Anthropic bill.

## Data attribution

City/state centroids and metadata © US Census Bureau, [2024 Gazetteer Files](https://www.census.gov/geographies/reference-files/time-series/geo/gazetteer-files.html). Public domain (no rights reserved).

## License

[MIT](./LICENSE)
