diff --git a/README.md b/README.md index ee85eeb6..92d98258 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | -------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | Géocoder un lieu | `geocode` | [Autocomplétion Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/autocompletion/) | Localiser une mairie | | Obtenir une altitude | `altitude` | [Calcul altimétrique Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-altimetrique/) | Altitude d'un point | +| Calculer une distance ou un temps de trajet | `distance` | [Calcul d'itinéraire Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-itineraire/) | Temps de trajet à pied | | Récupérer le contexte administratif | `adminexpress` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [ADMIN-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_ADMIN-EXPRESS) | Commune, département, région | | Récupérer le cadastre | `cadastre` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [PARCELLAIRE-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_PARCELLAIRE-EXPRESS-PCI) | Parcelle cadastrale | | Récupérer les documents d'urbanisme | `urbanisme` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | PLU, POS, CC | diff --git a/docs/config.md b/docs/config.md index d076cabd..0a00ad26 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,7 +18,7 @@ | `GPF_WFS_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le WFS de la Géoplateforme. | 30 | | `GPF_GEOCODE_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'autocomplétion de la Géoplateforme. | 50 | | `GPF_ALTI_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'altimétrie de la Géoplateforme. | 50 | -| `GPF_NAVIGATION_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'isochrone/navigation de la Géoplateforme. | 5 | +| `GPF_NAVIGATION_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'isochrone/navigation de la Géoplateforme. Budget partagé entre les appels isochrone et itinéraire. | 5 | | `GPF_WFS_MINISEARCH_OPTIONS` | Chaîne JSON optionnelle permettant de configurer `gpf_search_types`. | options par défaut de `@ignfab/gpf-schema-store` | | `LOG_FORMAT` | Le format d'écriture des logs : "json" ou "simple". | "simple" | | `LOG_LEVEL` | Le niveau d'écriture des logs : ["error", "info", ou "debug"](https://github.com/winstonjs/winston#logging-levels) | "debug" | diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d1c2045c..2163ef58 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -2220,12 +2220,14 @@ Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts) ### Titre -Distance entre deux points +Distance et temps de trajet entre deux points ### Description du tool ``` Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude. +Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou `pedestrian`. +(source : Géoplateforme (calcul d'itinéraire)). ``` ### Schéma d’entrée @@ -2234,7 +2236,8 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e | --- | --- | --- | --- | | `arrival` | object | oui | Le point d'arrivée | | `departure` | object | oui | Le point de départ | -| `profile` | string (enum) | non | Le type de chemin suivi : `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm). Valeurs : spherical, ellipsoidal. Valeur par défaut : spherical. | +| `optimize` | string (enum) | non | La métrique à optimiser, lorsqu'il y a un choix : `time` chemin le plus rapide, `distance` chemin le plus court. Cette option est sans effet lorsque `profile=spherical` ou `ellipsoidal`. Valeurs : time, distance. Valeur par défaut : time. | +| `profile` | string (enum) | non | Le type de chemin suivi : `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm), `car` en voiture, `pedestrian` à pied. Valeurs : spherical, ellipsoidal, car, pedestrian. Valeur par défaut : spherical. |
Schéma d’entrée brut @@ -2293,10 +2296,21 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e "type": "string", "enum": [ "spherical", - "ellipsoidal" + "ellipsoidal", + "car", + "pedestrian" ], "default": "spherical", - "description": "Le type de chemin suivi : `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm)." + "description": "Le type de chemin suivi : `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm), `car` en voiture, `pedestrian` à pied." + }, + "optimize": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "default": "time", + "description": "La métrique à optimiser, lorsqu'il y a un choix : `time` chemin le plus rapide, `distance` chemin le plus court. Cette option est sans effet lorsque `profile=spherical` ou `ellipsoidal`." } }, "required": [ @@ -2315,6 +2329,7 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `distance` | number | oui | La distance entre les deux points, en mètres. | +| `time` | number | non | Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`. |
Schéma de sortie brut @@ -2326,6 +2341,10 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e "distance": { "type": "number", "description": "La distance entre les deux points, en mètres." + }, + "time": { + "type": "number", + "description": "Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`." } }, "required": [ diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts new file mode 100644 index 00000000..9eba76b7 --- /dev/null +++ b/src/gpf/itinerary.ts @@ -0,0 +1,93 @@ +import { fetchJSONGet } from "../helpers/http.js"; +import logger from "../logger.js"; +import type { JsonFetcher } from "../helpers/http.js"; +import type { RateLimiter } from "../helpers/RateLimiter.js"; +import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; +import { TRAVEL_TIME_PROFILES, TRAVEL_TIME_RESOURCE } from "./navigation.js"; + +export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)"; +export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire"; +// Same engine as the `travel_time_filter` isochrones, so that both report the +// same travel times. +export const ITINERARY_RESOURCE = TRAVEL_TIME_RESOURCE; +export const ITINERARY_PROFILES = TRAVEL_TIME_PROFILES; +export const ITINERARY_METRICS = ["time", "distance"] as const; + +export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; +export type ItineraryMetric = typeof ITINERARY_METRICS[number]; + +type ItineraryResponse = { + distance: number; + duration: number; +}; + +export type ItineraryInput = { + departure: { + lon: number; + lat: number; + }; + arrival: { + lon: number; + lat: number; + }; + profile: ItineraryProfile; + optimize?: ItineraryMetric; +}; + +/** + * Builds the itinerary request URL. + */ +function buildItineraryUrl(input: ItineraryInput, geometryFormat: "polyline" | "geojson"): string { + return `${NAVIGATION_ITINERARY_URL}?${new URLSearchParams({ + resource: ITINERARY_RESOURCE, + start: `${input.departure.lon},${input.departure.lat}`, + end: `${input.arrival.lon},${input.arrival.lat}`, + profile: input.profile, + optimization: input.optimize === "distance" ? "shortest" : "fastest", + timeUnit: "minute", + distanceUnit: "meter", + crs: "EPSG:4326", + geometryFormat, + getSteps: "false", + getBbox: "false", + }).toString()}`; +} + +/** + * Validates the distance and duration returned by the itinerary service. + */ +function parseItineraryCosts(distance: unknown, duration: unknown): ItineraryResponse { + if (typeof distance !== "number" || typeof duration !== "number") { + throw new Error("Le service d'itinéraire n'a pas renvoyé de distance et de durée exploitables."); + } + return { distance, duration }; +} + +export class NavigationItineraryClient { + constructor( + private rateLimiter: RateLimiter, + private fetcher: JsonFetcher<{distance?: unknown; duration?: unknown}> = fetchJSONGet, + ) {} + + async getItinerary(input: ItineraryInput): Promise { + await this.rateLimiter.limit(); + logger.debug(`[gpf:navigation] getItinerary(${JSON.stringify(input)})...`); + + // polyline format minimizes response size; geometry is discarded anyway + const result = await this.fetcher(buildItineraryUrl(input, "polyline")); + return parseItineraryCosts(result.distance, result.duration); + } +} + +let defaultNavigationItineraryClient: NavigationItineraryClient | undefined; + +function getDefaultNavigationItineraryClient() { + defaultNavigationItineraryClient ??= new NavigationItineraryClient(getNavigationRateLimiter()); + return defaultNavigationItineraryClient; +} + +export const navigationItineraryClient = { + getItinerary(input: ItineraryInput) { + return getDefaultNavigationItineraryClient().getItinerary(input); + }, +}; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 52fc3fd9..6c8668aa 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -1,10 +1,10 @@ import { fetchJSONGet } from "../helpers/http.js"; import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; -import { RateLimiter } from "../helpers/RateLimiter.js"; -import { getEnv } from "../config/env.js"; import type { Geometry } from "geojson"; import { isGeometryLike } from "../helpers/geojson.js"; +import type { RateLimiter } from "../helpers/RateLimiter.js"; +import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; @@ -57,9 +57,7 @@ export class NavigationIsochroneClient { let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined; function getDefaultNavigationIsochroneClient() { - defaultNavigationIsochroneClient ??= new NavigationIsochroneClient( - new RateLimiter({ name: "GPF_NAVIGATION", maxCalls: getEnv().GPF_NAVIGATION_RATE_LIMIT, period: 1 }), - ); + defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter()); return defaultNavigationIsochroneClient; } diff --git a/src/gpf/navigationRateLimiter.ts b/src/gpf/navigationRateLimiter.ts new file mode 100644 index 00000000..2da47202 --- /dev/null +++ b/src/gpf/navigationRateLimiter.ts @@ -0,0 +1,16 @@ +import { RateLimiter } from "../helpers/RateLimiter.js"; +import { getEnv } from "../config/env.js"; + +// Isochrone (navigation.ts) and itinerary (itinerary.ts) calls both hit the +// data.geopf.fr/navigation service and share the same GPF_NAVIGATION_RATE_LIMIT +// budget, so they share one RateLimiter instance. +let sharedNavigationRateLimiter: RateLimiter | undefined; + +export function getNavigationRateLimiter(): RateLimiter { + sharedNavigationRateLimiter ??= new RateLimiter({ + name: "GPF_NAVIGATION", + maxCalls: getEnv().GPF_NAVIGATION_RATE_LIMIT, + period: 1, + }); + return sharedNavigationRateLimiter; +} diff --git a/src/tools/DistanceTool.ts b/src/tools/DistanceTool.ts index b4a660a5..d21cc849 100644 --- a/src/tools/DistanceTool.ts +++ b/src/tools/DistanceTool.ts @@ -1,10 +1,11 @@ /** - * MCP tool exposing the distance between two geographic positions. + * MCP tool exposing the distance and travel time between two geographic positions. */ import BaseTool from "./BaseTool.js"; import { z } from "zod"; +import { NAVIGATION_ITINERARY_SOURCE, navigationItineraryClient, ITINERARY_METRICS, ITINERARY_PROFILES } from "../gpf/itinerary.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import { lonSchema, latSchema } from "../helpers/schemas.js"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; @@ -23,16 +24,27 @@ const distanceInputSchema = z.object({ lat: latSchema.describe("La latitude du point d'arrivée."), }).describe("Le point d'arrivée"), profile: z - .enum(["spherical", "ellipsoidal"]) + .enum(["spherical", "ellipsoidal", ...ITINERARY_PROFILES]) .default("spherical") .describe(["Le type de chemin suivi :", " `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%),", - " `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm).", + " `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm),", + " `car` en voiture,", + " `pedestrian` à pied.", ].join("")), + optimize: z + .enum(ITINERARY_METRICS) + .default("time") + .describe(["La métrique à optimiser, lorsqu'il y a un choix :", + " `time` chemin le plus rapide,", + " `distance` chemin le plus court.", + " Cette option est sans effet lorsque `profile=spherical` ou `ellipsoidal`." + ].join("")) }).strict(); const distanceOutputSchema = z.object({ distance: z.number().describe("La distance entre les deux points, en mètres."), + time: z.number().optional().describe("Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`."), }); // --- Types --- @@ -41,11 +53,15 @@ type DistanceInput = z.infer; // --- Tool --- -const DISTANCE_TOOL_DESCRIPTION = `Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`; +const DISTANCE_TOOL_DESCRIPTION = [ + `Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`, + `Renvoie aussi une estimation du temps de trajet lorsque \`profile\` vaut \`car\` ou \`pedestrian\`.`, + `(source : ${NAVIGATION_ITINERARY_SOURCE}).`, +].join("\n"); class DistanceTool extends BaseTool { name = "distance"; - title = "Distance entre deux points"; + title = "Distance et temps de trajet entre deux points"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; description = DISTANCE_TOOL_DESCRIPTION; protected outputSchemaShape = distanceOutputSchema; @@ -62,7 +78,7 @@ class DistanceTool extends BaseTool { * Resolves the distance query. * * @param input Normalized tool input. - * @returns The distance. + * @returns The distance, and the travel time for itinerary profiles. */ async execute(input: DistanceInput) { logger.info(`[tool] execute ${this.name} ...`, { @@ -81,6 +97,19 @@ class DistanceTool extends BaseTool { distance: Math.round(raw * 100) / 100 }; } + case "car": + case "pedestrian": { + const itinerary = await navigationItineraryClient.getItinerary({ + departure: input.departure, + arrival: input.arrival, + profile: input.profile, + optimize: input.optimize, + }); + return { + distance: Math.round(itinerary.distance * 100) / 100, + time: Math.round(itinerary.duration * 10) / 10 + }; + } default: { const profile: never = input.profile; throw new Error(`Impossible profile ${profile}`); diff --git a/test/gpf/itinerary.test.ts b/test/gpf/itinerary.test.ts new file mode 100644 index 00000000..a9b552db --- /dev/null +++ b/test/gpf/itinerary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { NavigationItineraryClient } from "../../src/gpf/itinerary.js"; +import { RateLimiter } from "../../src/helpers/RateLimiter.js"; + +describe("NavigationItineraryClient", () => { + it("should build an itinerary request and return distance and duration", async () => { + const urls: string[] = []; + const client = new NavigationItineraryClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async (url) => { + urls.push(url); + return { + distance: 395174, + duration: 212, + }; + }, + ); + + const itinerary = await client.getItinerary({ + departure: { // 117 rue de Paris, 02100 Saint-Quentin + lon: 3.274356, + lat: 49.839862, + }, + arrival: { // 8 place de la République, 21000 Dijon + lon: 5.044572, + lat: 47.326213, + }, + profile: "car", + }); + + expect(itinerary).toEqual({ + distance: 395174, + duration: 212, + }); + + const parsedUrl = new URL(urls[0]); + expect(parsedUrl.origin + parsedUrl.pathname).toEqual("https://data.geopf.fr/navigation/itineraire"); + expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-valhalla"); + expect(parsedUrl.searchParams.get("start")).toEqual("3.274356,49.839862"); + expect(parsedUrl.searchParams.get("end")).toEqual("5.044572,47.326213"); + expect(parsedUrl.searchParams.get("profile")).toEqual("car"); + expect(parsedUrl.searchParams.get("optimization")).toEqual("fastest"); + expect(parsedUrl.searchParams.get("timeUnit")).toEqual("minute"); + expect(parsedUrl.searchParams.get("distanceUnit")).toEqual("meter"); + expect(parsedUrl.searchParams.get("crs")).toEqual("EPSG:4326"); + expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("polyline"); + expect(parsedUrl.searchParams.get("getSteps")).toEqual("false"); + expect(parsedUrl.searchParams.get("getBbox")).toEqual("false"); + }); + + it("should request the shortest itinerary when optimize=distance", async () => { + const urls: string[] = []; + const client = new NavigationItineraryClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async (url) => { + urls.push(url); + return { distance: 1000, duration: 10 }; + }, + ); + + await client.getItinerary({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 5.044572, lat: 47.326213 }, + profile: "pedestrian", + optimize: "distance", + }); + + expect(new URL(urls[0]).searchParams.get("optimization")).toEqual("shortest"); + }); + + it("should reject responses without usable distance and duration", async () => { + const client = new NavigationItineraryClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async () => ({ distance: 1000 }), + ); + + await expect(client.getItinerary({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 5.044572, lat: 47.326213 }, + profile: "car", + })).rejects.toThrow("distance et de durée exploitables"); + }); +}); diff --git a/test/integration/level2-agent/level2-agent.test.ts b/test/integration/level2-agent/level2-agent.test.ts index 2d0aeabf..b5b3421b 100644 --- a/test/integration/level2-agent/level2-agent.test.ts +++ b/test/integration/level2-agent/level2-agent.test.ts @@ -90,6 +90,16 @@ const mcpScenarios = [ // assuming that it won't often change and that the number is correct at the moment of writing // (switch to assertScenarioResult with a range if needed in the future) expectedResponseFragments: ["19", "batiments"] + }, + { + testName: "should find that Sivom swimming pool in Mondeville is the nearest pool to the LUX cinema in Caen, and that the walking distance is 29 minutes", + userInput: "Quel est le temps de marche exact entre le cinéma LUX, situé au sud-est de Caen, et la piscine la plus proche ?", + toolMode: "mcp", + requiredToolCalls: ["geocode", "gpf_search_types", "gpf_describe_type", "gpf_get_features", "distance"], + expectedResponseFragments: ["Sivom", "Mondeville"], + assertScenarioResult: ({ normalizedFinalMessage }) => { + expect(containsNumberInRange(normalizedFinalMessage, 25, 35)).toBe(true); + }, } ] satisfies Level2AgentScenario[]; diff --git a/test/tools/distance.test.ts b/test/tools/distance.test.ts index 1f175d18..7d575155 100644 --- a/test/tools/distance.test.ts +++ b/test/tools/distance.test.ts @@ -1,52 +1,106 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; -import DistanceTool from "../../src/tools/DistanceTool"; -import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema"; -import { expectErrorText } from "./helpers/errorAssertions"; +import DistanceTool from "../../src/tools/DistanceTool.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema.js"; +import { expectErrorText } from "./helpers/errorAssertions.js"; +import { navigationItineraryClient } from "../../src/gpf/itinerary.js"; +import { ellipsoidalDistance, haversine } from "../../src/helpers/distance.js"; describe("Test DistanceTool", () => { - const departure = { lon: 2.3522, lat: 48.8566 }; - const arrival = { lon: 2.2945, lat: 48.8584 }; - - it("should publish an optional profile and a distance output schema", () => { - const tool = new DistanceTool(); - expect(tool.toolDefinition.title).toEqual("Distance entre deux points"); - expect(tool.toolDefinition.inputSchema.required).not.toContain("profile"); - expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({ - enum: ["spherical", "ellipsoidal"], - default: "spherical", - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); + const departure = { lon: 2.3522, lat: 48.8566 }; + const arrival = { lon: 2.2945, lat: 48.8584 }; + + afterEach(() => vi.restoreAllMocks()); + + it("should publish an optional profile and a distance output schema", () => { + const tool = new DistanceTool(); + expect(tool.toolDefinition.title).toEqual("Distance et temps de trajet entre deux points"); + expect(tool.toolDefinition.inputSchema.required).not.toContain("profile"); + expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({ + enum: ["spherical", "ellipsoidal", "car", "pedestrian"], + default: "spherical", + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it.each([undefined, "spherical", "ellipsoidal"])("should return a structured distance for profile %s", async (profile) => { + const tool = new DistanceTool(); + const response = await tool.toolCall({ + params: { + name: "distance", + arguments: { departure, arrival, ...(profile && { profile }) }, + }, }); - it.each([undefined, "spherical", "ellipsoidal"])("should return a structured distance for profile %s", async (profile) => { - const tool = new DistanceTool(); - const response = await tool.toolCall({ - params: { - name: "distance", - arguments: { departure, arrival, ...(profile && { profile }) }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent).toMatchObject({ distance: expect.any(Number) }); - expect((response.structuredContent as { distance: number }).distance).toBeGreaterThan(0); - expect(response.content[0]).toMatchObject({ type: "text" }); - expect(validateStructuredContentAgainstOutputSchema( - tool.toolDefinition.outputSchema, - response.structuredContent, - )).toBeNull(); + expect(response.isError).toBeUndefined(); + expect(response.structuredContent).toMatchObject({ distance: expect.any(Number) }); + expect((response.structuredContent as { distance: number }).distance).toBeGreaterThan(0); + expect(response.content[0]).toMatchObject({ type: "text" }); + expect(validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + )).toBeNull(); + }); + + it("should reject invalid coordinates at the tool boundary", async () => { + const tool = new DistanceTool(); + const response = await tool.toolCall({ + params: { + name: "distance", + arguments: { departure: { lon: 600, lat: 48.8566 }, arrival }, + }, + }); + + expect(expectErrorText(response)).toContain("departure.lon: La valeur doit être au plus 180."); + }); + + it.each([ + ["spherical", haversine], + ["ellipsoidal", ellipsoidalDistance], + ] as const)("should return the rounded %s distance", async (profile, calculateDistance) => { + const response = await new DistanceTool().toolCall({ + params: { name: "distance", arguments: { departure, arrival, profile } }, }); + const expected = Math.round(calculateDistance( + [departure.lon, departure.lat], [arrival.lon, arrival.lat], + ) * 100) / 100; - it("should reject invalid coordinates at the tool boundary", async () => { - const tool = new DistanceTool(); - const response = await tool.toolCall({ - params: { - name: "distance", - arguments: { departure: { lon: 600, lat: 48.8566 }, arrival }, - }, - }); + expect(response.isError).toBeUndefined(); + expect(response.structuredContent).toEqual({ distance: expected }); + expect(JSON.parse((response.content[0] as { type: "text"; text: string }).text)).toEqual({ distance: expected }); + }); - expect(expectErrorText(response)).toContain("departure.lon: La valeur doit être au plus 180."); + it.each([ + ["car", 395174.126, 395174.13, 212, 212], + ["pedestrian", 12345, 12345, 67.83, 67.8], + ] as const)("should return itinerary distance and time for %s", async (profile, rawDistance, distance, duration, time) => { + const tool = new DistanceTool(); + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ distance: rawDistance, duration }); + const response = await tool.toolCall({ + params: { name: "distance", arguments: { departure, arrival, profile } }, }); + + expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile, optimize: "time" }); + expect(response.isError).toBeUndefined(); + expect(response.content[0]).toMatchObject({ type: "text" }); + expect(JSON.parse((response.content[0] as { type: "text"; text: string }).text)).toEqual({ distance, time }); + expect(response.structuredContent).toEqual({ distance, time }); + expect(validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + )).toBeNull(); + }); + + it("should forward optimize=distance to itinerary client", async () => { + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ + distance: 999, + duration: 10, + }); + + await new DistanceTool().toolCall({ + params: { name: "distance", arguments: { departure, arrival, profile: "car", optimize: "distance" } }, + }); + + expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile: "car", optimize: "distance" }); + }); });