mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add maps skill (OpenStreetMap + Overpass + OSRM, no API key)
Adds a maps optional skill with 8 commands, 44 POI categories, and zero external dependencies. Uses free open data: Nominatim, Overpass API, OSRM, and TimeAPI.io. Commands: search, reverse, nearby, distance, directions, timezone, area, bbox. Improvements over original PR #2015: - Fixed directory structure (optional-skills/productivity/maps/) - Fixed distance argparse (--to flag instead of broken dual nargs=+) - Fixed timezone (TimeAPI.io instead of broken worldtimeapi heuristic) - Expanded POI categories from 12 to 44 - Added directions command with turn-by-turn OSRM steps - Added area command (bounding box + dimensions for a named place) - Added bbox command (POI search within a geographic rectangle) - Added 23 unit tests - Improved haversine (atan2 for numerical stability) - Comprehensive SKILL.md with workflow examples Co-authored-by: Mibayy <Mibayy@users.noreply.github.com>
This commit is contained in:
parent
206a449b29
commit
7fa01fafa5
3 changed files with 1473 additions and 0 deletions
153
optional-skills/productivity/maps/SKILL.md
Normal file
153
optional-skills/productivity/maps/SKILL.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
name: maps
|
||||
description: >
|
||||
Geocoding, reverse geocoding, nearby POI search (44 categories),
|
||||
distance/routing, turn-by-turn directions, timezone lookup, bounding box
|
||||
search, and area info. Uses OpenStreetMap + Overpass + OSRM. Free, no API key.
|
||||
version: 1.1.0
|
||||
author: Mibayy
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [maps, geocoding, places, routing, distance, directions, openstreetmap, nominatim, overpass, osrm]
|
||||
category: productivity
|
||||
requires_toolsets: [terminal]
|
||||
---
|
||||
|
||||
# Maps Skill
|
||||
|
||||
Location intelligence using free, open data sources. 8 commands, 44 POI
|
||||
categories, zero dependencies (Python stdlib only), no API key required.
|
||||
|
||||
Data sources: OpenStreetMap/Nominatim, Overpass API, OSRM, TimeAPI.io.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User wants coordinates for a place name
|
||||
- User has coordinates and wants the address
|
||||
- User asks for nearby restaurants, hospitals, pharmacies, hotels, etc.
|
||||
- User wants driving/walking/cycling distance or travel time
|
||||
- User wants turn-by-turn directions between two places
|
||||
- User wants timezone information for a location
|
||||
- User wants to search for POIs within a geographic area
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Python 3.8+ (stdlib only — no pip installs needed).
|
||||
|
||||
Script path after install: `~/.hermes/skills/maps/scripts/maps_client.py`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
MAPS=~/.hermes/skills/maps/scripts/maps_client.py
|
||||
```
|
||||
|
||||
### search — Geocode a place name
|
||||
|
||||
```bash
|
||||
python3 $MAPS search "Eiffel Tower"
|
||||
python3 $MAPS search "1600 Pennsylvania Ave, Washington DC"
|
||||
```
|
||||
|
||||
Returns: lat, lon, display name, type, bounding box, importance score.
|
||||
|
||||
### reverse — Coordinates to address
|
||||
|
||||
```bash
|
||||
python3 $MAPS reverse 48.8584 2.2945
|
||||
```
|
||||
|
||||
Returns: full address breakdown (street, city, state, country, postcode).
|
||||
|
||||
### nearby — Find places by category
|
||||
|
||||
```bash
|
||||
python3 $MAPS nearby 48.8584 2.2945 restaurant --limit 10
|
||||
python3 $MAPS nearby 40.7128 -74.0060 hospital --radius 2000
|
||||
python3 $MAPS nearby 51.5074 -0.1278 cafe --limit 5 --radius 300
|
||||
```
|
||||
|
||||
44 categories: restaurant, cafe, bar, hospital, pharmacy, hotel, supermarket,
|
||||
atm, gas_station, parking, museum, park, school, university, bank, police,
|
||||
fire_station, library, airport, train_station, bus_stop, church, mosque,
|
||||
synagogue, dentist, doctor, cinema, theatre, gym, swimming_pool, post_office,
|
||||
convenience_store, bakery, bookshop, laundry, car_wash, car_rental,
|
||||
bicycle_rental, taxi, veterinary, zoo, playground, stadium, nightclub.
|
||||
|
||||
### distance — Travel distance and time
|
||||
|
||||
```bash
|
||||
python3 $MAPS distance "Paris" --to "Lyon"
|
||||
python3 $MAPS distance "New York" --to "Boston" --mode driving
|
||||
python3 $MAPS distance "Big Ben" --to "Tower Bridge" --mode walking
|
||||
```
|
||||
|
||||
Modes: driving (default), walking, cycling. Returns road distance, duration,
|
||||
and straight-line distance for comparison.
|
||||
|
||||
### directions — Turn-by-turn navigation
|
||||
|
||||
```bash
|
||||
python3 $MAPS directions "Eiffel Tower" --to "Louvre Museum" --mode walking
|
||||
python3 $MAPS directions "JFK Airport" --to "Times Square" --mode driving
|
||||
```
|
||||
|
||||
Returns numbered steps with instruction, distance, duration, road name, and
|
||||
maneuver type (turn, depart, arrive, etc.).
|
||||
|
||||
### timezone — Timezone for coordinates
|
||||
|
||||
```bash
|
||||
python3 $MAPS timezone 48.8584 2.2945
|
||||
python3 $MAPS timezone 35.6762 139.6503
|
||||
```
|
||||
|
||||
Returns timezone name, UTC offset, and current local time.
|
||||
|
||||
### area — Bounding box and area for a place
|
||||
|
||||
```bash
|
||||
python3 $MAPS area "Manhattan, New York"
|
||||
python3 $MAPS area "London"
|
||||
```
|
||||
|
||||
Returns bounding box coordinates, width/height in km, and approximate area.
|
||||
Useful as input for the bbox command.
|
||||
|
||||
### bbox — Search within a bounding box
|
||||
|
||||
```bash
|
||||
python3 $MAPS bbox 40.75 -74.00 40.77 -73.98 restaurant --limit 20
|
||||
```
|
||||
|
||||
Finds POIs within a geographic rectangle. Use `area` first to get the
|
||||
bounding box coordinates for a named place.
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
**"Find Italian restaurants near the Colosseum":**
|
||||
1. `search "Colosseum Rome"` → get lat/lon
|
||||
2. `nearby LAT LON restaurant --radius 500`
|
||||
|
||||
**"How do I walk from hotel to conference center?":**
|
||||
1. `directions "Hotel Name" --to "Conference Center" --mode walking`
|
||||
|
||||
**"What restaurants are in downtown Seattle?":**
|
||||
1. `area "Downtown Seattle"` → get bounding box
|
||||
2. `bbox S W N E restaurant --limit 30`
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Nominatim ToS: max 1 req/s (handled automatically by the script)
|
||||
- `nearby` requires lat/lon — use `search` first to get coordinates
|
||||
- OSRM routing coverage is best for Europe and North America
|
||||
- Overpass API can be slow during peak hours (script retries automatically)
|
||||
- `distance` and `directions` use `--to` flag for the destination (not positional)
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/maps/scripts/maps_client.py search "Statue of Liberty"
|
||||
# Should return lat ~40.689, lon ~-74.044
|
||||
```
|
||||
1143
optional-skills/productivity/maps/scripts/maps_client.py
Normal file
1143
optional-skills/productivity/maps/scripts/maps_client.py
Normal file
File diff suppressed because it is too large
Load diff
177
optional-skills/productivity/maps/tests/test_maps_client.py
Normal file
177
optional-skills/productivity/maps/tests/test_maps_client.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""Unit tests for maps_client.py pure functions."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the scripts directory to the path so we can import maps_client
|
||||
SCRIPTS_DIR = str(Path(__file__).resolve().parent.parent / "scripts")
|
||||
sys.path.insert(0, SCRIPTS_DIR)
|
||||
|
||||
import maps_client as mc
|
||||
|
||||
|
||||
# ── Haversine ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHaversine:
|
||||
def test_same_point_is_zero(self):
|
||||
assert mc.haversine_m(48.8584, 2.2945, 48.8584, 2.2945) == 0.0
|
||||
|
||||
def test_known_distance_paris_lyon(self):
|
||||
# Paris to Lyon is ~393 km straight line
|
||||
dist = mc.haversine_m(48.8566, 2.3522, 45.7640, 4.8357)
|
||||
assert 390_000 < dist < 400_000
|
||||
|
||||
def test_antipodal_points(self):
|
||||
# North pole to south pole ~20,000 km
|
||||
dist = mc.haversine_m(90, 0, -90, 0)
|
||||
assert 20_000_000 < dist < 20_100_000
|
||||
|
||||
def test_equator_quarter(self):
|
||||
# 0,0 to 0,90 is ~10,000 km
|
||||
dist = mc.haversine_m(0, 0, 0, 90)
|
||||
assert 10_000_000 < dist < 10_100_000
|
||||
|
||||
def test_symmetry(self):
|
||||
d1 = mc.haversine_m(40.7128, -74.0060, 51.5074, -0.1278)
|
||||
d2 = mc.haversine_m(51.5074, -0.1278, 40.7128, -74.0060)
|
||||
assert d1 == pytest.approx(d2)
|
||||
|
||||
|
||||
# ── Overpass query builder ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildOverpassQuery:
|
||||
def test_basic_query_structure(self):
|
||||
q = mc.build_overpass_nearby("amenity", "restaurant", 48.85, 2.29, 500, 10)
|
||||
assert "[out:json]" in q
|
||||
assert '"amenity"="restaurant"' in q
|
||||
assert "around:500,48.85,2.29" in q
|
||||
assert "out center 10" in q
|
||||
|
||||
def test_contains_node_and_way(self):
|
||||
q = mc.build_overpass_nearby("tourism", "hotel", 40.0, -74.0, 1000, 5)
|
||||
assert "node[" in q
|
||||
assert "way[" in q
|
||||
|
||||
def test_bbox_query_structure(self):
|
||||
q = mc.build_overpass_bbox("amenity", "cafe", 40.75, -74.00, 40.77, -73.98, 20)
|
||||
assert "[out:json]" in q
|
||||
assert '"amenity"="cafe"' in q
|
||||
assert "40.75,-74.0,40.77,-73.98" in q
|
||||
|
||||
|
||||
# ── Category validation ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCategories:
|
||||
def test_original_12_categories_exist(self):
|
||||
original = [
|
||||
"restaurant", "cafe", "bar", "hospital", "pharmacy", "hotel",
|
||||
"supermarket", "atm", "gas_station", "parking", "museum", "park",
|
||||
]
|
||||
for cat in original:
|
||||
assert cat in mc.CATEGORY_TAGS, f"Missing original category: {cat}"
|
||||
|
||||
def test_new_categories_exist(self):
|
||||
new_cats = [
|
||||
"school", "university", "bank", "police", "fire_station",
|
||||
"library", "airport", "train_station", "bus_stop", "dentist",
|
||||
"doctor", "cinema", "theatre", "gym", "post_office",
|
||||
"convenience_store", "bakery", "nightclub", "zoo", "playground",
|
||||
]
|
||||
for cat in new_cats:
|
||||
assert cat in mc.CATEGORY_TAGS, f"Missing new category: {cat}"
|
||||
|
||||
def test_all_categories_have_valid_tags(self):
|
||||
for cat, tag in mc.CATEGORY_TAGS.items():
|
||||
assert isinstance(tag, tuple), f"{cat}: tag should be tuple"
|
||||
assert len(tag) == 2, f"{cat}: tag should be (key, value)"
|
||||
assert isinstance(tag[0], str) and isinstance(tag[1], str)
|
||||
|
||||
def test_at_least_40_categories(self):
|
||||
assert len(mc.CATEGORY_TAGS) >= 40
|
||||
|
||||
|
||||
# ── OSRM profiles ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOSRMProfiles:
|
||||
def test_driving_walking_cycling(self):
|
||||
assert "driving" in mc.OSRM_PROFILES
|
||||
assert "walking" in mc.OSRM_PROFILES
|
||||
assert "cycling" in mc.OSRM_PROFILES
|
||||
|
||||
def test_profile_mappings(self):
|
||||
assert mc.OSRM_PROFILES["driving"] == "driving"
|
||||
assert mc.OSRM_PROFILES["walking"] == "foot"
|
||||
assert mc.OSRM_PROFILES["cycling"] == "bike"
|
||||
|
||||
|
||||
# ── Argparse ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestArgparse:
|
||||
def test_distance_uses_to_flag(self):
|
||||
"""The distance command should use --to, not two positional nargs='+'."""
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["distance", "Paris", "--to", "Lyon"])
|
||||
assert args.command == "distance"
|
||||
assert args.origin == ["Paris"]
|
||||
assert args.to == ["Lyon"]
|
||||
|
||||
def test_distance_multiword_origin(self):
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["distance", "New", "York", "--to", "Boston"])
|
||||
assert args.origin == ["New", "York"]
|
||||
assert args.to == ["Boston"]
|
||||
|
||||
def test_directions_uses_to_flag(self):
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["directions", "Big Ben", "--to", "Tower Bridge"])
|
||||
assert args.command == "directions"
|
||||
|
||||
def test_search_accepts_query(self):
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["search", "Eiffel", "Tower"])
|
||||
assert args.command == "search"
|
||||
assert args.query == ["Eiffel", "Tower"]
|
||||
|
||||
def test_nearby_accepts_category(self):
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["nearby", "48.85", "2.29", "restaurant"])
|
||||
assert args.command == "nearby"
|
||||
assert args.category == "restaurant"
|
||||
|
||||
def test_bbox_accepts_coordinates(self):
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["bbox", "40.75", "-74.00", "40.77", "-73.98", "cafe"])
|
||||
assert args.command == "bbox"
|
||||
assert args.category == "cafe"
|
||||
|
||||
def test_area_accepts_query(self):
|
||||
parser = mc.build_parser()
|
||||
args = parser.parse_args(["area", "Manhattan"])
|
||||
assert args.command == "area"
|
||||
|
||||
|
||||
# ── Output helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOutputHelpers:
|
||||
def test_print_json_outputs_valid_json(self, capsys):
|
||||
mc.print_json({"key": "value", "num": 42})
|
||||
captured = capsys.readouterr()
|
||||
data = json.loads(captured.out)
|
||||
assert data["key"] == "value"
|
||||
assert data["num"] == 42
|
||||
|
||||
def test_error_exit_outputs_error_json(self):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mc.error_exit("something went wrong")
|
||||
assert exc_info.value.code == 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue