From 80b83fd409c14ce8f6aaf1f429aa195eb5e46d53 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 9 Sep 2025 08:36:35 +0200 Subject: [PATCH] [Agent][Toolbox] Add Mapbox.com geocoding tool for address-to-coordinates conversion --- examples/.env | 3 + examples/bootstrap.php | 2 +- examples/toolbox/mapbox-geocode.php | 34 ++++ examples/toolbox/mapbox-reverse-geocode.php | 34 ++++ src/agent/CHANGELOG.md | 1 + src/agent/doc/index.rst | 4 + src/agent/src/Toolbox/Tool/Mapbox.php | 159 +++++++++++++++++ src/agent/tests/Toolbox/Tool/MapboxTest.php | 165 ++++++++++++++++++ .../Tool/fixtures/mapbox-geocode-empty.json | 5 + .../fixtures/mapbox-geocode-multiple.json | 30 ++++ .../Tool/fixtures/mapbox-geocode-single.json | 28 +++ .../mapbox-reverse-geocode-empty.json | 4 + .../Tool/fixtures/mapbox-reverse-geocode.json | 39 +++++ 13 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 examples/toolbox/mapbox-geocode.php create mode 100644 examples/toolbox/mapbox-reverse-geocode.php create mode 100644 src/agent/src/Toolbox/Tool/Mapbox.php create mode 100644 src/agent/tests/Toolbox/Tool/MapboxTest.php create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-empty.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-multiple.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-single.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode-empty.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode.json diff --git a/examples/.env b/examples/.env index 7a8741e1a..5c1fdc0b4 100644 --- a/examples/.env +++ b/examples/.env @@ -60,6 +60,9 @@ BRAVE_API_KEY= FIRECRAWL_HOST=https://api.firecrawl.dev FIRECRAWL_API_KEY= +# For using Mapbox (tool) +MAPBOX_ACCESS_TOKEN= + # For using MongoDB Atlas (store) MONGODB_URI=mongodb://symfony:symfony@127.0.0.1:27017 diff --git a/examples/bootstrap.php b/examples/bootstrap.php index 5a81d8f17..f743de3bf 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -23,7 +23,7 @@ require_once __DIR__.'/vendor/autoload.php'; (new Dotenv())->loadEnv(__DIR__.'/.env'); -function env(string $var) +function env(string $var): string { if (!isset($_SERVER[$var])) { printf('Please set the "%s" environment variable to run this example.', $var); diff --git a/examples/toolbox/mapbox-geocode.php b/examples/toolbox/mapbox-geocode.php new file mode 100644 index 000000000..1319a7650 --- /dev/null +++ b/examples/toolbox/mapbox-geocode.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Mapbox; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$model = new Gpt(Gpt::GPT_4O_MINI); + +$mapbox = new Mapbox(http_client(), env('MAPBOX_ACCESS_TOKEN')); +$toolbox = new Toolbox([$mapbox], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger()); + +$messages = new MessageBag(Message::ofUser('What are the coordinates of Brandenburg Gate in Berlin?')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/toolbox/mapbox-reverse-geocode.php b/examples/toolbox/mapbox-reverse-geocode.php new file mode 100644 index 000000000..62f571027 --- /dev/null +++ b/examples/toolbox/mapbox-reverse-geocode.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Mapbox; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$model = new Gpt(Gpt::GPT_4O_MINI); + +$mapbox = new Mapbox(http_client(), env('MAPBOX_ACCESS_TOKEN')); +$toolbox = new Toolbox([$mapbox], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger()); + +$messages = new MessageBag(Message::ofUser('What address is at coordinates longitude -73.985131, latitude 40.758895?')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/src/agent/CHANGELOG.md b/src/agent/CHANGELOG.md index 63b33fd46..7ab3b901b 100644 --- a/src/agent/CHANGELOG.md +++ b/src/agent/CHANGELOG.md @@ -26,6 +26,7 @@ CHANGELOG - `Clock` for current date/time - `Brave` for web search integration - `Crawler` for web page crawling + - `Mapbox` for geocoding addresses to coordinates and reverse geocoding - `OpenMeteo` for weather information - `SerpApi` for search engine results - `Tavily` for AI-powered search diff --git a/src/agent/doc/index.rst b/src/agent/doc/index.rst index d03f44a6b..47fda54a0 100644 --- a/src/agent/doc/index.rst +++ b/src/agent/doc/index.rst @@ -352,6 +352,8 @@ messages will be added to your MessageBag:: * `Brave Tool`_ * `Clock Tool`_ * `Crawler Tool`_ +* `Mapbox Geocode Tool`_ +* `Mapbox Reverse Geocode Tool`_ * `SerpAPI Tool`_ * `Tavily Tool`_ * `Weather Tool with Event Listener`_ @@ -623,6 +625,8 @@ useful when certain interactions shouldn't be influenced by the memory context:: .. _`Brave Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/brave.php .. _`Clock Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/clock.php .. _`Crawler Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/brave.php +.. _`Mapbox Geocode Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/mapbox-geocode.php +.. _`Mapbox Reverse Geocode Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/mapbox-reverse-geocode.php .. _`SerpAPI Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/serpapi.php .. _`Tavily Tool`: https://github.com/symfony/ai/blob/main/examples/toolbox/tavily.php .. _`Weather Tool with Event Listener`: https://github.com/symfony/ai/blob/main/examples/toolbox/weather-event.php diff --git a/src/agent/src/Toolbox/Tool/Mapbox.php b/src/agent/src/Toolbox/Tool/Mapbox.php new file mode 100644 index 000000000..8caefd85c --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Mapbox.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Oskar Stark + */ +#[AsTool(name: 'geocode', description: 'Convert addresses to coordinates using Mapbox Geocoding API', method: 'geocode')] +#[AsTool(name: 'reverse_geocode', description: 'Convert coordinates to addresses using Mapbox Reverse Geocoding API', method: 'reverseGeocode')] +final readonly class Mapbox +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + ) { + } + + /** + * @param string $address The address to geocode (e.g., "1600 Pennsylvania Ave, Washington DC") + * @param int $limit Maximum number of results to return (1-10) + * + * @return array{ + * results: array, + * count: int + * } + */ + public function geocode( + string $address, + #[With(minimum: 1, maximum: 10)] + int $limit = 1, + ): array { + $response = $this->httpClient->request('GET', 'https://api.mapbox.com/geocoding/v5/mapbox.places/'.urlencode($address).'.json', [ + 'query' => [ + 'access_token' => $this->accessToken, + 'limit' => $limit, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['features']) || [] === $data['features']) { + return [ + 'results' => [], + 'count' => 0, + ]; + } + + $results = []; + foreach ($data['features'] as $feature) { + $center = $feature['center'] ?? [0.0, 0.0]; + $results[] = [ + 'address' => $feature['place_name'] ?? '', + 'coordinates' => [ + 'longitude' => $center[0] ?? 0.0, + 'latitude' => $center[1] ?? 0.0, + ], + 'relevance' => $feature['relevance'] ?? 0.0, + 'place_type' => $feature['place_type'] ?? [], + ]; + } + + return [ + 'results' => $results, + 'count' => \count($results), + ]; + } + + /** + * @param float $longitude The longitude coordinate + * @param float $latitude The latitude coordinate + * @param int $limit Maximum number of results to return (1-5) + * + * @return array{ + * results: array + * }>, + * count: int + * } + */ + public function reverseGeocode( + float $longitude, + float $latitude, + #[With(minimum: 1, maximum: 5)] + int $limit = 1, + ): array { + $response = $this->httpClient->request('GET', 'https://api.mapbox.com/search/geocode/v6/reverse', [ + 'query' => [ + 'longitude' => $longitude, + 'latitude' => $latitude, + 'access_token' => $this->accessToken, + 'limit' => $limit, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['features']) || [] === $data['features']) { + return [ + 'results' => [], + 'count' => 0, + ]; + } + + $results = []; + foreach ($data['features'] as $feature) { + $properties = $feature['properties'] ?? []; + $coordinates = $properties['coordinates'] ?? []; + + $context = []; + if (isset($properties['context'])) { + foreach ($properties['context'] as $key => $contextItem) { + if (\is_array($contextItem) && isset($contextItem['name'])) { + $context[] = [ + 'id' => $contextItem['id'] ?? $contextItem['mapbox_id'] ?? '', + 'text' => $contextItem['name'], + 'type' => $key, + ]; + } + } + } + + $results[] = [ + 'address' => $properties['place_formatted'] ?? $properties['name'] ?? '', + 'coordinates' => [ + 'longitude' => $coordinates['longitude'] ?? 0.0, + 'latitude' => $coordinates['latitude'] ?? 0.0, + ], + 'place_type' => [$properties['feature_type'] ?? 'unknown'], + 'context' => $context, + ]; + } + + return [ + 'results' => $results, + 'count' => \count($results), + ]; + } +} diff --git a/src/agent/tests/Toolbox/Tool/MapboxTest.php b/src/agent/tests/Toolbox/Tool/MapboxTest.php new file mode 100644 index 000000000..05973a930 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/MapboxTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\Mapbox; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(Mapbox::class)] +final class MapboxTest extends TestCase +{ + public function testGeocodeWithSingleResult() + { + $result = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/mapbox-geocode-single.json'); + $httpClient = new MockHttpClient($result); + + $mapbox = new Mapbox($httpClient, 'test_token'); + + $actual = $mapbox->geocode('Brandenburg Gate, Berlin'); + $expected = [ + 'results' => [ + [ + 'address' => 'Brandenburg Gate, Pariser Platz, 10117 Berlin, Germany', + 'coordinates' => [ + 'longitude' => 13.377704, + 'latitude' => 52.516275, + ], + 'relevance' => 1.0, + 'place_type' => ['poi'], + ], + ], + 'count' => 1, + ]; + + $this->assertEquals($expected, $actual); + } + + public function testGeocodeWithMultipleResults() + { + $result = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/mapbox-geocode-multiple.json'); + $httpClient = new MockHttpClient($result); + + $mapbox = new Mapbox($httpClient, 'test_token'); + + $actual = $mapbox->geocode('Paris', 2); + $expected = [ + 'results' => [ + [ + 'address' => 'Paris, France', + 'coordinates' => [ + 'longitude' => 2.3522, + 'latitude' => 48.8566, + ], + 'relevance' => 1.0, + 'place_type' => ['place'], + ], + [ + 'address' => 'Paris, Texas, United States', + 'coordinates' => [ + 'longitude' => -95.5555, + 'latitude' => 33.6609, + ], + 'relevance' => 0.8, + 'place_type' => ['place'], + ], + ], + 'count' => 2, + ]; + + $this->assertEquals($expected, $actual); + } + + public function testGeocodeWithNoResults() + { + $result = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/mapbox-geocode-empty.json'); + $httpClient = new MockHttpClient($result); + + $mapbox = new Mapbox($httpClient, 'test_token'); + + $actual = $mapbox->geocode('nonexistent location xyz123'); + $expected = [ + 'results' => [], + 'count' => 0, + ]; + + $this->assertEquals($expected, $actual); + } + + public function testReverseGeocodeWithValidCoordinates() + { + $result = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/mapbox-reverse-geocode.json'); + $httpClient = new MockHttpClient($result); + + $mapbox = new Mapbox($httpClient, 'test_token'); + + $actual = $mapbox->reverseGeocode(-73.985131, 40.758895); + $expected = [ + 'results' => [ + [ + 'address' => 'Times Square, New York, NY 10036, United States', + 'coordinates' => [ + 'longitude' => -73.985131, + 'latitude' => 40.758895, + ], + 'place_type' => ['address'], + 'context' => [ + [ + 'id' => 'place.12345', + 'text' => 'New York', + 'type' => 'place', + ], + [ + 'id' => 'region.6789', + 'text' => 'New York', + 'type' => 'region', + ], + [ + 'id' => 'country.54321', + 'text' => 'United States', + 'type' => 'country', + ], + ], + ], + ], + 'count' => 1, + ]; + + $this->assertEquals($expected, $actual); + } + + public function testReverseGeocodeWithNoResults() + { + $result = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/mapbox-reverse-geocode-empty.json'); + $httpClient = new MockHttpClient($result); + + $mapbox = new Mapbox($httpClient, 'test_token'); + + $actual = $mapbox->reverseGeocode(0.0, 0.0); + $expected = [ + 'results' => [], + 'count' => 0, + ]; + + $this->assertEquals($expected, $actual); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-empty.json b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-empty.json new file mode 100644 index 000000000..6348ef4ae --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-empty.json @@ -0,0 +1,5 @@ +{ + "type": "FeatureCollection", + "query": ["nonexistent", "location", "xyz123"], + "features": [] +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-multiple.json b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-multiple.json new file mode 100644 index 000000000..138dd7cee --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-multiple.json @@ -0,0 +1,30 @@ +{ + "type": "FeatureCollection", + "query": ["paris"], + "features": [ + { + "id": "place.123", + "type": "Feature", + "place_type": ["place"], + "relevance": 1.0, + "place_name": "Paris, France", + "center": [2.3522, 48.8566], + "geometry": { + "type": "Point", + "coordinates": [2.3522, 48.8566] + } + }, + { + "id": "place.456", + "type": "Feature", + "place_type": ["place"], + "relevance": 0.8, + "place_name": "Paris, Texas, United States", + "center": [-95.5555, 33.6609], + "geometry": { + "type": "Point", + "coordinates": [-95.5555, 33.6609] + } + } + ] +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-single.json b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-single.json new file mode 100644 index 000000000..660ebb6a8 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-geocode-single.json @@ -0,0 +1,28 @@ +{ + "type": "FeatureCollection", + "query": ["brandenburg", "gate", "berlin"], + "features": [ + { + "id": "poi.123456", + "type": "Feature", + "place_type": ["poi"], + "relevance": 1.0, + "place_name": "Brandenburg Gate, Pariser Platz, 10117 Berlin, Germany", + "center": [13.377704, 52.516275], + "geometry": { + "type": "Point", + "coordinates": [13.377704, 52.516275] + }, + "context": [ + { + "id": "region.12345", + "text": "Berlin" + }, + { + "id": "country.6789", + "text": "Germany" + } + ] + } + ] +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode-empty.json b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode-empty.json new file mode 100644 index 000000000..8b3698faf --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode-empty.json @@ -0,0 +1,4 @@ +{ + "type": "FeatureCollection", + "features": [] +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode.json b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode.json new file mode 100644 index 000000000..1c01aa56a --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/mapbox-reverse-geocode.json @@ -0,0 +1,39 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "address.987654321", + "geometry": { + "type": "Point", + "coordinates": [-73.985131, 40.758895] + }, + "properties": { + "mapbox_id": "address.987654321", + "feature_type": "address", + "name": "Times Square", + "coordinates": { + "longitude": -73.985131, + "latitude": 40.758895, + "accuracy": "rooftop" + }, + "place_formatted": "Times Square, New York, NY 10036, United States", + "context": { + "place": { + "mapbox_id": "place.12345", + "name": "New York" + }, + "region": { + "mapbox_id": "region.6789", + "name": "New York" + }, + "country": { + "mapbox_id": "country.54321", + "name": "United States" + } + } + } + } + ], + "attribution": "NOTICE: © 2024 Mapbox and its suppliers." +}