From df0f6576b401f46b14472fb20406bcd76e88286d Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 2 Oct 2026 16:29:54 +0200 Subject: [PATCH 1/5] feat: add GpfIsochroneLayer tool --- README.md | 1 + docs/config.md | 6 +- docs/dev.md | 2 +- docs/mcp-tools.md | 110 ++++++++++++- scripts/generate-mcp-docs.mjs | 1 + src/gpf/itinerary.ts | 6 +- src/gpf/navigation.ts | 33 ++-- src/proxy/execute.ts | 50 +++++- src/proxy/server.ts | 34 ++-- src/proxy/transport.ts | 19 ++- src/tools/GpfIsochroneLayerTool.ts | 106 +++++++++++++ src/wfs/features.ts | 9 +- src/wfs/schema.ts | 62 ++++++-- test/gpf/navigation.test.ts | 4 +- test/integration/samples.ts | 1 + test/proxy/execute.test.ts | 37 ++++- test/proxy/server.test.ts | 52 +++++++ test/proxy/transport.test.ts | 32 +++- test/scripts/generate-mcp-docs.test.ts | 2 + test/tools/gpf-isochrone-layer.test.ts | 208 +++++++++++++++++++++++++ 20 files changed, 704 insertions(+), 71 deletions(-) create mode 100644 src/tools/GpfIsochroneLayerTool.ts create mode 100644 test/tools/gpf-isochrone-layer.test.ts diff --git a/README.md b/README.md index 92d98258..7e1ed408 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | Récupérer un objet par identifiant | `gpf_get_feature_by_id` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Charger une commune précise | | Télécharger le résultat d'une interrogation de couche GPF | `gpf_get_features_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier un résultat | | Télécharger un objet par identifiant | `gpf_get_feature_by_id_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Cartographier un objet | +| Télécharger une isochrone | `gpf_isochrone_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | ## Architecture en bref diff --git a/docs/config.md b/docs/config.md index 0a00ad26..dec5c140 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,12 +28,12 @@ | `PROXY_ENDPOINT` | Chemin exposé par le proxy geodata. | `/api/v1/proxy` | | `PROXY_PUBLIC_BASE_URL` | URL de base publiquement joignable du proxy, utilisée pour construire la `data_url` absolue transmise à Carto. Derrière un reverse-proxy, elle diffère de l'adresse d'écoute ; en développement local, c'est typiquement `http://localhost:3002`. Requise avec `PROXY_URL_SECRET` pour activer les tools `*_layer`. | Aucune | | `GPF_WFS_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le WFS, distincte de `GPF_WFS_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `10` | -| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `travel_time`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` | -| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. | `10` | +| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `travel_time` et `gpf_isochrone_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` | +| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isochrone_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` | ## Génération de `PROXY_URL_SECRET` -Pour produire des URLs opaques d'affichage cartographique (tools `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer). +Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isochrone_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer). La clé doit être une valeur aléatoire de **32 octets encodée en hexadécimal** (soit 64 caractères `0-9a-f`). Générez-la avec : diff --git a/docs/dev.md b/docs/dev.md index 3c80d322..157ee10b 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -60,7 +60,7 @@ Avec certains clients MCP, vous serez amené à éditer un fichier JSON. Par exe ## Activer les tools cartographiques en local -Les tools `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. +Les tools `gpf_isochrone_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. Il faut une clé partagée (`PROXY_URL_SECRET`) entre les deux processus, et pointer le MCP vers le proxy geodata local via `PROXY_PUBLIC_BASE_URL`. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 2163ef58..e55632f2 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -35,7 +35,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : | `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. | | `destructiveHint` | non | Le tool n'est pas signalé comme destructif. | | `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. | -| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | +| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isochrone_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -52,6 +52,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : - [`gpf_count_features`](#gpf_count_features) - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) +- [`gpf_isochrone_layer`](#gpf_isochrone_layer) - [`distance`](#distance) ## `geocode` @@ -2214,6 +2215,113 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu | Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). | +## `gpf_isochrone_layer` + +Code Source : [src/tools/GpfIsochroneLayerTool.ts](../src/tools/GpfIsochroneLayerTool.ts) + +### Titre + +Couche cartographiable d’isochrone GPF + +### Description du tool + +``` +Calcule l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète. +À utiliser pour afficher ou cartographier une zone de desserte. +Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal. +(source : Géoplateforme (calcul d'isochrone)). +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. | +| `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. | +| `minutes` | number | oui | Temps de trajet maximal en minutes. Maximum : 600. | +| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`). Valeurs : car, pedestrian. | + +
+Schéma d’entrée brut + +```json +{ + "type": "object", + "properties": { + "lon": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude du point de départ en WGS84 `lon/lat`." + }, + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude du point de départ en WGS84 `lon/lat`." + }, + "minutes": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 600, + "description": "Temps de trajet maximal en minutes. Maximum : 600." + }, + "profile": { + "type": "string", + "enum": [ + "car", + "pedestrian" + ], + "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + } + }, + "required": [ + "lon", + "lat", + "minutes", + "profile" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} +``` + +
+ +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `data_url` | string | oui | URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "data_url": { + "type": "string", + "description": "URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique.", + "format": "uri" + } + }, + "required": [ + "data_url" + ] +} +``` + +
+ +### Réponse MCP + +| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` | +| --- | --- | --- | --- | +| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | +| Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). | + ## `distance` Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts) diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index 4b7a4706..202d50d0 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -23,6 +23,7 @@ const toolDisplayOrder = [ "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", + "gpf_isochrone_layer", ]; /** diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index 9eba76b7..da3d8aae 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -3,14 +3,14 @@ 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"; +import { NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_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_RESOURCE = NAVIGATION_ISOCHRONE_RESOURCE; +export const ITINERARY_PROFILES = NAVIGATION_PROFILES; export const ITINERARY_METRICS = ["time", "distance"] as const; export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 6c8668aa..450e9f20 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -1,26 +1,19 @@ -import { fetchJSONGet } from "../helpers/http.js"; +import { fetchJSONGet, ServiceResponseError } from "../helpers/http.js"; import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; import type { Geometry } from "geojson"; import { isGeometryLike } from "../helpers/geojson.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; +import type { GpfIsochroneLayerInput } from "../wfs/schema.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; -export const TRAVEL_TIME_RESOURCE = "bdtopo-valhalla"; +export const NAVIGATION_ISOCHRONE_RESOURCE = "bdtopo-valhalla"; +// Upstream ceiling accepted by the GPF isochrone service for a time cost. +export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600; +export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const TRAVEL_TIME_MAX_MINUTES = 120; -export const TRAVEL_TIME_PROFILES = ["car", "pedestrian"] as const; - -export type TravelTimeProfile = typeof TRAVEL_TIME_PROFILES[number]; - - -export type TravelTimeGeometryInput = { - lon: number; - lat: number; - minutes: number; - profile: TravelTimeProfile; -}; export class NavigationIsochroneClient { constructor( @@ -28,12 +21,12 @@ export class NavigationIsochroneClient { private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet, ) {} - async getTravelTimeGeometry(input: TravelTimeGeometryInput): Promise { + async getIsochrone(input: GpfIsochroneLayerInput): Promise { await this.rateLimiter.limit(); - logger.debug(`[gpf:navigation] getTravelTimeGeometry(${JSON.stringify(input)})...`); + logger.debug(`[gpf:navigation] getGeometry(${JSON.stringify(input)})...`); const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({ - resource: TRAVEL_TIME_RESOURCE, + resource: NAVIGATION_ISOCHRONE_RESOURCE, point: `${input.lon},${input.lat}`, direction: "departure", costType: "time", @@ -47,7 +40,9 @@ export class NavigationIsochroneClient { const json = await this.fetcher(url); if (!isGeometryLike(json.geometry)) { - throw new Error("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable."); + throw new ServiceResponseError("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable.", { + http: { status: 502, statusText: "Bad Gateway" }, + }); } return json.geometry; @@ -62,7 +57,7 @@ function getDefaultNavigationIsochroneClient() { } export const navigationIsochroneClient = { - getTravelTimeGeometry(input: TravelTimeGeometryInput) { - return getDefaultNavigationIsochroneClient().getTravelTimeGeometry(input); + getIsochrone(input: GpfIsochroneLayerInput) { + return getDefaultNavigationIsochroneClient().getIsochrone(input); }, }; diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index dc0e9568..5e0caf6c 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -1,5 +1,5 @@ /** - * Proxy-side WFS execution engine. + * Proxy-side WFS and isochrone execution engine. * * `runGeometryFeatureQuery` (entry point) compiles and runs the layer query; * `resolveReferenceGeometry` (internal helper) resolves the reference geometry @@ -37,8 +37,12 @@ import { resolveFeatureGeometry } from "../wfs/referenceGeometry.js"; import { rethrowIdentifiedCatalogDesyncError } from "../wfs/catalogDesync.js"; import { ServiceResponseError, extractJsonServiceError } from "../helpers/http.js"; import type { WfsFeatureCollectionResponse } from "../wfs/types.js"; -import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput } from "../wfs/schema.js"; -import type { Geometry } from "geojson"; +import type { FeatureCollection, Geometry } from "geojson"; +import type { + GpfGetFeaturesInput, + GpfGetFeatureByIdLayerInput, + GpfIsochroneLayerInput, +} from "../wfs/schema.js"; // --- Injected Dependencies --- @@ -302,3 +306,43 @@ export async function runGeometryFeatureByIdQuery( numberMatched: 1, }; } + +// --- Isochrone Public Engine --- + +export type IsochroneGeometryResolver = ( + input: GpfIsochroneLayerInput, +) => Promise; + +export type GeometryIsochroneQueryDeps = { + getGeometry: IsochroneGeometryResolver; +}; + +/** + * Resolves an isochrone and returns it as a GeoJSON `FeatureCollection` with full + * geometry (for map rendering by MCP Carto). + * + * Counterpart of {@link runGeometryFeatureQuery} for the isochrone producer tool. + * The request params are echoed into `properties` so the rendered layer carries + * its own legend. + * + * @param input Validated isochrone layer input (`{ lon, lat, profile, minutes }`). + * @param deps Injected isochrone geometry resolver. + * @returns The isochrone as a single GeoJSON FeatureCollection. + */ +export async function runGeometryIsochroneQuery( + input: GpfIsochroneLayerInput, + deps: GeometryIsochroneQueryDeps, +): Promise { + const geometry = await deps.getGeometry(input); + + return { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + geometry, + properties: input, + } + ] + }; +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 7d8951c8..87c52847 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -2,8 +2,8 @@ * Stateless geodata proxy HTTP server. * * Serves `GET {PROXY_ENDPOINT}/.json`: decode the opaque token, re-validate - * it through the layer schema, run the geometry-full WFS query, and return a - * GeoJSON FeatureCollection for MCP Carto to render. + * it through the layer schema, run the geometry-full WFS or isochrone query, and + * return a GeoJSON FeatureCollection for MCP Carto to render. */ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; @@ -14,13 +14,19 @@ import { GPF_WFS_URL } from "../wfs/catalog.js"; import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, + gpfIsochroneLayerInputObjectSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; -import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery } from "./execute.js"; +import { + runGeometryFeatureQuery, + runGeometryFeatureByIdQuery, + runGeometryIsochroneQuery, +} from "./execute.js"; import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js"; import { getDefaultGeometryFeatureQueryDeps, getDefaultGeometryFeatureByIdQueryDeps, + getDefaultGeometryIsochroneQueryDeps, } from "./transport.js"; import { decodeToken, @@ -72,7 +78,7 @@ function toHttpError(error: unknown): HttpError { if (error instanceof FeatureCardinalityError) { // The client request was valid but the upstream WFS broke the single-feature // contract (duplicate / id mismatch / unusable body): an upstream anomaly. - return { status: 502, detail: "Le service WFS a renvoyé une réponse incohérente pour cet objet." }; + return { status: 502, detail: "Le service amont a renvoyé une réponse incohérente pour cet objet." }; } if (error instanceof ServiceResponseError) { const upstream = error.httpStatus ?? 502; @@ -80,13 +86,13 @@ function toHttpError(error: unknown): HttpError { // timeout (504). Client-provided data already passed validation, so a 4xx here // means an upstream contract issue, not a client error on the proxy endpoint. const status = upstream === 504 ? 504 : 502; - // Do NOT forward error.serviceDetail (raw upstream WFS text, English, internal + // Do NOT forward error.serviceDetail (raw upstream WFS or GPF text, English, internal // column names) to the client: like the other branches, return a fixed FR // message. The upstream detail is logged server-side by the caller. const detail = status === 504 - ? "Le service WFS n'a pas répondu à temps." - : "Le service WFS a renvoyé une réponse inexploitable."; + ? "Le service amont n'a pas répondu à temps." + : "Le service amont a renvoyé une réponse inexploitable."; return { status, detail }; } return { status: 500, detail: "Erreur interne du proxy." }; @@ -164,7 +170,7 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise getProxyIsochroneClient().getIsochrone(input), + }; +} diff --git a/src/tools/GpfIsochroneLayerTool.ts b/src/tools/GpfIsochroneLayerTool.ts new file mode 100644 index 00000000..edadc800 --- /dev/null +++ b/src/tools/GpfIsochroneLayerTool.ts @@ -0,0 +1,106 @@ +/** + * MCP tool producing an opaque, cartographiable layer URL for a Géoplateforme + * isochrone request. + * + * The tool returns a short opaque `data_url` that the LLM passes verbatim to a + * map client. Fetching it yields a GeoJSON FeatureCollection served by the + * stateless geodata proxy. The URL encodes the validated request params as an + * opaque token, so the LLM can neither parse nor rebuild the underlying + * upstream request. + */ + +import BaseTool from "./BaseTool.js"; + +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { getEnv } from "../config/env.js"; +import { encodeToken } from "../proxy/token.js"; +import { buildDataUrl } from "../proxy/dataUrl.js"; +import { + PROXY_TOKEN_KIND, + gpfGetFeaturesLayerOutputSchema, + gpfIsochroneLayerInputObjectSchema, + gpfIsochroneLayerPublishedInputSchema, + type GpfIsochroneLayerInput, +} from "../wfs/schema.js"; +import { NAVIGATION_SOURCE } from "../gpf/navigation.js"; +import logger from "../logger.js"; + +const GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION = [ + "Calcule l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.", + "À utiliser pour afficher ou cartographier une zone de desserte.", + "Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal.", + `(source : ${NAVIGATION_SOURCE}).`, +].join("\n"); + +// --- Tool --- + +class GpfIsochroneLayerTool extends BaseTool { + name = "gpf_isochrone_layer"; + title = "Couche cartographiable d’isochrone GPF"; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; + description = GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION; + protected outputSchemaShape = gpfGetFeaturesLayerOutputSchema; + + // The framework requires a plain Zod object here to publish a compatible input + // schema. The object schema is the full runtime contract here. + schema = gpfIsochroneLayerInputObjectSchema; + + /** + * Exposes an input schema variant that stays compatible with most MCP integrations. + * + * @returns The published input schema exposed through the MCP tool definition. + */ + get inputSchema() { + return gpfIsochroneLayerPublishedInputSchema; + } + + /** + * Formats the `{ data_url }` response into `structuredContent`. + * + * @param data Raw execution result returned by the tool implementation. + * @returns An MCP success response enriched with structured content. + */ + protected createSuccessResponse(data: unknown) { + const payload = gpfGetFeaturesLayerOutputSchema.parse(data); + + return { + content: [{ type: "text" as const, text: JSON.stringify(payload) }], + structuredContent: payload, + }; + } + + /** + * Mints the opaque proxy URL for the requested isochrone. No upstream call is + * made here: the isochrone itself is computed by the proxy when the `data_url` + * is fetched. + * + * @param input Validated isochrone layer input. + * @returns The `{ data_url }` payload carrying the opaque token. + */ + async execute(input: GpfIsochroneLayerInput) { + const env = getEnv(); + + if (!env.PROXY_URL_SECRET || !env.PROXY_PUBLIC_BASE_URL) { + throw new Error( + "`gpf_isochrone_layer` nécessite un proxy geodata configuré (variables d'environnement `PROXY_URL_SECRET` et `PROXY_PUBLIC_BASE_URL`, pointant vers un proxy joignable).", + ); + } + + const tokenParams = gpfIsochroneLayerInputObjectSchema.parse(input); + + logger.info(`[tool] execute ${this.name} ...`, { + input: tokenParams, + }); + + const token = encodeToken( + { kind: PROXY_TOKEN_KIND.isochrone, ...tokenParams }, + env.PROXY_URL_SECRET, + ); + + const dataUrl = buildDataUrl(env.PROXY_PUBLIC_BASE_URL, env.PROXY_ENDPOINT, token); + + return { data_url: dataUrl }; + } +} + +export default GpfIsochroneLayerTool; diff --git a/src/wfs/features.ts b/src/wfs/features.ts index 2caeb8fc..2cbaf697 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -109,12 +109,9 @@ export async function resolveTravelTimeGeometry( return undefined; } - return await navigationIsochroneClient.getTravelTimeGeometry({ - lon: spatialFilter.lon, - lat: spatialFilter.lat, - minutes: spatialFilter.minutes, - profile: spatialFilter.profile, - }); + const { operator, ...parameters } = spatialFilter; + + return await navigationIsochroneClient.getIsochrone(parameters); } /** diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 457bc330..0dca7cdb 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -10,7 +10,11 @@ import { z } from "zod"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; import { lonSchema, latSchema } from "../helpers/schemas.js"; -import { TRAVEL_TIME_MAX_MINUTES, TRAVEL_TIME_PROFILES } from "../gpf/navigation.js"; +import { + TRAVEL_TIME_MAX_MINUTES, + NAVIGATION_PROFILES, + NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, +} from "../gpf/navigation.js"; // --- Shared Constants --- @@ -91,19 +95,37 @@ const intersectsFeatureFilterSchema = z.object({ feature_id: z.string().trim().min(1).describe("Identifiant du feature de référence."), }).strict().describe("Filtre les objets dont la géométrie intersecte celle d'un objet GPF de référence."); -const travelTimeFilterSchema = z.object({ +const navigationProfileSchema = z + .enum(NAVIGATION_PROFILES) + .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."); + +const travelTimeMinutesSchema = z + .number() + .finite() + .positive() + .max(TRAVEL_TIME_MAX_MINUTES) + .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`); + +// Departure point of an isochrone. Flat `lon`/`lat`, exactly like every spatial +// filter (`intersects_point_filter`, `dwithin_point_filter`, ...), so the LLM sees +// one point convention across the whole surface. +const isochronePointSchema = z.object({ lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."), lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), - minutes: z - .number() - .finite() - .positive() - .max(TRAVEL_TIME_MAX_MINUTES) - .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`), - profile: z - .enum(TRAVEL_TIME_PROFILES) - .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."), -}).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); +}).strict(); + +const travelTimeFilterSchema = isochronePointSchema + .merge(z.object({ + minutes: travelTimeMinutesSchema, + profile: navigationProfileSchema, +})).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); + +const isochroneCostValueSchema = z + .number() + .finite() + .positive() + .max(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) + .describe(`Temps de trajet maximal en minutes. Maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); // --- Shared GPF Inputs --- @@ -384,14 +406,15 @@ export const gpfGetFeaturesLayerOutputSchema = z.object({ // --- Proxy token discriminant --- // The proxy serves ONE opaque token (in the URL path, `${endpoint}/.json`) -// but two token kinds (a filtered layer query and a single-feature by-id lookup). -// Both producer tools stamp their token +// but several token kinds (a filtered layer query, a single-feature by-id lookup +// and an isochrone). Every producer tool stamps its token // with this `kind` discriminant; the proxy reads it to dispatch to the right // schema + engine, then strips it before the strict per-kind `.parse`. It is // injected by the tool from validated params — never an LLM-supplied field. export const PROXY_TOKEN_KIND = { query: "query", byId: "by_id", + isochrone: "isochrone", } as const; export type ProxyTokenKind = (typeof PROXY_TOKEN_KIND)[keyof typeof PROXY_TOKEN_KIND]; @@ -430,6 +453,17 @@ export type GpfGetFeatureByIdLayerInput = z.infer; + +export const gpfIsochroneLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsochroneLayerInputObjectSchema); + // --- `gpf_count_features` --- export const gpfCountFeaturesInputObjectSchema = gpfTypenameInputSchema diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index c96cf1e0..34623527 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -21,7 +21,7 @@ describe("NavigationIsochroneClient", () => { }, ); - const geometry = await client.getTravelTimeGeometry({ + const geometry = await client.getIsochrone({ lon: 2.337306, lat: 48.849319, minutes: 15, @@ -49,7 +49,7 @@ describe("NavigationIsochroneClient", () => { async () => ({ geometry: null }), ); - await expect(client.getTravelTimeGeometry({ + await expect(client.getIsochrone({ lon: 2.337306, lat: 48.849319, minutes: 15, diff --git a/test/integration/samples.ts b/test/integration/samples.ts index e85668c5..eaeccf71 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -25,6 +25,7 @@ export const EXPECTED_TOOL_NAMES = [ "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", + "gpf_isochrone_layer", "gpf_get_features_layer", "gpf_get_feature_by_id_layer", ] as const; diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts index 11e7c0e3..993ba11d 100644 --- a/test/proxy/execute.test.ts +++ b/test/proxy/execute.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; import type { GpfFeatureType } from "../../src/wfs/catalog.js"; -import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; +import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsochroneQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; import type { CompiledRequest } from "../../src/wfs/request"; import type { WfsFeatureCollectionResponse } from "../../src/wfs/types"; import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; @@ -412,3 +412,38 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => { await expect(promise).rejects.toThrow(/FeatureCollection GeoJSON exploitable/); }); }); + +describe("proxy/execute · runGeometryIsochroneQuery", () => { + const isochroneInput = { lon: 2.35, lat: 48.85, profile: "pedestrian" as const, minutes: 15 }; + const isochroneGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] }; + + it("returns the isochrone as a FeatureCollection", async () => { + const result = await runGeometryIsochroneQuery(isochroneInput, { + getGeometry: async () => isochroneGeometry, + }); + + expect(result).toEqual({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: isochroneGeometry, + properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", minutes: 15 }, + } + ] + }); + }); + + it("maps the layer input onto the isochrone client input", async () => { + const calls: unknown[] = []; + + await runGeometryIsochroneQuery(isochroneInput, { + getGeometry: async (input) => { + calls.push(input); + return isochroneGeometry; + }, + }); + + expect(calls).toEqual([{ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }]); + }); +}); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 5eb89af8..77218e88 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -8,17 +8,21 @@ import { resetEnv } from "../../src/config/env"; import { PROXY_TOKEN_KIND } from "../../src/wfs/schema"; import { FeatureNotFoundError, FeatureCardinalityError } from "../../src/wfs/byId"; import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/http"; +import { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation"; // Mock the proxy engine + transport so the server is exercised WITHOUT network. const runGeometryFeatureQuery = vi.fn(); const runGeometryFeatureByIdQuery = vi.fn(); +const runGeometryIsochroneQuery = vi.fn(); vi.mock("../../src/proxy/execute", () => ({ runGeometryFeatureQuery: (...args: unknown[]) => runGeometryFeatureQuery(...args), runGeometryFeatureByIdQuery: (...args: unknown[]) => runGeometryFeatureByIdQuery(...args), + runGeometryIsochroneQuery: (...args: unknown[]) => runGeometryIsochroneQuery(...args), })); vi.mock("../../src/proxy/transport", () => ({ getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveTravelTime: vi.fn() }), getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }), + getDefaultGeometryIsochroneQueryDeps: () => ({ getGeometry: vi.fn() }), })); // A fixed 32-byte hex key for the test environment. @@ -50,6 +54,16 @@ function validByIdToken() { }, KEY); } +function validIsochroneToken() { + return encodeToken({ + kind: PROXY_TOKEN_KIND.isochrone, + lon: 2.35, + lat: 48.85, + profile: "pedestrian", + minutes: 15, + }, KEY); +} + beforeAll(async () => { process.env.TRANSPORT_TYPE = "http"; process.env.PROXY_URL_SECRET = TEST_SECRET; @@ -75,6 +89,7 @@ afterAll(async () => { beforeEach(() => { runGeometryFeatureQuery.mockReset(); runGeometryFeatureByIdQuery.mockReset(); + runGeometryIsochroneQuery.mockReset(); }); describe("proxy/server", () => { @@ -203,6 +218,43 @@ describe("proxy/server", () => { }); }); + it("dispatches an isochrone token to the isochrone engine", async () => { + runGeometryIsochroneQuery.mockResolvedValue(SAMPLE_COLLECTION); + + const res = await request(baseUrl).get(layerPath(validIsochroneToken())); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/geo+json"); + expect(JSON.parse(res.text)).toEqual(SAMPLE_COLLECTION); + expect(runGeometryIsochroneQuery).toHaveBeenCalledOnce(); + expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); + expect(runGeometryFeatureByIdQuery).not.toHaveBeenCalled(); + const [input] = runGeometryIsochroneQuery.mock.calls[0]; + expect(input).toEqual({ + lon: 2.35, + lat: 48.85, + profile: "pedestrian", + minutes: 15, + }); + }); + + it.each([ + ["minutes above the maximum", { minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }], + ["an unknown key", { typename: "BDTOPO_V3:batiment" }], + ])("400 when an isochrone token carries %s", async (_name, overrides) => { + const badToken = encodeToken({ + kind: PROXY_TOKEN_KIND.isochrone, + lon: 2.35, + lat: 48.85, + profile: "pedestrian", + minutes: 15, + ...overrides, + }, KEY); + const res = await request(baseUrl).get(layerPath(badToken)); + expect(res.status).toBe(400); + expect(runGeometryIsochroneQuery).not.toHaveBeenCalled(); + }); + it("404 when the by-id feature is absent (FeatureNotFoundError)", async () => { runGeometryFeatureByIdQuery.mockRejectedValue( new FeatureNotFoundError("Le feature 'batiment.404' est introuvable dans 'BDTOPO_V3:batiment'."), diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index 102873cf..70a0804c 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -35,7 +35,11 @@ vi.mock("../../src/helpers/RateLimiter", () => ({ }, })); -import { getProxyWfsClient, resolveProxyTravelTimeGeometry } from "../../src/proxy/transport"; +import { + getDefaultGeometryIsochroneQueryDeps, + getProxyWfsClient, + resolveProxyTravelTimeGeometry, +} from "../../src/proxy/transport"; import { resetEnv } from "../../src/config/env"; import { geometryToEwkt } from "../../src/wfs/geometry"; @@ -150,3 +154,29 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { expect(fetchJSONGetWithLimit).not.toHaveBeenCalled(); }); }); + +describe("proxy/transport · getDefaultGeometryIsochroneQueryDeps", () => { + it("resolves the isochrone through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { + const geometry = { type: "Polygon", coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] }; + fetchJSONGetWithLimit.mockResolvedValue({ geometry }); + + const result = await getDefaultGeometryIsochroneQueryDeps().getGeometry({ + lon: 2.35, + lat: 48.85, + minutes: 15, + profile: "car", + }); + + expect(fetchJSONGetWithLimit).toHaveBeenCalledOnce(); + const [url, timeoutMs, maxBytes, label] = fetchJSONGetWithLimit.mock.calls[0]; + expect(url).toContain("data.geopf.fr/navigation/isochrone"); + expect(url).toContain("point=2.35%2C48.85"); + expect(url).toContain("costValue=15"); + expect(url).toContain("profile=car"); + expect(timeoutMs).toBe(10 * 1000); // PROXY_UPSTREAM_TIMEOUT (s) → ms, NOT HTTP_TIMEOUT + expect(maxBytes).toBe(26214400); // PROXY_MAX_RESPONSE_BYTES + expect(label).toBe("d'isochrone"); + expect(rateLimit).toHaveBeenCalled(); + expect(result).toEqual(geometry); + }); +}); diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index bcb52e79..40f4d976 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -65,6 +65,7 @@ describe("generate-mcp-docs helpers", () => { { name: "gpf_get_feature_by_id_layer" }, { name: "gpf_count_features" }, { name: "gpf_get_features" }, + { name: "gpf_isochrone_layer" }, { name: "gpf_get_feature_by_id" }, { name: "adminexpress" }, { name: "gpf_get_features_layer" }, @@ -82,6 +83,7 @@ describe("generate-mcp-docs helpers", () => { "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", + "gpf_isochrone_layer", "unknown_custom_tool", ]); }); diff --git a/test/tools/gpf-isochrone-layer.test.ts b/test/tools/gpf-isochrone-layer.test.ts new file mode 100644 index 00000000..32bb14ab --- /dev/null +++ b/test/tools/gpf-isochrone-layer.test.ts @@ -0,0 +1,208 @@ +import { vi, describe, it, expect, afterEach } from "vitest"; + +import type { Env } from "../../src/config/env.js"; +import { decodeToken } from "../../src/proxy/token.js"; +import { PROXY_TOKEN_KIND } from "../../src/wfs/schema.js"; +import { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema.js"; + +const SECRET_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SECRET = Buffer.from(SECRET_HEX, "hex"); + +const mockGetEnv = vi.fn<() => Env>(); + +vi.doMock("../../src/config/env.js", async () => { + const actual = await vi.importActual( + "../../src/config/env.js", + ); + mockGetEnv.mockImplementation(actual.getEnv); + return { + ...actual, + getEnv: mockGetEnv, + }; +}); + +const { default: GpfIsochroneLayerTool } = await import( + "../../src/tools/GpfIsochroneLayerTool.js" +); + +function makeEnv(overrides: Partial): Env { + return { + TRANSPORT_TYPE: "http", + PROXY_URL_SECRET: SECRET, + PROXY_PUBLIC_BASE_URL: "https://proxy.example.test", + PROXY_ENDPOINT: "/api/v1/proxy", + ...overrides, + } as Env; +} + +describe("Test GpfIsochroneLayerTool", () => { + afterEach(() => { + vi.clearAllMocks(); + mockGetEnv.mockReset(); + }); + + it("publishes the same minutes upper bound as runtime validation", () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const minutesSchema = (tool.toolDefinition.inputSchema.properties as Record) + .minutes as { maximum?: number }; + + expect(minutesSchema.maximum).toBe(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES); + }); + + it("fails fast when no proxy is configured", async () => { + mockGetEnv.mockReturnValue( + makeEnv({ PROXY_URL_SECRET: undefined, PROXY_PUBLIC_BASE_URL: undefined }), + ); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: 15, + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("PROXY_URL_SECRET"); + }); + + it("mints a data_url under stdio when a proxy is configured (gate is config, not transport)", async () => { + mockGetEnv.mockReturnValue(makeEnv({ TRANSPORT_TYPE: "stdio" })); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: 15, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = JSON.parse((response.content[0] as { text: string }).text); + expect(payload.data_url).toContain("https://proxy.example.test/api/v1/proxy/"); + }); + + it("builds an opaque data_url that round-trips to the tagged isochrone params", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "car", + minutes: 60, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const payload = JSON.parse(textContent.text); + expect(payload).toEqual(response.structuredContent); + expect( + validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + ), + ).toBeNull(); + + const url = new URL(payload.data_url); + const token = url.pathname.slice("/api/v1/proxy/".length, -".json".length); + const decoded = decodeToken(token, SECRET); + expect(decoded).toEqual({ + kind: PROXY_TOKEN_KIND.isochrone, + lon: 2.337306, + lat: 48.849319, + profile: "car", + minutes: 60, + }); + }); + + it("rejects a time cost above the supported maximum", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain(`minutes: La valeur doit être au plus ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); + }); + + it("rejects an unknown key such as kind (strict isochrone surface)", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + kind: PROXY_TOKEN_KIND.query, + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: 15, + }, + }, + }); + + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'kind' n'est pas reconnu."); + }); + + it("rejects a missing profile", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + minutes: 15, + }, + }, + }); + + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'profile' est requis."); + }); +}); From f9256da3935393f9b7a0f81a626cdbf7f02b6ba9 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 17:07:06 +0200 Subject: [PATCH 2/5] refactor: rename isochrone -> isoline --- README.md | 2 +- docs/config.md | 2 +- docs/dev.md | 2 +- docs/mcp-tools.md | 11 +++--- scripts/generate-mcp-docs.mjs | 2 +- src/gpf/navigation.ts | 28 +++++++------- src/proxy/execute.ts | 16 ++++---- src/proxy/server.ts | 14 +++---- src/proxy/transport.ts | 38 +++++++++---------- ...oneLayerTool.ts => GpfIsolineLayerTool.ts} | 31 ++++++++------- src/wfs/features.ts | 4 +- src/wfs/schema.ts | 20 +++++----- test/gpf/navigation.test.ts | 14 +++---- test/integration/samples.ts | 2 +- test/proxy/execute.test.ts | 8 ++-- test/proxy/server.test.ts | 26 ++++++------- test/proxy/transport.test.ts | 14 +++---- test/scripts/generate-mcp-docs.test.ts | 4 +- ...ayer.test.ts => gpf-isoline-layer.test.ts} | 38 +++++++++---------- 19 files changed, 137 insertions(+), 139 deletions(-) rename src/tools/{GpfIsochroneLayerTool.ts => GpfIsolineLayerTool.ts} (71%) rename test/tools/{gpf-isochrone-layer.test.ts => gpf-isoline-layer.test.ts} (86%) diff --git a/README.md b/README.md index 7e1ed408..9d33aa79 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | Récupérer un objet par identifiant | `gpf_get_feature_by_id` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Charger une commune précise | | Télécharger le résultat d'une interrogation de couche GPF | `gpf_get_features_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier un résultat | | Télécharger un objet par identifiant | `gpf_get_feature_by_id_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Cartographier un objet | -| Télécharger une isochrone | `gpf_isochrone_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | +| Télécharger une isochrone | `gpf_isoline_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | ## Architecture en bref diff --git a/docs/config.md b/docs/config.md index dec5c140..a597255b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -33,7 +33,7 @@ ## Génération de `PROXY_URL_SECRET` -Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isochrone_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer). +Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer). La clé doit être une valeur aléatoire de **32 octets encodée en hexadécimal** (soit 64 caractères `0-9a-f`). Générez-la avec : diff --git a/docs/dev.md b/docs/dev.md index 157ee10b..aa14470f 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -60,7 +60,7 @@ Avec certains clients MCP, vous serez amené à éditer un fichier JSON. Par exe ## Activer les tools cartographiques en local -Les tools `gpf_isochrone_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. +Les tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. Il faut une clé partagée (`PROXY_URL_SECRET`) entre les deux processus, et pointer le MCP vers le proxy geodata local via `PROXY_PUBLIC_BASE_URL`. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index e55632f2..4e734824 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -35,7 +35,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : | `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. | | `destructiveHint` | non | Le tool n'est pas signalé comme destructif. | | `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. | -| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isochrone_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | +| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isoline_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -52,7 +52,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : - [`gpf_count_features`](#gpf_count_features) - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) -- [`gpf_isochrone_layer`](#gpf_isochrone_layer) +- [`gpf_isoline_layer`](#gpf_isoline_layer) - [`distance`](#distance) ## `geocode` @@ -2215,9 +2215,9 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu | Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). | -## `gpf_isochrone_layer` +## `gpf_isoline_layer` -Code Source : [src/tools/GpfIsochroneLayerTool.ts](../src/tools/GpfIsochroneLayerTool.ts) +Code Source : [src/tools/GpfIsolineLayerTool.ts](../src/tools/GpfIsolineLayerTool.ts) ### Titre @@ -2226,8 +2226,7 @@ Couche cartographiable d’isochrone GPF ### Description du tool ``` -Calcule l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète. -À utiliser pour afficher ou cartographier une zone de desserte. +Interroge l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète. Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal. (source : Géoplateforme (calcul d'isochrone)). ``` diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index 202d50d0..cd9e4afc 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -23,7 +23,7 @@ const toolDisplayOrder = [ "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", - "gpf_isochrone_layer", + "gpf_isoline_layer", ]; /** diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 450e9f20..043a2e26 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -5,28 +5,28 @@ import type { Geometry } from "geojson"; import { isGeometryLike } from "../helpers/geojson.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; -import type { GpfIsochroneLayerInput } from "../wfs/schema.js"; +import type { GpfIsolineLayerInput } from "../wfs/schema.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; -export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; -export const NAVIGATION_ISOCHRONE_RESOURCE = "bdtopo-valhalla"; +export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone"; +export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla"; // Upstream ceiling accepted by the GPF isochrone service for a time cost. export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600; export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const TRAVEL_TIME_MAX_MINUTES = 120; -export class NavigationIsochroneClient { +export class NavigationIsolineClient { constructor( private rateLimiter: RateLimiter, private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet, ) {} - async getIsochrone(input: GpfIsochroneLayerInput): Promise { + async getIsoline(input: GpfIsolineLayerInput): Promise { await this.rateLimiter.limit(); logger.debug(`[gpf:navigation] getGeometry(${JSON.stringify(input)})...`); - const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({ - resource: NAVIGATION_ISOCHRONE_RESOURCE, + const url = `${NAVIGATION_ISOLINE_URL}?${new URLSearchParams({ + resource: NAVIGATION_ISOLINE_RESOURCE, point: `${input.lon},${input.lat}`, direction: "departure", costType: "time", @@ -49,15 +49,15 @@ export class NavigationIsochroneClient { } } -let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined; +let defaultNavigationIsolineClient: NavigationIsolineClient | undefined; -function getDefaultNavigationIsochroneClient() { - defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter()); - return defaultNavigationIsochroneClient; +function getDefaultNavigationIsolineClient() { + defaultNavigationIsolineClient ??= new NavigationIsolineClient(getNavigationRateLimiter()); + return defaultNavigationIsolineClient; } -export const navigationIsochroneClient = { - getIsochrone(input: GpfIsochroneLayerInput) { - return getDefaultNavigationIsochroneClient().getIsochrone(input); +export const navigationIsolineClient = { + getIsoline(input: GpfIsolineLayerInput) { + return getDefaultNavigationIsolineClient().getIsoline(input); }, }; diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index 5e0caf6c..8835235f 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -41,7 +41,7 @@ import type { FeatureCollection, Geometry } from "geojson"; import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput, - GpfIsochroneLayerInput, + GpfIsolineLayerInput, } from "../wfs/schema.js"; // --- Injected Dependencies --- @@ -309,12 +309,12 @@ export async function runGeometryFeatureByIdQuery( // --- Isochrone Public Engine --- -export type IsochroneGeometryResolver = ( - input: GpfIsochroneLayerInput, +export type IsolineGeometryResolver = ( + input: GpfIsolineLayerInput, ) => Promise; -export type GeometryIsochroneQueryDeps = { - getGeometry: IsochroneGeometryResolver; +export type GeometryIsolineQueryDeps = { + getGeometry: IsolineGeometryResolver; }; /** @@ -329,9 +329,9 @@ export type GeometryIsochroneQueryDeps = { * @param deps Injected isochrone geometry resolver. * @returns The isochrone as a single GeoJSON FeatureCollection. */ -export async function runGeometryIsochroneQuery( - input: GpfIsochroneLayerInput, - deps: GeometryIsochroneQueryDeps, +export async function runGeometryIsolineQuery( + input: GpfIsolineLayerInput, + deps: GeometryIsolineQueryDeps, ): Promise { const geometry = await deps.getGeometry(input); diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 87c52847..fc885a0b 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -14,19 +14,19 @@ import { GPF_WFS_URL } from "../wfs/catalog.js"; import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, - gpfIsochroneLayerInputObjectSchema, + gpfIsolineLayerInputObjectSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, - runGeometryIsochroneQuery, + runGeometryIsolineQuery, } from "./execute.js"; import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js"; import { getDefaultGeometryFeatureQueryDeps, getDefaultGeometryFeatureByIdQueryDeps, - getDefaultGeometryIsochroneQueryDeps, + getDefaultGeometryIsolineQueryDeps, } from "./transport.js"; import { decodeToken, @@ -186,11 +186,11 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise fetchJSONGetWithLimit(url, getEnv().PROXY_UPSTREAM_TIMEOUT * 1000, getEnv().PROXY_MAX_RESPONSE_BYTES, "d'isochrone"), ); - return cachedProxyIsochroneClient; + return cachedProxyIsolineClient; } -// --- Reference-geometry resolver (travel_time / isochrone) --- +// --- Reference-geometry resolver (travel_time / isoline) --- /** * Reference-geometry resolver for the `travel_time` spatial filter: turns the - * isochrone into a reference geometry that is fed INTO the WFS query — the + * isoline into a reference geometry that is fed INTO the WFS query — the * sibling of `intersects_feature`'s reference-geometry resolution * (`resolveFeatureGeometry`). It does NOT fetch features itself (that is the - * WFS transport's job). Backed by the proxy isochrone client (bounded fetch + + * WFS transport's job). Backed by the proxy isoline client (bounded fetch + * `GPF_NAVIGATION_PROXY` rate limiter), and injected into `runGeometryFeatureQuery` * so it only fires for travel_time inputs. */ @@ -127,14 +127,14 @@ export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async ( const { operator, ...parameters } = spatialFilter; - return await getProxyIsochroneClient().getIsochrone(parameters); + return await getProxyIsolineClient().getIsoline(parameters); }; // --- Default Engine Dependencies --- /** * Default (production) dependency bundle for `runGeometryFeatureQuery`: the proxy - * WFS client and the proxy isochrone resolver. Bundling the concrete proxy wiring + * WFS client and the proxy isoline resolver. Bundling the concrete proxy wiring * here keeps `server.ts` decoupled from the individual clients — it asks the * transport layer for "the deps" instead of assembling them itself. Tests inject * their own deps into the engine directly. @@ -149,7 +149,7 @@ export function getDefaultGeometryFeatureQueryDeps(): GeometryFeatureQueryDeps { /** * Default (production) dependency bundle for `runGeometryFeatureByIdQuery`. * Narrower than {@link getDefaultGeometryFeatureQueryDeps}: a by-id lookup has no - * spatial filter, so it needs only the WFS client (no isochrone resolver). + * spatial filter, so it needs only the WFS client (no isoline resolver). */ export function getDefaultGeometryFeatureByIdQueryDeps(): GeometryFeatureByIdQueryDeps { return { @@ -158,10 +158,10 @@ export function getDefaultGeometryFeatureByIdQueryDeps(): GeometryFeatureByIdQue } /** - * Default dependency bundle for `runGeometryIsochroneQuery`. + * Default dependency bundle for `runGeometryIsolineQuery`. */ -export function getDefaultGeometryIsochroneQueryDeps(): GeometryIsochroneQueryDeps { +export function getDefaultGeometryIsolineQueryDeps(): GeometryIsolineQueryDeps { return { - getGeometry: (input) => getProxyIsochroneClient().getIsochrone(input), + getGeometry: (input) => getProxyIsolineClient().getIsoline(input), }; } diff --git a/src/tools/GpfIsochroneLayerTool.ts b/src/tools/GpfIsolineLayerTool.ts similarity index 71% rename from src/tools/GpfIsochroneLayerTool.ts rename to src/tools/GpfIsolineLayerTool.ts index edadc800..6180447d 100644 --- a/src/tools/GpfIsochroneLayerTool.ts +++ b/src/tools/GpfIsolineLayerTool.ts @@ -18,32 +18,31 @@ import { buildDataUrl } from "../proxy/dataUrl.js"; import { PROXY_TOKEN_KIND, gpfGetFeaturesLayerOutputSchema, - gpfIsochroneLayerInputObjectSchema, - gpfIsochroneLayerPublishedInputSchema, - type GpfIsochroneLayerInput, + gpfIsolineLayerInputObjectSchema, + gpfIsolineLayerPublishedInputSchema, + type GpfIsolineLayerInput, } from "../wfs/schema.js"; import { NAVIGATION_SOURCE } from "../gpf/navigation.js"; import logger from "../logger.js"; -const GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION = [ - "Calcule l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.", - "À utiliser pour afficher ou cartographier une zone de desserte.", +const GPF_ISOLINE_LAYER_TOOL_DESCRIPTION = [ + "Interroge l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.", "Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal.", `(source : ${NAVIGATION_SOURCE}).`, ].join("\n"); // --- Tool --- -class GpfIsochroneLayerTool extends BaseTool { - name = "gpf_isochrone_layer"; +class GpfIsolineLayerTool extends BaseTool { + name = "gpf_isoline_layer"; title = "Couche cartographiable d’isochrone GPF"; annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; - description = GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION; + description = GPF_ISOLINE_LAYER_TOOL_DESCRIPTION; protected outputSchemaShape = gpfGetFeaturesLayerOutputSchema; // The framework requires a plain Zod object here to publish a compatible input // schema. The object schema is the full runtime contract here. - schema = gpfIsochroneLayerInputObjectSchema; + schema = gpfIsolineLayerInputObjectSchema; /** * Exposes an input schema variant that stays compatible with most MCP integrations. @@ -51,7 +50,7 @@ class GpfIsochroneLayerTool extends BaseTool { * @returns The published input schema exposed through the MCP tool definition. */ get inputSchema() { - return gpfIsochroneLayerPublishedInputSchema; + return gpfIsolineLayerPublishedInputSchema; } /** @@ -77,23 +76,23 @@ class GpfIsochroneLayerTool extends BaseTool { * @param input Validated isochrone layer input. * @returns The `{ data_url }` payload carrying the opaque token. */ - async execute(input: GpfIsochroneLayerInput) { + async execute(input: GpfIsolineLayerInput) { const env = getEnv(); if (!env.PROXY_URL_SECRET || !env.PROXY_PUBLIC_BASE_URL) { throw new Error( - "`gpf_isochrone_layer` nécessite un proxy geodata configuré (variables d'environnement `PROXY_URL_SECRET` et `PROXY_PUBLIC_BASE_URL`, pointant vers un proxy joignable).", + "`gpf_isoline_layer` nécessite un proxy geodata configuré (variables d'environnement `PROXY_URL_SECRET` et `PROXY_PUBLIC_BASE_URL`, pointant vers un proxy joignable).", ); } - const tokenParams = gpfIsochroneLayerInputObjectSchema.parse(input); + const tokenParams = gpfIsolineLayerInputObjectSchema.parse(input); logger.info(`[tool] execute ${this.name} ...`, { input: tokenParams, }); const token = encodeToken( - { kind: PROXY_TOKEN_KIND.isochrone, ...tokenParams }, + { kind: PROXY_TOKEN_KIND.isoline, ...tokenParams }, env.PROXY_URL_SECRET, ); @@ -103,4 +102,4 @@ class GpfIsochroneLayerTool extends BaseTool { } } -export default GpfIsochroneLayerTool; +export default GpfIsolineLayerTool; diff --git a/src/wfs/features.ts b/src/wfs/features.ts index 2cbaf697..dea01035 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -6,7 +6,7 @@ * hit counting, and FeatureCollection post-processing. */ -import { navigationIsochroneClient } from "../gpf/navigation.js"; +import { navigationIsolineClient } from "../gpf/navigation.js"; import logger from "../logger.js"; import { resolveFeatureGeometry } from "./referenceGeometry.js"; import { rethrowIdentifiedCatalogDesyncError } from "./catalogDesync.js"; @@ -111,7 +111,7 @@ export async function resolveTravelTimeGeometry( const { operator, ...parameters } = spatialFilter; - return await navigationIsochroneClient.getIsochrone(parameters); + return await navigationIsolineClient.getIsoline(parameters); } /** diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 0dca7cdb..e8b03d48 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -109,18 +109,18 @@ const travelTimeMinutesSchema = z // Departure point of an isochrone. Flat `lon`/`lat`, exactly like every spatial // filter (`intersects_point_filter`, `dwithin_point_filter`, ...), so the LLM sees // one point convention across the whole surface. -const isochronePointSchema = z.object({ +const isolinePointSchema = z.object({ lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."), lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), }).strict(); -const travelTimeFilterSchema = isochronePointSchema +const travelTimeFilterSchema = isolinePointSchema .merge(z.object({ minutes: travelTimeMinutesSchema, profile: navigationProfileSchema, })).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); -const isochroneCostValueSchema = z +const isolineCostValueSchema = z .number() .finite() .positive() @@ -407,14 +407,14 @@ export const gpfGetFeaturesLayerOutputSchema = z.object({ // The proxy serves ONE opaque token (in the URL path, `${endpoint}/.json`) // but several token kinds (a filtered layer query, a single-feature by-id lookup -// and an isochrone). Every producer tool stamps its token +// and an isoline). Every producer tool stamps its token // with this `kind` discriminant; the proxy reads it to dispatch to the right // schema + engine, then strips it before the strict per-kind `.parse`. It is // injected by the tool from validated params — never an LLM-supplied field. export const PROXY_TOKEN_KIND = { query: "query", byId: "by_id", - isochrone: "isochrone", + isoline: "isoline", } as const; export type ProxyTokenKind = (typeof PROXY_TOKEN_KIND)[keyof typeof PROXY_TOKEN_KIND]; @@ -453,16 +453,16 @@ export type GpfGetFeatureByIdLayerInput = z.infer; +export type GpfIsolineLayerInput = z.infer; -export const gpfIsochroneLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsochroneLayerInputObjectSchema); +export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema); // --- `gpf_count_features` --- diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index 34623527..4d357896 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { NavigationIsochroneClient } from "../../src/gpf/navigation.js"; +import { NavigationIsolineClient } from "../../src/gpf/navigation.js"; import { RateLimiter } from "../../src/helpers/RateLimiter.js"; -describe("NavigationIsochroneClient", () => { - it("should build a Valhalla travel-time isochrone request and return its GeoJSON geometry", async () => { +describe("NavigationIsolineClient", () => { + it("should build a Valhalla travel-time isoline request and return its GeoJSON geometry", async () => { const urls: string[] = []; - const client = new NavigationIsochroneClient( + const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), async (url) => { urls.push(url); @@ -21,7 +21,7 @@ describe("NavigationIsochroneClient", () => { }, ); - const geometry = await client.getIsochrone({ + const geometry = await client.getIsoline({ lon: 2.337306, lat: 48.849319, minutes: 15, @@ -44,12 +44,12 @@ describe("NavigationIsochroneClient", () => { }); it("should reject responses without usable GeoJSON geometry", async () => { - const client = new NavigationIsochroneClient( + const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), async () => ({ geometry: null }), ); - await expect(client.getIsochrone({ + await expect(client.getIsoline({ lon: 2.337306, lat: 48.849319, minutes: 15, diff --git a/test/integration/samples.ts b/test/integration/samples.ts index eaeccf71..fa89d23a 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -25,7 +25,7 @@ export const EXPECTED_TOOL_NAMES = [ "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", - "gpf_isochrone_layer", + "gpf_isoline_layer", "gpf_get_features_layer", "gpf_get_feature_by_id_layer", ] as const; diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts index 993ba11d..4ad25010 100644 --- a/test/proxy/execute.test.ts +++ b/test/proxy/execute.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; import type { GpfFeatureType } from "../../src/wfs/catalog.js"; -import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsochroneQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; +import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; import type { CompiledRequest } from "../../src/wfs/request"; import type { WfsFeatureCollectionResponse } from "../../src/wfs/types"; import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; @@ -413,12 +413,12 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => { }); }); -describe("proxy/execute · runGeometryIsochroneQuery", () => { +describe("proxy/execute · runGeometryIsolineQuery", () => { const isochroneInput = { lon: 2.35, lat: 48.85, profile: "pedestrian" as const, minutes: 15 }; const isochroneGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] }; it("returns the isochrone as a FeatureCollection", async () => { - const result = await runGeometryIsochroneQuery(isochroneInput, { + const result = await runGeometryIsolineQuery(isochroneInput, { getGeometry: async () => isochroneGeometry, }); @@ -437,7 +437,7 @@ describe("proxy/execute · runGeometryIsochroneQuery", () => { it("maps the layer input onto the isochrone client input", async () => { const calls: unknown[] = []; - await runGeometryIsochroneQuery(isochroneInput, { + await runGeometryIsolineQuery(isochroneInput, { getGeometry: async (input) => { calls.push(input); return isochroneGeometry; diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 77218e88..5a85d42d 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -13,16 +13,16 @@ import { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation" // Mock the proxy engine + transport so the server is exercised WITHOUT network. const runGeometryFeatureQuery = vi.fn(); const runGeometryFeatureByIdQuery = vi.fn(); -const runGeometryIsochroneQuery = vi.fn(); +const runGeometryIsolineQuery = vi.fn(); vi.mock("../../src/proxy/execute", () => ({ runGeometryFeatureQuery: (...args: unknown[]) => runGeometryFeatureQuery(...args), runGeometryFeatureByIdQuery: (...args: unknown[]) => runGeometryFeatureByIdQuery(...args), - runGeometryIsochroneQuery: (...args: unknown[]) => runGeometryIsochroneQuery(...args), + runGeometryIsolineQuery: (...args: unknown[]) => runGeometryIsolineQuery(...args), })); vi.mock("../../src/proxy/transport", () => ({ getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveTravelTime: vi.fn() }), getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }), - getDefaultGeometryIsochroneQueryDeps: () => ({ getGeometry: vi.fn() }), + getDefaultGeometryIsolineQueryDeps: () => ({ getGeometry: vi.fn() }), })); // A fixed 32-byte hex key for the test environment. @@ -54,9 +54,9 @@ function validByIdToken() { }, KEY); } -function validIsochroneToken() { +function validIsolineToken() { return encodeToken({ - kind: PROXY_TOKEN_KIND.isochrone, + kind: PROXY_TOKEN_KIND.isoline, lon: 2.35, lat: 48.85, profile: "pedestrian", @@ -89,7 +89,7 @@ afterAll(async () => { beforeEach(() => { runGeometryFeatureQuery.mockReset(); runGeometryFeatureByIdQuery.mockReset(); - runGeometryIsochroneQuery.mockReset(); + runGeometryIsolineQuery.mockReset(); }); describe("proxy/server", () => { @@ -218,18 +218,18 @@ describe("proxy/server", () => { }); }); - it("dispatches an isochrone token to the isochrone engine", async () => { - runGeometryIsochroneQuery.mockResolvedValue(SAMPLE_COLLECTION); + it("dispatches an isoline token to the isoline engine", async () => { + runGeometryIsolineQuery.mockResolvedValue(SAMPLE_COLLECTION); - const res = await request(baseUrl).get(layerPath(validIsochroneToken())); + const res = await request(baseUrl).get(layerPath(validIsolineToken())); expect(res.status).toBe(200); expect(res.headers["content-type"]).toContain("application/geo+json"); expect(JSON.parse(res.text)).toEqual(SAMPLE_COLLECTION); - expect(runGeometryIsochroneQuery).toHaveBeenCalledOnce(); + expect(runGeometryIsolineQuery).toHaveBeenCalledOnce(); expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); expect(runGeometryFeatureByIdQuery).not.toHaveBeenCalled(); - const [input] = runGeometryIsochroneQuery.mock.calls[0]; + const [input] = runGeometryIsolineQuery.mock.calls[0]; expect(input).toEqual({ lon: 2.35, lat: 48.85, @@ -243,7 +243,7 @@ describe("proxy/server", () => { ["an unknown key", { typename: "BDTOPO_V3:batiment" }], ])("400 when an isochrone token carries %s", async (_name, overrides) => { const badToken = encodeToken({ - kind: PROXY_TOKEN_KIND.isochrone, + kind: PROXY_TOKEN_KIND.isoline, lon: 2.35, lat: 48.85, profile: "pedestrian", @@ -252,7 +252,7 @@ describe("proxy/server", () => { }, KEY); const res = await request(baseUrl).get(layerPath(badToken)); expect(res.status).toBe(400); - expect(runGeometryIsochroneQuery).not.toHaveBeenCalled(); + expect(runGeometryIsolineQuery).not.toHaveBeenCalled(); }); it("404 when the by-id feature is absent (FeatureNotFoundError)", async () => { diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index 70a0804c..a0e2bfac 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -7,8 +7,8 @@ import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; // - fetchJSONPostWithLimit (the bounded WFS fetch, parses to JSON) — but keep the real error classes; // - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the travel_time leg // goes through the SAME PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES bounds as WFS. -// The real NavigationIsochroneClient runs (only its fetcher is mocked), so this covers -// the previously-untested gap where the isochrone leg used unbounded fetchJSONGet. +// The real NavigationIsolineClient runs (only its fetcher is mocked), so this covers +// the previously-untested gap where the isoline leg used unbounded fetchJSONGet. // - RateLimiter (assert it is invoked, without real timing). // The parse + 502-on-bad-body now lives inside fetchJSON*WithLimit (helpers/http), // so it is covered there; here we only assert the transport wires the right args. @@ -36,7 +36,7 @@ vi.mock("../../src/helpers/RateLimiter", () => ({ })); import { - getDefaultGeometryIsochroneQueryDeps, + getDefaultGeometryIsolineQueryDeps, getProxyWfsClient, resolveProxyTravelTimeGeometry, } from "../../src/proxy/transport"; @@ -118,7 +118,7 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { }; it("resolves the isochrone through the BOUNDED fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES) and returns EWKT", async () => { - // The real NavigationIsochroneClient runs; only its fetcher is mocked. This is + // The real NavigationIsolineClient runs; only its fetcher is mocked. This is // the regression guard: the travel_time leg must NOT use the unbounded // fetchJSONGet (HTTP_TIMEOUT only) — it must go through fetchJSONGetWithLimit // with the SAME bounds as the WFS leg, so a 2-call travel_time stays capped. @@ -155,12 +155,12 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { }); }); -describe("proxy/transport · getDefaultGeometryIsochroneQueryDeps", () => { - it("resolves the isochrone through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { +describe("proxy/transport · getDefaultGeometryIsolineQueryDeps", () => { + it("resolves the isoline through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { const geometry = { type: "Polygon", coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] }; fetchJSONGetWithLimit.mockResolvedValue({ geometry }); - const result = await getDefaultGeometryIsochroneQueryDeps().getGeometry({ + const result = await getDefaultGeometryIsolineQueryDeps().getGeometry({ lon: 2.35, lat: 48.85, minutes: 15, diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index 40f4d976..1bdbc168 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -65,7 +65,7 @@ describe("generate-mcp-docs helpers", () => { { name: "gpf_get_feature_by_id_layer" }, { name: "gpf_count_features" }, { name: "gpf_get_features" }, - { name: "gpf_isochrone_layer" }, + { name: "gpf_isoline_layer" }, { name: "gpf_get_feature_by_id" }, { name: "adminexpress" }, { name: "gpf_get_features_layer" }, @@ -83,7 +83,7 @@ describe("generate-mcp-docs helpers", () => { "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", - "gpf_isochrone_layer", + "gpf_isoline_layer", "unknown_custom_tool", ]); }); diff --git a/test/tools/gpf-isochrone-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts similarity index 86% rename from test/tools/gpf-isochrone-layer.test.ts rename to test/tools/gpf-isoline-layer.test.ts index 32bb14ab..fb27a96a 100644 --- a/test/tools/gpf-isochrone-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -22,8 +22,8 @@ vi.doMock("../../src/config/env.js", async () => { }; }); -const { default: GpfIsochroneLayerTool } = await import( - "../../src/tools/GpfIsochroneLayerTool.js" +const { default: GpfIsolineLayerTool } = await import( + "../../src/tools/GpfIsolineLayerTool.js" ); function makeEnv(overrides: Partial): Env { @@ -36,7 +36,7 @@ function makeEnv(overrides: Partial): Env { } as Env; } -describe("Test GpfIsochroneLayerTool", () => { +describe("Test GpfIsolineLayerTool", () => { afterEach(() => { vi.clearAllMocks(); mockGetEnv.mockReset(); @@ -44,7 +44,7 @@ describe("Test GpfIsochroneLayerTool", () => { it("publishes the same minutes upper bound as runtime validation", () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const minutesSchema = (tool.toolDefinition.inputSchema.properties as Record) .minutes as { maximum?: number }; @@ -56,11 +56,11 @@ describe("Test GpfIsochroneLayerTool", () => { mockGetEnv.mockReturnValue( makeEnv({ PROXY_URL_SECRET: undefined, PROXY_PUBLIC_BASE_URL: undefined }), ); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -80,11 +80,11 @@ describe("Test GpfIsochroneLayerTool", () => { it("mints a data_url under stdio when a proxy is configured (gate is config, not transport)", async () => { mockGetEnv.mockReturnValue(makeEnv({ TRANSPORT_TYPE: "stdio" })); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -99,13 +99,13 @@ describe("Test GpfIsochroneLayerTool", () => { expect(payload.data_url).toContain("https://proxy.example.test/api/v1/proxy/"); }); - it("builds an opaque data_url that round-trips to the tagged isochrone params", async () => { + it("builds an opaque data_url that round-trips to the tagged isoline params", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -133,7 +133,7 @@ describe("Test GpfIsochroneLayerTool", () => { const token = url.pathname.slice("/api/v1/proxy/".length, -".json".length); const decoded = decodeToken(token, SECRET); expect(decoded).toEqual({ - kind: PROXY_TOKEN_KIND.isochrone, + kind: PROXY_TOKEN_KIND.isoline, lon: 2.337306, lat: 48.849319, profile: "car", @@ -143,11 +143,11 @@ describe("Test GpfIsochroneLayerTool", () => { it("rejects a time cost above the supported maximum", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -166,13 +166,13 @@ describe("Test GpfIsochroneLayerTool", () => { expect(textContent.text).toContain(`minutes: La valeur doit être au plus ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); }); - it("rejects an unknown key such as kind (strict isochrone surface)", async () => { + it("rejects an unknown key such as kind (strict isoline surface)", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { kind: PROXY_TOKEN_KIND.query, lon: 2.337306, @@ -189,11 +189,11 @@ describe("Test GpfIsochroneLayerTool", () => { it("rejects a missing profile", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, From 62761e0eca474794029e1a9c5dd943d5a675cbe2 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 17:10:20 +0200 Subject: [PATCH 3/5] feat: add isodistance capability to GpfIsolineLayer --- README.md | 2 +- docs/mcp-tools.md | 46 +++++++++++++++---------- src/gpf/itinerary.ts | 6 ++-- src/gpf/navigation.ts | 11 ++++-- src/proxy/execute.ts | 12 +++---- src/proxy/server.ts | 4 +-- src/proxy/transport.ts | 8 +++-- src/tools/GpfIsolineLayerTool.ts | 20 ++++++----- src/wfs/features.ts | 8 +++-- src/wfs/schema.ts | 34 +++++++++++++++---- test/gpf/navigation.test.ts | 46 +++++++++++++++++++++++-- test/proxy/execute.test.ts | 25 ++++++++------ test/proxy/server.test.ts | 6 ++-- test/proxy/transport.test.ts | 3 +- test/tools/gpf-isoline-layer.test.ts | 51 ++++++++++++++++++++++------ 15 files changed, 204 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 9d33aa79..2da189dc 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | Récupérer un objet par identifiant | `gpf_get_feature_by_id` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Charger une commune précise | | Télécharger le résultat d'une interrogation de couche GPF | `gpf_get_features_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier un résultat | | Télécharger un objet par identifiant | `gpf_get_feature_by_id_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Cartographier un objet | -| Télécharger une isochrone | `gpf_isoline_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | +| Télécharger une isochrone ou une isodistance | `gpf_isoline_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | ## Architecture en bref diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4e734824..93d3ce80 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1320,7 +1320,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." } }, "required": [ @@ -1645,7 +1645,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." } }, "required": [ @@ -1972,7 +1972,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." } }, "required": [ @@ -2221,24 +2221,26 @@ Code Source : [src/tools/GpfIsolineLayerTool.ts](../src/tools/GpfIsolineLayerToo ### Titre -Couche cartographiable d’isochrone GPF +Couche cartographiable d’isochrone / d'isodistance GPF ### Description du tool ``` -Interroge l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète. -Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal. -(source : Géoplateforme (calcul d'isochrone)). +Interroge une zone de desserte calculée autour d'un point : isochrone si `cost_type = "time"`, isodistance si `cost_type = "distance"`. +Renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète. +Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement, `cost_type` pour choisir le type de calcul et `cost_value` pour fixer le seuil maximal (en minutes si `time`, en mètres si `distance`). +(source : Géoplateforme (calcul d'isochrone / d'isodistance)). ``` ### Schéma d’entrée | Champ | Type | Requis | Description | | --- | --- | --- | --- | +| `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. | +| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"`. | | `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. | | `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. | -| `minutes` | number | oui | Temps de trajet maximal en minutes. Maximum : 600. | -| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`). Valeurs : car, pedestrian. | +| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. |
Schéma d’entrée brut @@ -2259,26 +2261,34 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac "maximum": 90, "description": "Latitude du point de départ en WGS84 `lon/lat`." }, - "minutes": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 600, - "description": "Temps de trajet maximal en minutes. Maximum : 600." - }, "profile": { "type": "string", "enum": [ "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." + }, + "cost_type": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance." + }, + "cost_value": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"`." } }, "required": [ "lon", "lat", - "minutes", - "profile" + "profile", + "cost_type", + "cost_value" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index da3d8aae..8ad26a4e 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -3,15 +3,15 @@ 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 { NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_RESOURCE } from "./navigation.js"; +import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_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 = NAVIGATION_ISOCHRONE_RESOURCE; +export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE; export const ITINERARY_PROFILES = NAVIGATION_PROFILES; -export const ITINERARY_METRICS = ["time", "distance"] as const; +export const ITINERARY_METRICS = NAVIGATION_METRICS; export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; export type ItineraryMetric = typeof ITINERARY_METRICS[number]; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 043a2e26..5a6dcf3f 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -7,14 +7,19 @@ import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; import type { GpfIsolineLayerInput } from "../wfs/schema.js"; -export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; +export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)"; export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone"; export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla"; // Upstream ceiling accepted by the GPF isochrone service for a time cost. export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600; export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; +export const NAVIGATION_METRICS = ["time", "distance"] as const; + export const TRAVEL_TIME_MAX_MINUTES = 120; +export type NavigationProfile = typeof NAVIGATION_PROFILES[number]; +export type NavigationMetrics = typeof NAVIGATION_METRICS[number]; + export class NavigationIsolineClient { constructor( private rateLimiter: RateLimiter, @@ -29,8 +34,8 @@ export class NavigationIsolineClient { resource: NAVIGATION_ISOLINE_RESOURCE, point: `${input.lon},${input.lat}`, direction: "departure", - costType: "time", - costValue: String(input.minutes), + costType: input.cost_type, + costValue: String(input.cost_value), profile: input.profile, timeUnit: "minute", distanceUnit: "meter", diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index 8835235f..d71bc807 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -318,16 +318,16 @@ export type GeometryIsolineQueryDeps = { }; /** - * Resolves an isochrone and returns it as a GeoJSON `FeatureCollection` with full - * geometry (for map rendering by MCP Carto). + * Resolves an isoline (isochrone or isodistance) and returns it as a + * GeoJSON `FeatureCollection` with full geometry (for map rendering by MCP Carto). * - * Counterpart of {@link runGeometryFeatureQuery} for the isochrone producer tool. + * Counterpart of {@link runGeometryFeatureQuery} for the isoline producer tool. * The request params are echoed into `properties` so the rendered layer carries * its own legend. * - * @param input Validated isochrone layer input (`{ lon, lat, profile, minutes }`). - * @param deps Injected isochrone geometry resolver. - * @returns The isochrone as a single GeoJSON FeatureCollection. + * @param input Validated isoline layer input (`{ lon, lat, profile, cost_type, cost_value }`). + * @param deps Injected isoline geometry resolver. + * @returns The isoline as a GeoJSON FeatureCollection. */ export async function runGeometryIsolineQuery( input: GpfIsolineLayerInput, diff --git a/src/proxy/server.ts b/src/proxy/server.ts index fc885a0b..0af7d89d 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -14,7 +14,7 @@ import { GPF_WFS_URL } from "../wfs/catalog.js"; import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, - gpfIsolineLayerInputObjectSchema, + gpfIsolineLayerInputSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; import { @@ -187,7 +187,7 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise { name = "gpf_isoline_layer"; - title = "Couche cartographiable d’isochrone GPF"; + title = "Couche cartographiable d’isochrone / d'isodistance GPF"; annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; description = GPF_ISOLINE_LAYER_TOOL_DESCRIPTION; protected outputSchemaShape = gpfGetFeaturesLayerOutputSchema; // The framework requires a plain Zod object here to publish a compatible input - // schema. The object schema is the full runtime contract here. + // schema. schema = gpfIsolineLayerInputObjectSchema; /** @@ -69,11 +71,11 @@ class GpfIsolineLayerTool extends BaseTool { } /** - * Mints the opaque proxy URL for the requested isochrone. No upstream call is - * made here: the isochrone itself is computed by the proxy when the `data_url` + * Mints the opaque proxy URL for the requested isoline. No upstream call is + * made here: the isoline itself is computed by the proxy when the `data_url` * is fetched. * - * @param input Validated isochrone layer input. + * @param input Validated isoline layer input. * @returns The `{ data_url }` payload carrying the opaque token. */ async execute(input: GpfIsolineLayerInput) { @@ -85,7 +87,7 @@ class GpfIsolineLayerTool extends BaseTool { ); } - const tokenParams = gpfIsolineLayerInputObjectSchema.parse(input); + const tokenParams = gpfIsolineLayerInputSchema.parse(input); logger.info(`[tool] execute ${this.name} ...`, { input: tokenParams, diff --git a/src/wfs/features.ts b/src/wfs/features.ts index dea01035..559a85bb 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -109,9 +109,13 @@ export async function resolveTravelTimeGeometry( return undefined; } - const { operator, ...parameters } = spatialFilter; + const { operator, minutes, ...parameters } = spatialFilter; - return await navigationIsolineClient.getIsoline(parameters); + return await navigationIsolineClient.getIsoline({ + ...parameters, + cost_type: "time", + cost_value: minutes, + }); } /** diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index e8b03d48..2725e8dc 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -11,9 +11,11 @@ import { z } from "zod"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; import { lonSchema, latSchema } from "../helpers/schemas.js"; import { - TRAVEL_TIME_MAX_MINUTES, + NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + type NavigationMetrics, + TRAVEL_TIME_MAX_MINUTES, } from "../gpf/navigation.js"; // --- Shared Constants --- @@ -97,7 +99,7 @@ const intersectsFeatureFilterSchema = z.object({ const navigationProfileSchema = z .enum(NAVIGATION_PROFILES) - .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."); + .describe("Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."); const travelTimeMinutesSchema = z .number() @@ -120,12 +122,28 @@ const travelTimeFilterSchema = isolinePointSchema profile: navigationProfileSchema, })).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); +const NavigationMetricsSchema = z + .enum(NAVIGATION_METRICS) + .describe("Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."); + const isolineCostValueSchema = z .number() .finite() .positive() - .max(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) - .describe(`Temps de trajet maximal en minutes. Maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); + .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}), et en mètres si \`cost_type = \"distance\"\`.`); + +function assertIsolineCostValue(input: { cost_type: NavigationMetrics; cost_value: number }, ctx: z.RefinementCtx) { + if (input.cost_type === "time" && input.cost_value > NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + type: "number", + inclusive: true, + path: ["cost_value"], + message: `Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`, + }); + } +} // --- Shared GPF Inputs --- @@ -456,11 +474,15 @@ export const gpfGetFeatureByIdLayerPublishedInputSchema = generatePublishedInput // --- `gpf_isoline_layer` (proxy) --- export const gpfIsolineLayerInputObjectSchema = isolinePointSchema.merge(z.object({ - minutes: isolineCostValueSchema, profile: navigationProfileSchema, + cost_type: NavigationMetricsSchema, + cost_value: isolineCostValueSchema, })).strict(); -export type GpfIsolineLayerInput = z.infer; +export const gpfIsolineLayerInputSchema = gpfIsolineLayerInputObjectSchema + .superRefine(assertIsolineCostValue); + +export type GpfIsolineLayerInput = z.infer; export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema); diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index 4d357896..2cf703cf 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -24,7 +24,8 @@ describe("NavigationIsolineClient", () => { const geometry = await client.getIsoline({ lon: 2.337306, lat: 48.849319, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "pedestrian", }); @@ -43,6 +44,46 @@ describe("NavigationIsolineClient", () => { expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("geojson"); }); + it("should build an isodistance request and return its GeoJSON geometry", async () => { + const urls: string[] = []; + const client = new NavigationIsolineClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async (url) => { + urls.push(url); + return { + geometry: { + type: "Polygon", + coordinates: [ + [[2.338306, 48.849753], [2.337306, 48.850142], [2.338306, 48.849753]], + ], + }, + }; + }, + ); + + const geometry = await client.getIsoline({ + lon: 2.337306, + lat: 48.849319, + cost_type: "distance", + cost_value: 1200, + profile: "car", + }); + + expect(geometry.type).toEqual("Polygon"); + const parsedUrl = new URL(urls[0]); + expect(parsedUrl.origin + parsedUrl.pathname).toEqual("https://data.geopf.fr/navigation/isochrone"); + expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-valhalla"); + expect(parsedUrl.searchParams.get("point")).toEqual("2.337306,48.849319"); + expect(parsedUrl.searchParams.get("direction")).toEqual("departure"); + expect(parsedUrl.searchParams.get("costType")).toEqual("distance"); + expect(parsedUrl.searchParams.get("costValue")).toEqual("1200"); + expect(parsedUrl.searchParams.get("profile")).toEqual("car"); + 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("geojson"); + }); + it("should reject responses without usable GeoJSON geometry", async () => { const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), @@ -52,7 +93,8 @@ describe("NavigationIsolineClient", () => { await expect(client.getIsoline({ lon: 2.337306, lat: 48.849319, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "car", })).rejects.toThrow("géométrie GeoJSON exploitable"); }); diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts index 4ad25010..15867f0b 100644 --- a/test/proxy/execute.test.ts +++ b/test/proxy/execute.test.ts @@ -414,12 +414,15 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => { }); describe("proxy/execute · runGeometryIsolineQuery", () => { - const isochroneInput = { lon: 2.35, lat: 48.85, profile: "pedestrian" as const, minutes: 15 }; - const isochroneGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] }; + const isolineInput = { + lon: 2.35, lat: 48.85, profile: "pedestrian" as const, + cost_type: "time" as const, cost_value: 15, + }; + const isolineGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] }; - it("returns the isochrone as a FeatureCollection", async () => { - const result = await runGeometryIsolineQuery(isochroneInput, { - getGeometry: async () => isochroneGeometry, + it("returns the isoline as a FeatureCollection", async () => { + const result = await runGeometryIsolineQuery(isolineInput, { + getGeometry: async () => isolineGeometry, }); expect(result).toEqual({ @@ -427,23 +430,23 @@ describe("proxy/execute · runGeometryIsolineQuery", () => { features: [ { type: "Feature", - geometry: isochroneGeometry, - properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", minutes: 15 }, + geometry: isolineGeometry, + properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 }, } ] }); }); - it("maps the layer input onto the isochrone client input", async () => { + it("maps the layer input onto the isoline client input", async () => { const calls: unknown[] = []; - await runGeometryIsolineQuery(isochroneInput, { + await runGeometryIsolineQuery(isolineInput, { getGeometry: async (input) => { calls.push(input); - return isochroneGeometry; + return isolineGeometry; }, }); - expect(calls).toEqual([{ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }]); + expect(calls).toEqual([{ lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" }]); }); }); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 5a85d42d..a4abc92f 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -60,7 +60,8 @@ function validIsolineToken() { lon: 2.35, lat: 48.85, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }, KEY); } @@ -234,7 +235,8 @@ describe("proxy/server", () => { lon: 2.35, lat: 48.85, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }); }); diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index a0e2bfac..2aef5981 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -163,7 +163,8 @@ describe("proxy/transport · getDefaultGeometryIsolineQueryDeps", () => { const result = await getDefaultGeometryIsolineQueryDeps().getGeometry({ lon: 2.35, lat: 48.85, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "car", }); diff --git a/test/tools/gpf-isoline-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts index fb27a96a..7b2c8186 100644 --- a/test/tools/gpf-isoline-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -42,14 +42,40 @@ describe("Test GpfIsolineLayerTool", () => { mockGetEnv.mockReset(); }); - it("publishes the same minutes upper bound as runtime validation", () => { + it("publishes the time-only cost limit without capping distance costs", () => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); - const minutesSchema = (tool.toolDefinition.inputSchema.properties as Record) - .minutes as { maximum?: number }; + const properties = tool.toolDefinition.inputSchema.properties as Record; + const costValueSchema = properties.cost_value as { description?: string; maximum?: number }; - expect(minutesSchema.maximum).toBe(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES); + expect(properties).not.toHaveProperty("minutes"); + expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES})`); + expect(costValueSchema.maximum).toBeUndefined(); + }); + + it.each([ + { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES }, + { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }, + ])("accepts $cost_type cost at $cost_value", async ({ cost_type, cost_value }) => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsolineLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isoline_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + cost_type, + cost_value, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(response.structuredContent).toEqual({ data_url: expect.any(String) }); }); it("fails fast when no proxy is configured", async () => { @@ -65,7 +91,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }, }, }); @@ -89,7 +116,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }, }, }); @@ -110,7 +138,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "car", - minutes: 60, + cost_type: "distance", + cost_value: 1200, }, }, }); @@ -137,7 +166,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "car", - minutes: 60, + cost_type: "distance", + cost_value: 1200, }); }); @@ -152,7 +182,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, + cost_type: "time", + cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, }, }, }); @@ -163,7 +194,7 @@ describe("Test GpfIsolineLayerTool", () => { if (textContent.type !== "text") { throw new Error("expected text content"); } - expect(textContent.text).toContain(`minutes: La valeur doit être au plus ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); + expect(textContent.text).toContain(`cost_value: Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`); }); it("rejects an unknown key such as kind (strict isoline surface)", async () => { From 1142fa5b320610448bb6128f406b59c71aa311e8 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 14:44:55 +0200 Subject: [PATCH 4/5] feat: add NAVIGATION_MAX_DISTANCE_METERS --- docs/mcp-tools.md | 4 +-- src/gpf/navigation.ts | 5 +-- src/wfs/schema.ts | 20 ++++++++--- test/proxy/server.test.ts | 5 +-- test/tools/gpf-isoline-layer.test.ts | 54 +++++++++++++++++++++------- 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 93d3ce80..adf6bb67 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -2237,7 +2237,7 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. | -| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"`. | +| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"` (maximum : 50000). | | `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. | | `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. | | `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. | @@ -2280,7 +2280,7 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac "cost_value": { "type": "number", "exclusiveMinimum": 0, - "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"`." + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"` (maximum : 50000)." } }, "required": [ diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 5a6dcf3f..b60b7a06 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -10,8 +10,9 @@ import type { GpfIsolineLayerInput } from "../wfs/schema.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)"; export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone"; export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla"; -// Upstream ceiling accepted by the GPF isochrone service for a time cost. -export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600; +// Upstream ceilings accepted by the GPF isochrone service, per cost type. +export const NAVIGATION_ISOCHRONE_MAX_MINUTES = 600; +export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000; export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const NAVIGATION_METRICS = ["time", "distance"] as const; diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 2725e8dc..450fa648 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -13,7 +13,8 @@ import { lonSchema, latSchema } from "../helpers/schemas.js"; import { NAVIGATION_METRICS, NAVIGATION_PROFILES, - NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + NAVIGATION_ISOCHRONE_MAX_MINUTES, + NAVIGATION_ISODISTANCE_MAX_METERS, type NavigationMetrics, TRAVEL_TIME_MAX_MINUTES, } from "../gpf/navigation.js"; @@ -130,17 +131,26 @@ const isolineCostValueSchema = z .number() .finite() .positive() - .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}), et en mètres si \`cost_type = \"distance\"\`.`); + .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES}), et en mètres si \`cost_type = \"distance\"\` (maximum : ${NAVIGATION_ISODISTANCE_MAX_METERS}).`); + +// One max per cost type: `cost_value` is minutes for `time` and meters for +// `distance`, so the ceiling can only be checked once `cost_type` is known. +const ISOLINE_COST_LIMITS: Record = { + time: { max: NAVIGATION_ISOCHRONE_MAX_MINUTES, name: "temps", unit: "minutes" }, + distance: { max: NAVIGATION_ISODISTANCE_MAX_METERS, name: "distance", unit: "mètres" }, +}; function assertIsolineCostValue(input: { cost_type: NavigationMetrics; cost_value: number }, ctx: z.RefinementCtx) { - if (input.cost_type === "time" && input.cost_value > NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) { + const { max, name, unit } = ISOLINE_COST_LIMITS[input.cost_type]; + + if (input.cost_value > max) { ctx.addIssue({ code: z.ZodIssueCode.too_big, - maximum: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + maximum: max, type: "number", inclusive: true, path: ["cost_value"], - message: `Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`, + message: `Le coût maximal en ${name} ne peut pas dépasser ${max} ${unit}.`, }); } } diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index a4abc92f..ca96dd8d 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -8,7 +8,7 @@ import { resetEnv } from "../../src/config/env"; import { PROXY_TOKEN_KIND } from "../../src/wfs/schema"; import { FeatureNotFoundError, FeatureCardinalityError } from "../../src/wfs/byId"; import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/http"; -import { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation"; +import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation"; // Mock the proxy engine + transport so the server is exercised WITHOUT network. const runGeometryFeatureQuery = vi.fn(); @@ -241,7 +241,8 @@ describe("proxy/server", () => { }); it.each([ - ["minutes above the maximum", { minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }], + ["minutes above the maximum", { minutes: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 }], + ["distance above the maximum", { minutes: NAVIGATION_ISODISTANCE_MAX_METERS + 1 }], ["an unknown key", { typename: "BDTOPO_V3:batiment" }], ])("400 when an isochrone token carries %s", async (_name, overrides) => { const badToken = encodeToken({ diff --git a/test/tools/gpf-isoline-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts index 7b2c8186..a35fae99 100644 --- a/test/tools/gpf-isoline-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -2,9 +2,9 @@ import { vi, describe, it, expect, afterEach } from "vitest"; import type { Env } from "../../src/config/env.js"; import { decodeToken } from "../../src/proxy/token.js"; -import { PROXY_TOKEN_KIND } from "../../src/wfs/schema.js"; -import { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation.js"; -import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema.js"; +import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation.js"; +import { PROXY_TOKEN_KIND, gpfIsolineLayerInputSchema } from "../../src/wfs/schema.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema"; const SECRET_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const SECRET = Buffer.from(SECRET_HEX, "hex"); @@ -50,13 +50,13 @@ describe("Test GpfIsolineLayerTool", () => { const costValueSchema = properties.cost_value as { description?: string; maximum?: number }; expect(properties).not.toHaveProperty("minutes"); - expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES})`); + expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES})`); expect(costValueSchema.maximum).toBeUndefined(); }); it.each([ - { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES }, - { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }, + { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES }, + { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 }, ])("accepts $cost_type cost at $cost_value", async ({ cost_type, cost_value }) => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); @@ -139,7 +139,7 @@ describe("Test GpfIsolineLayerTool", () => { lat: 48.849319, profile: "car", cost_type: "distance", - cost_value: 1200, + cost_value: 50_000, }, }, }); @@ -167,11 +167,14 @@ describe("Test GpfIsolineLayerTool", () => { lat: 48.849319, profile: "car", cost_type: "distance", - cost_value: 1200, + cost_value: 50_000, }); }); - it("rejects a time cost above the supported maximum", async () => { + it.each([ + { type: "time", cost: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 }, + { type: "distance", cost: NAVIGATION_ISODISTANCE_MAX_METERS + 1 }, + ])("rejects a $type cost above the supported maximum ($cost)", async ({ type, cost }) => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); @@ -182,8 +185,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - cost_type: "time", - cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, + cost_type: type, + cost_value: cost, }, }, }); @@ -194,7 +197,9 @@ describe("Test GpfIsolineLayerTool", () => { if (textContent.type !== "text") { throw new Error("expected text content"); } - expect(textContent.text).toContain(`cost_value: Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`); + const nom = type === "time" ? "temps" : type; + const units = type === "time" ? "minutes" : "mètres"; + expect(textContent.text).toContain(`cost_value: Le coût maximal en ${nom} ne peut pas dépasser ${cost-1} ${units}.`); }); it("rejects an unknown key such as kind (strict isoline surface)", async () => { @@ -236,4 +241,29 @@ describe("Test GpfIsolineLayerTool", () => { expect(response.isError).toBe(true); expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'profile' est requis."); }); + + it("emits a `too_big` cost_value issue for an out-of-range distance", () => { + const result = gpfIsolineLayerInputSchema.safeParse({ + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + cost_type: "distance", + cost_value: 50_001, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("expected parse failure"); + } + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "too_big", + path: ["cost_value"], + message: expect.stringContaining("ne peut pas dépasser"), + }), + ]), + ); + }); }); From 94d9361971506a48d8a05bdca27a9d60ce77d768 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 17:05:14 +0200 Subject: [PATCH 5/5] feat: change time_travel_filter into isoline_filter reusing GpfIsolineLayerTool shared navigation logic --- docs/config.md | 4 +- docs/mcp-tools.md | 114 ++++++++++++++++----------- src/config/env.ts | 4 +- src/gpf/navigation.ts | 2 - src/helpers/http.ts | 2 +- src/proxy/execute.ts | 20 ++--- src/proxy/transport.ts | 28 +++---- src/tools/GpfCountFeaturesTool.ts | 3 +- src/tools/GpfGetFeaturesLayerTool.ts | 4 +- src/tools/GpfGetFeaturesTool.ts | 3 +- src/wfs/features.ts | 20 ++--- src/wfs/queryPreparation.ts | 4 +- src/wfs/schema.ts | 93 ++++++++++++---------- src/wfs/spatialExtras.ts | 6 +- test/gpf/navigation.test.ts | 2 +- test/proxy/execute.test.ts | 40 +++++----- test/proxy/server.test.ts | 15 +++- test/proxy/token.test.ts | 4 +- test/proxy/transport.test.ts | 20 ++--- test/tools/wfs/countFeatures.test.ts | 7 +- test/tools/wfs/getFeatures.test.ts | 15 ++-- test/wfs/queryPreparation.test.ts | 7 +- test/wfs/spatialFilter.test.ts | 52 ++++++++---- 23 files changed, 266 insertions(+), 203 deletions(-) diff --git a/docs/config.md b/docs/config.md index a597255b..c01e37f1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,8 +28,8 @@ | `PROXY_ENDPOINT` | Chemin exposé par le proxy geodata. | `/api/v1/proxy` | | `PROXY_PUBLIC_BASE_URL` | URL de base publiquement joignable du proxy, utilisée pour construire la `data_url` absolue transmise à Carto. Derrière un reverse-proxy, elle diffère de l'adresse d'écoute ; en développement local, c'est typiquement `http://localhost:3002`. Requise avec `PROXY_URL_SECRET` pour activer les tools `*_layer`. | Aucune | | `GPF_WFS_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le WFS, distincte de `GPF_WFS_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `10` | -| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `travel_time` et `gpf_isochrone_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` | -| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isochrone_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` | +| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `isoline` et `gpf_isochrone_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` | +| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `isoline`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isochrone_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` | ## Génération de `PROXY_URL_SECRET` diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index adf6bb67..998e2bfb 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1084,14 +1084,15 @@ Lecture d’objets GPF ``` Interroge un type GPF et renvoie des résultats structurés (propriétés attributaires ; les géométries ne sont pas incluses). Pour obtenir une couche cartographiable, utiliser `gpf_get_features_layer`. -Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial. +Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `isoline_filter`) pour le spatial. Utiliser `spatial_extras` pour obtenir des mesures calculées sur la géométrie (`centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`). Elles portent uniquement sur les objets renvoyés. Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`. Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`. Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`. Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`. Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`. -Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }` pour les objets atteignables en 15 minutes à pied depuis ce point. +Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point. +Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "car", cost_type: "distance", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point. ⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé. **OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_describe_type` **provoquera une erreur.** @@ -1102,14 +1103,14 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. | -| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. | +| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux. | | `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. | | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | +| `isoline_filter` | object | non | Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux. | | `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. | | `order_by` | array | non | Liste ordonnée des critères de tri. | | `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. | | `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.
`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter_center` est la distance (en m) entre le centre du filtre spatial et le point le plus proche de l'objet renvoyé, `0` si l'objet contient ce centre. Ce centre est le point de `dwithin_point_filter`, le point de départ de `travel_time_filter`, le centre de la boîte de `bbox_filter` et le centroïde (moyenne des sommets) de l'objet de référence de `intersects_feature_filter`.
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone ou objet de référence surfacique). Elle vaut `null` si l'objet renvoyé n'a pas de partie surfacique, et `0` si l'objet ne recouvre pas le filtre.
`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.
Les `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, les N plus proches) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit` ou restreindre le filtre spatial. Pour les N plus proches d'un point, utiliser `dwithin_point_filter` avec `distance_to_filter_center`, trier sur cette distance et élargir `distance_m` s'il y a moins de N objets.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. | -| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. | | `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. | | `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. | @@ -1270,7 +1271,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "distance_m" ], "additionalProperties": false, - "description": "Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux." }, "intersects_feature_filter": { "type": "object", @@ -1293,7 +1294,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "additionalProperties": false, "description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux." }, - "travel_time_filter": { + "isoline_filter": { "type": "object", "properties": { "lon": { @@ -1308,12 +1309,6 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "maximum": 90, "description": "Latitude du point de départ en WGS84 `lon/lat`." }, - "minutes": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 120, - "description": "Temps de trajet maximal en minutes. Maximum : 120." - }, "profile": { "type": "string", "enum": [ @@ -1321,16 +1316,30 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "pedestrian" ], "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." + }, + "cost_type": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance." + }, + "cost_value": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 120), et en mètres si `cost_type = \"distance\"` (maximum : 50000)." } }, "required": [ "lon", "lat", - "minutes", - "profile" + "profile", + "cost_type", + "cost_value" ], "additionalProperties": false, - "description": "Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux." }, "limit": { "type": "integer", @@ -1419,7 +1428,7 @@ Couche cartographiable d’objets GPF ``` Interroge un type GPF et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec les géométries complètes. À utiliser dès qu'il faut **afficher / cartographier** des objets GPF. Pour des attributs sans géométrie, utiliser `gpf_get_features`. -Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial. +Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `isoline_filter`) pour le spatial. **OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Les noms de propriétés ne peuvent pas être devinés. ``` @@ -1428,13 +1437,13 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. | -| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. | +| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux. | | `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. | | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | +| `isoline_filter` | object | non | Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux. | | `limit` | integer | non | Nombre maximum d'objets à cartographier. Valeur par défaut : 5000 (plafond du service). Réduire pour alléger la carte. Maximum : 5000. Une requête produisant plus de 5000 objets sera tronquée. Valeur par défaut : 5000. | | `order_by` | array | non | Liste ordonnée des critères de tri. | | `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. | -| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. | | `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. | | `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. | @@ -1595,7 +1604,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "distance_m" ], "additionalProperties": false, - "description": "Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux." }, "intersects_feature_filter": { "type": "object", @@ -1618,7 +1627,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "additionalProperties": false, "description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux." }, - "travel_time_filter": { + "isoline_filter": { "type": "object", "properties": { "lon": { @@ -1633,12 +1642,6 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "maximum": 90, "description": "Latitude du point de départ en WGS84 `lon/lat`." }, - "minutes": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 120, - "description": "Temps de trajet maximal en minutes. Maximum : 120." - }, "profile": { "type": "string", "enum": [ @@ -1646,16 +1649,30 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "pedestrian" ], "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." + }, + "cost_type": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance." + }, + "cost_value": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 120), et en mètres si `cost_type = \"distance\"` (maximum : 50000)." } }, "required": [ "lon", "lat", - "minutes", - "profile" + "profile", + "cost_type", + "cost_value" ], "additionalProperties": false, - "description": "Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux." }, "order_by": { "type": "array", @@ -1750,13 +1767,14 @@ Décompte d’objets GPF ``` Interroge un type GPF et renvoie le nombre de résultats obtenus. -Utiliser `where` pour filtrer et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial. +Utiliser `where` pour filtrer et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `isoline_filter`) pour le spatial. Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`. Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`. Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`. Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`. Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`. -Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }` pour les objets atteignables en 15 minutes à pied depuis ce point. +Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point. +Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "car", cost_type: "distance", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point. ⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé. **OBLIGATOIRE dès que `where` est utilisé : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Un comptage par `typename` et filtre spatial seul (sans `where`) ne référence aucun nom de propriété et ne nécessite pas cet appel préalable. Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute clause `where` sans appel préalable à `gpf_describe_type` **provoquera une erreur.** @@ -1767,10 +1785,10 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. | -| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. | +| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux. | | `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. | | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | -| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. | +| `isoline_filter` | object | non | Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux. | | `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. | | `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. | @@ -1922,7 +1940,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "distance_m" ], "additionalProperties": false, - "description": "Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux." }, "intersects_feature_filter": { "type": "object", @@ -1945,7 +1963,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "additionalProperties": false, "description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux." }, - "travel_time_filter": { + "isoline_filter": { "type": "object", "properties": { "lon": { @@ -1960,12 +1978,6 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "maximum": 90, "description": "Latitude du point de départ en WGS84 `lon/lat`." }, - "minutes": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 120, - "description": "Temps de trajet maximal en minutes. Maximum : 120." - }, "profile": { "type": "string", "enum": [ @@ -1973,16 +1985,30 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "pedestrian" ], "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." + }, + "cost_type": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance." + }, + "cost_value": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 120), et en mètres si `cost_type = \"distance\"` (maximum : 50000)." } }, "required": [ "lon", "lat", - "minutes", - "profile" + "profile", + "cost_type", + "cost_value" ], "additionalProperties": false, - "description": "Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux." } }, "required": [ diff --git a/src/config/env.ts b/src/config/env.ts index a61751fb..ff016cb2 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -225,12 +225,12 @@ const envSchema = z.object({ // Dedicated upstream WFS rate limit for the proxy, separate from GPF_WFS_RATE_LIMIT. // Both counters hit the same IGN service, so split one allowance across them. GPF_WFS_PROXY_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(10)), - // Dedicated isochrone rate limit for the proxy's travel_time leg, separate from + // Dedicated isochrone rate limit for the proxy's isoline leg, separate from // GPF_NAVIGATION_RATE_LIMIT. Both counters hit the same IGN service, so split one // allowance across them. GPF_NAVIGATION_PROXY_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(5)), // Upstream timeout (seconds) for the proxy's WFS AND isochrone calls, shorter than - // HTTP_TIMEOUT so a 2-call intersects_feature/travel_time stays under the + // HTTP_TIMEOUT so a 2-call intersects_feature/isoline stays under the // browser/Carto fetch timeout. PROXY_UPSTREAM_TIMEOUT: z.preprocess(emptyToUndefined, positiveNumberSchema.default(10)), }); diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index b60b7a06..add2eb0b 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -16,8 +16,6 @@ export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000; export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const NAVIGATION_METRICS = ["time", "distance"] as const; -export const TRAVEL_TIME_MAX_MINUTES = 120; - export type NavigationProfile = typeof NAVIGATION_PROFILES[number]; export type NavigationMetrics = typeof NAVIGATION_METRICS[number]; diff --git a/src/helpers/http.ts b/src/helpers/http.ts index bdd2e739..481d1b0a 100644 --- a/src/helpers/http.ts +++ b/src/helpers/http.ts @@ -402,7 +402,7 @@ export async function fetchJSONPostWithLimit( /** * GET variant of the size-bounded transport, parsing the bounded body as JSON. - * Used by the proxy's isochrone leg so a `travel_time` layer request goes + * Used by the proxy's isochrone leg so an `isoline` layer request goes * through the SAME `PROXY_UPSTREAM_TIMEOUT` + `PROXY_MAX_RESPONSE_BYTES` bounds * as its WFS leg, instead of the unbounded `HTTP_TIMEOUT`-only `fetchJSONGet`. * diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index d71bc807..35f6f276 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -3,7 +3,7 @@ * * `runGeometryFeatureQuery` (entry point) compiles and runs the layer query; * `resolveReferenceGeometry` (internal helper) resolves the reference geometry - * for `intersects_feature` / `travel_time` filters. + * for `intersects_feature` / `isoline` filters. * * Unlike the LLM-facing `executeQueryFeatures` (which strips geometry to `null` * via `postProcessFeatureCollection` to save tokens), the proxy needs the OPPOSITE: a @@ -57,21 +57,21 @@ export type WfsClientLike = { }; /** - * Resolves the isochrone geometry for a `travel_time` filter. Injected by + * Resolves the isochrone/isodistance geometry for an `isoline` filter. Injected by * the HTTP layer (backed by the navigation/isochrone service). Required, because - * `travel_time` is part of the `gpf_get_features` query contract the proxy must + * `isoline` is part of the `gpf_get_features` query contract the proxy must * honour — it is not an optional capability. The engine stays isochrone-agnostic * (pure, network-free, testable), exactly as it is for `wfsClient`. */ -export type TravelTimeResolver = (input: GpfGetFeaturesInput) => Promise; +export type IsolineResolver = (input: GpfGetFeaturesInput) => Promise; /** * Dependencies injected into {@link runGeometryFeatureQuery}. */ export type GeometryFeatureQueryDeps = { wfsClient: WfsClientLike; - /** Isochrone resolver, invoked only for `travel_time` filters. */ - resolveTravelTime: TravelTimeResolver; + /** Isochrone/isodistance resolver, invoked only for `isoline` filters. */ + resolveIsoline: IsolineResolver; }; // --- Internal Helpers --- @@ -161,10 +161,10 @@ async function resolveReferenceGeometry( ): Promise { const spatialFilter = getSpatialFilter(input); - // travel_time is resolved by the injected isochrone resolver, up front, so + // isoline is resolved by the injected isochrone/isodistance resolver, up front, so // compileQueryParts never sees an unresolved ref (symmetric to intersects_feature). - if (spatialFilter?.operator === "travel_time") { - return deps.resolveTravelTime(input); + if (spatialFilter?.operator === "isoline") { + return deps.resolveIsoline(input); } if (!spatialFilter || spatialFilter.operator !== "intersects_feature") { @@ -190,7 +190,7 @@ async function resolveReferenceGeometry( * @param input Validated layer query input (same shape as `gpf_get_features` * minus the LLM-only `spatial_extras` knob). * @param deps Injected WFS client (catalog + execution) and isochrone resolver - * (always required; invoked only for `travel_time` filters). + * (always required; invoked only for `isoline` filters). * @returns The raw WFS FeatureCollection, geometry preserved. */ export async function runGeometryFeatureQuery( diff --git a/src/proxy/transport.ts b/src/proxy/transport.ts index ba036437..53cdaccd 100644 --- a/src/proxy/transport.ts +++ b/src/proxy/transport.ts @@ -17,7 +17,7 @@ import { getSpatialFilter } from "../wfs/queryPreparation.js"; import type { GpfGetFeaturesInput } from "../wfs/schema.js"; import { NavigationIsolineClient } from "../gpf/navigation.js"; import type { - TravelTimeResolver, + IsolineResolver, GeometryFeatureQueryDeps, GeometryFeatureByIdQueryDeps, GeometryIsolineQueryDeps, @@ -92,7 +92,7 @@ let cachedProxyIsolineClient: NavigationIsolineClient | undefined; * (`PROXY_UPSTREAM_TIMEOUT` + `PROXY_MAX_RESPONSE_BYTES`) and its own * `GPF_NAVIGATION_PROXY` rate limiter — NOT the default `navigationIsolineClient` * singleton, which uses the unbounded `HTTP_TIMEOUT`-only `fetchJSONGet`. This - * keeps both upstream legs of a `travel_time` layer request under the same bounds, + * keeps both upstream legs of an `isoline` layer request under the same bounds, * so its worst case matches `intersects_feature` (2 × PROXY_UPSTREAM_TIMEOUT). * Lazily built so the bounds are read from a fully-parsed environment. */ @@ -104,34 +104,30 @@ function getProxyIsolineClient(): NavigationIsolineClient { return cachedProxyIsolineClient; } -// --- Reference-geometry resolver (travel_time / isoline) --- +// --- Reference-geometry resolver --- /** - * Reference-geometry resolver for the `travel_time` spatial filter: turns the + * Reference-geometry resolver for the `isoline` spatial filter: turns the * isoline into a reference geometry that is fed INTO the WFS query — the * sibling of `intersects_feature`'s reference-geometry resolution * (`resolveFeatureGeometry`). It does NOT fetch features itself (that is the * WFS transport's job). Backed by the proxy isoline client (bounded fetch + * `GPF_NAVIGATION_PROXY` rate limiter), and injected into `runGeometryFeatureQuery` - * so it only fires for travel_time inputs. + * so it only fires for isoline inputs. */ -export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async ( +export const resolveProxyIsolineGeometry: IsolineResolver = async ( input: GpfGetFeaturesInput, ): Promise => { const spatialFilter = getSpatialFilter(input); - if (spatialFilter?.operator !== "travel_time") { - // Guarded by the caller (runGeometryFeatureQuery only calls this for travel_time); + if (spatialFilter?.operator !== "isoline") { + // Guarded by the caller (runGeometryFeatureQuery only calls this for isoline); // defensive check keeps the type narrow. - throw new Error("resolveProxyTravelTimeGeometry appelé sans filtre `travel_time`."); + throw new Error("resolveProxyIsolineGeometry appelé sans filtre `isoline`."); } - const { operator, minutes, ...parameters } = spatialFilter; + const { operator, ...parameters } = spatialFilter; - return await getProxyIsolineClient().getIsoline({ - ...parameters, - cost_type: "time", - cost_value: minutes, - }); + return await getProxyIsolineClient().getIsoline(parameters); }; // --- Default Engine Dependencies --- @@ -146,7 +142,7 @@ export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async ( export function getDefaultGeometryFeatureQueryDeps(): GeometryFeatureQueryDeps { return { wfsClient: getProxyWfsClient(), - resolveTravelTime: resolveProxyTravelTimeGeometry, + resolveIsoline: resolveProxyIsolineGeometry, }; } diff --git a/src/tools/GpfCountFeaturesTool.ts b/src/tools/GpfCountFeaturesTool.ts index fcd91205..924ab257 100644 --- a/src/tools/GpfCountFeaturesTool.ts +++ b/src/tools/GpfCountFeaturesTool.ts @@ -31,7 +31,8 @@ const GPF_COUNT_FEATURES_TOOL_DESCRIPTION = [ "Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.", "Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.", "Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.", - "Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.", + "Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"pedestrian\", cost_type: \"time\", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.", + "Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"car\", cost_type: \"distance\", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.", "⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.", "**OBLIGATOIRE dès que `where` est utilisé : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Un comptage par `typename` et filtre spatial seul (sans `where`) ne référence aucun nom de propriété et ne nécessite pas cet appel préalable.", "Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute clause `where` sans appel préalable à `gpf_describe_type` **provoquera une erreur.**", diff --git a/src/tools/GpfGetFeaturesLayerTool.ts b/src/tools/GpfGetFeaturesLayerTool.ts index b97c2bae..f2db7ce1 100644 --- a/src/tools/GpfGetFeaturesLayerTool.ts +++ b/src/tools/GpfGetFeaturesLayerTool.ts @@ -140,12 +140,12 @@ class GpfGetFeaturesLayerTool extends BaseTool { getGeometryName(referenceType); } - // We must NOT resolve the reference geometry for intersects_feature/travel_time + // We must NOT resolve the reference geometry for intersects_feature/isoline // (that IS a network call) — those geometries resolve at fetch time on the // proxy — so we pass a placeholder ref just to let compileQueryParts validate // the attribute side (select/where/order_by). const needsResolvedRef = - spatialFilter?.operator === "intersects_feature" || spatialFilter?.operator === "travel_time"; + spatialFilter?.operator === "intersects_feature" || spatialFilter?.operator === "isoline"; compileQueryParts( compiledInput, featureType, diff --git a/src/tools/GpfGetFeaturesTool.ts b/src/tools/GpfGetFeaturesTool.ts index f1a83e0c..4aac90b7 100644 --- a/src/tools/GpfGetFeaturesTool.ts +++ b/src/tools/GpfGetFeaturesTool.ts @@ -32,7 +32,8 @@ const GPF_GET_FEATURES_TOOL_DESCRIPTION = [ "Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.", "Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.", "Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.", - "Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.", + "Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"pedestrian\", cost_type: \"time\", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.", + "Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"car\", cost_type: \"distance\", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.", "⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.", "**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**", "Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_describe_type` **provoquera une erreur.**", diff --git a/src/wfs/features.ts b/src/wfs/features.ts index 559a85bb..6f11c4ae 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -96,26 +96,22 @@ export async function resolveIntersectsFeatureGeometry( } /** - * Resolves the travel-time isochrone geometry when `travel_time_filter` is used. + * Resolves the isoline geometry (isochrone or isodistance) when `isoline_filter` is used. * * @param input Normalized tool input. - * @returns The resolved isochrone geometry, or `undefined` when no travel-time filter is requested. + * @returns The resolved isoline geometry, or `undefined` when no isoline filter is requested. */ -export async function resolveTravelTimeGeometry( +export async function resolveIsolineGeometry( input: GpfQueryFeaturesInput, ): Promise { const spatialFilter = getSpatialFilter(input); - if (!spatialFilter || spatialFilter.operator !== "travel_time") { + if (!spatialFilter || spatialFilter.operator !== "isoline") { return undefined; } - const { operator, minutes, ...parameters } = spatialFilter; + const { operator, ...parameters } = spatialFilter; - return await navigationIsolineClient.getIsoline({ - ...parameters, - cost_type: "time", - cost_value: minutes, - }); + return await navigationIsolineClient.getIsoline(parameters); } /** @@ -132,8 +128,8 @@ export async function resolveSpatialFilterGeometry( switch (spatialFilter?.operator) { case "intersects_feature": return resolveIntersectsFeatureGeometry(input); - case "travel_time": - return resolveTravelTimeGeometry(input); + case "isoline": + return resolveIsolineGeometry(input); default: return undefined; } diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts index d3cc9a45..f01d9e0f 100644 --- a/src/wfs/queryPreparation.ts +++ b/src/wfs/queryPreparation.ts @@ -209,9 +209,9 @@ export function compileQueryParts( } fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef)); break; - case "travel_time": + case "isoline": if (!resolvedGeometryRef) { - throw new Error("Le filtre spatial `travel_time` exige la résolution préalable de la géométrie d'isochrone."); + throw new Error("Le filtre spatial `isoline` exige la résolution préalable de la géométrie d'isochrone/isodistance."); } fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef)); break; diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 450fa648..282a34fc 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -16,7 +16,6 @@ import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS, type NavigationMetrics, - TRAVEL_TIME_MAX_MINUTES, } from "../gpf/navigation.js"; // --- Shared Constants --- @@ -102,14 +101,7 @@ const navigationProfileSchema = z .enum(NAVIGATION_PROFILES) .describe("Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."); -const travelTimeMinutesSchema = z - .number() - .finite() - .positive() - .max(TRAVEL_TIME_MAX_MINUTES) - .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`); - -// Departure point of an isochrone. Flat `lon`/`lat`, exactly like every spatial +// Departure point of an isoline. Flat `lon`/`lat`, exactly like every spatial // filter (`intersects_point_filter`, `dwithin_point_filter`, ...), so the LLM sees // one point convention across the whole surface. const isolinePointSchema = z.object({ @@ -117,44 +109,63 @@ const isolinePointSchema = z.object({ lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), }).strict(); -const travelTimeFilterSchema = isolinePointSchema - .merge(z.object({ - minutes: travelTimeMinutesSchema, - profile: navigationProfileSchema, -})).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); - -const NavigationMetricsSchema = z +const navigationMetricsSchema = z .enum(NAVIGATION_METRICS) .describe("Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."); -const isolineCostValueSchema = z - .number() - .finite() - .positive() - .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES}), et en mètres si \`cost_type = \"distance\"\` (maximum : ${NAVIGATION_ISODISTANCE_MAX_METERS}).`); +type CostLimits = Record // One max per cost type: `cost_value` is minutes for `time` and meters for // `distance`, so the ceiling can only be checked once `cost_type` is known. -const ISOLINE_COST_LIMITS: Record = { +const ISOLINE_COST_LIMITS: CostLimits = { time: { max: NAVIGATION_ISOCHRONE_MAX_MINUTES, name: "temps", unit: "minutes" }, distance: { max: NAVIGATION_ISODISTANCE_MAX_METERS, name: "distance", unit: "mètres" }, }; -function assertIsolineCostValue(input: { cost_type: NavigationMetrics; cost_value: number }, ctx: z.RefinementCtx) { - const { max, name, unit } = ISOLINE_COST_LIMITS[input.cost_type]; +// Lower time limit for `isoline_filter`, for performance. +const ISOLINE_FILTER_MAX_TIME_MINUTES = 120; - if (input.cost_value > max) { - ctx.addIssue({ - code: z.ZodIssueCode.too_big, - maximum: max, - type: "number", - inclusive: true, - path: ["cost_value"], - message: `Le coût maximal en ${name} ne peut pas dépasser ${max} ${unit}.`, - }); +const ISOLINE_FILTER_COST_LIMITS: CostLimits = { + ...ISOLINE_COST_LIMITS, + time: { ...ISOLINE_COST_LIMITS.time, max: ISOLINE_FILTER_MAX_TIME_MINUTES }, +} + +// `cost_value` is described from the same limits `assertIsolineCostValue` enforces, +// so the LLM is never told a maximum the schema does not apply. +function buildIsolineCostSchema(limits: CostLimits) { + return z.object({ + profile: navigationProfileSchema, + cost_type: navigationMetricsSchema, + cost_value: z + .number() + .finite() + .positive() + .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${limits.time.max}), et en mètres si \`cost_type = \"distance\"\` (maximum : ${limits.distance.max}).`), + }).strict(); +} + +function assertIsolineCostValue(limits: CostLimits) { + return (input: { cost_type: NavigationMetrics; cost_value: number }, ctx: z.RefinementCtx) => { + const { max, name, unit } = limits[input.cost_type]; + + if (input.cost_value > max) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: max, + type: "number", + inclusive: true, + path: ["cost_value"], + message: `Le coût maximal en ${name} ne peut pas dépasser ${max} ${unit}.`, + }); + } } } +const isolineFilterSchema = isolinePointSchema + .merge(buildIsolineCostSchema(ISOLINE_FILTER_COST_LIMITS)) + .superRefine(assertIsolineCostValue(ISOLINE_FILTER_COST_LIMITS)) + .describe("Filtre les objets situés dans une isochrone (temps de trajet maximum fixé) ou une isodistance (distance maximale fixée) autour d'un point."); + // --- Shared GPF Inputs --- const gpfTypenameInputSchema = z.object({ @@ -182,13 +193,13 @@ const gpfSpatialFilterInputSchema = z.object({ .describe("Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux."), dwithin_point_filter: dwithinPointFilterSchema .optional() - .describe("Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux."), + .describe("Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux."), intersects_feature_filter: intersectsFeatureFilterSchema .optional() .describe("Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux."), - travel_time_filter: travelTimeFilterSchema + isoline_filter: isolineFilterSchema .optional() - .describe("Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux."), + .describe("Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux."), }) export const GPF_GET_FEATURES_SPATIAL_FILTER_KEYS = @@ -483,14 +494,12 @@ export const gpfGetFeatureByIdLayerPublishedInputSchema = generatePublishedInput // --- `gpf_isoline_layer` (proxy) --- -export const gpfIsolineLayerInputObjectSchema = isolinePointSchema.merge(z.object({ - profile: navigationProfileSchema, - cost_type: NavigationMetricsSchema, - cost_value: isolineCostValueSchema, -})).strict(); +export const gpfIsolineLayerInputObjectSchema = isolinePointSchema + .merge(buildIsolineCostSchema(ISOLINE_COST_LIMITS)) + .strict(); export const gpfIsolineLayerInputSchema = gpfIsolineLayerInputObjectSchema - .superRefine(assertIsolineCostValue); + .superRefine(assertIsolineCostValue(ISOLINE_COST_LIMITS)); export type GpfIsolineLayerInput = z.infer; diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts index 2f226161..497f411d 100644 --- a/src/wfs/spatialExtras.ts +++ b/src/wfs/spatialExtras.ts @@ -37,7 +37,7 @@ function spatialFilterToGeometry(spatialFilter: SpatialFilter, resolvedGeometryR return { type: "Point", coordinates: point }; } case "intersects_feature": - case "travel_time": + case "isoline": if (!resolvedGeometryRef) { throw new Error(`Le filtre spatial \`${spatialFilter.operator}\` exige la résolution préalable de la géométrie de référence.`); } @@ -51,7 +51,7 @@ function spatialFilterToGeometry(spatialFilter: SpatialFilter, resolvedGeometryR function spatialFilterToCentroid(spatialFilter: SpatialFilter, resolvedGeometryRef?: Geometry) : Point { switch (spatialFilter.operator) { case "dwithin_point" : - case "travel_time": + case "isoline": case "intersects_point": { return { type: "Point", coordinates: [spatialFilter.lon, spatialFilter.lat] }; } @@ -190,7 +190,7 @@ function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: Spatia // lies within `distance_m`, so the intersection is needed even for it. case "dwithin_point": case "intersects_feature": - case "travel_time": + case "isoline": case "bbox": { if (!intersectionArea) return null; // non-areal filter, or filter preparation failed return intersectionArea(geo); diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index 2cf703cf..9bc144a0 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -4,7 +4,7 @@ import { NavigationIsolineClient } from "../../src/gpf/navigation.js"; import { RateLimiter } from "../../src/helpers/RateLimiter.js"; describe("NavigationIsolineClient", () => { - it("should build a Valhalla travel-time isoline request and return its GeoJSON geometry", async () => { + it("should build a Valhalla isochrone request and return its GeoJSON geometry", async () => { const urls: string[] = []; const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts index 15867f0b..b76413c9 100644 --- a/test/proxy/execute.test.ts +++ b/test/proxy/execute.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; import type { GpfFeatureType } from "../../src/wfs/catalog.js"; -import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; +import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, type WfsClientLike, type IsolineResolver } from "../../src/proxy/execute"; import type { CompiledRequest } from "../../src/wfs/request"; import type { WfsFeatureCollectionResponse } from "../../src/wfs/types"; import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; @@ -96,18 +96,18 @@ function makeClient(overrides?: { return { client, requests }; } -// A resolver stub for the non-travel_time cases: `resolveTravelTime` is a required +// A resolver stub for the non-isoline cases: `resolveIsoline` is a required // dependency, but these queries must never invoke it — so this throws if they do, -// turning an accidental travel_time path into a loud test failure. -const unexpectedResolveTravelTime: TravelTimeResolver = () => { - throw new Error("resolveTravelTime should not be called for a non-travel_time query"); +// turning an accidental isoline path into a loud test failure. +const unexpectedResolveIsoline: IsolineResolver = () => { + throw new Error("resolveIsoline should not be called for a non-isoline query"); }; describe("proxy/execute · runGeometryFeatureQuery", () => { it("returns the RAW FeatureCollection with geometry preserved", async () => { const { client } = makeClient(); - const result = await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + const result = await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); // Geometry, crs and geometry_name must survive (opposite of the LLM trim path). expect(result.features?.[0]?.geometry).toEqual(collectionWithGeometry.features?.[0]?.geometry); @@ -119,7 +119,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { it("forces the geometry column into propertyName when `select` is given", async () => { const { client, requests } = makeClient(); - await runGeometryFeatureQuery({ ...baseInput, select: ["code_insee"] }, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + await runGeometryFeatureQuery({ ...baseInput, select: ["code_insee"] }, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); const propertyName = requests[0].query.propertyName; expect(propertyName).toBeDefined(); @@ -131,7 +131,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { it("requests WGS84 EPSG:4326 (lon/lat convention)", async () => { const { client, requests } = makeClient(); - await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); // srsName lands on request.query, which the proxy transport serializes into the fetch URL. expect(requests[0].query.srsName).toBe("EPSG:4326"); @@ -140,7 +140,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { it("includes the geometry column alongside all non-geometry props when no `select` is given", async () => { const { client, requests } = makeClient(); - await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); // With no select, compileQueryParts materializes the non-geometry columns; // the runner then appends the geometry column. @@ -157,7 +157,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { ["a missing type", { features: [] }], ])("rejects an off-contract response (%s) as a 502, not a valid layer", async (_label, badResponse) => { const { client } = makeClient({ responses: [badResponse as unknown as WfsFeatureCollectionResponse] }); - const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); // Now a ServiceResponseError(502) — an UPSTREAM anomaly — not a plain Error (which // server.ts would map to a misleading 500). Client still gets the generic phrase. await expect(promise).rejects.toMatchObject({ name: "ServiceResponseError", httpStatus: 502 }); @@ -171,7 +171,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { // The client-facing message stays generic, but the internal (logged) message must // carry the extracted upstream cause so a 200-error-body is distinguishable from a // `{}` in the logs — the whole point of routing it through extractJsonServiceError. - const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); await expect(promise).rejects.toMatchObject({ name: "ServiceResponseError", httpStatus: 502 }); await expect(promise).rejects.toThrow(/détail amont/); }); @@ -206,26 +206,26 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { }, }; - const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); expect(client.fetchFeatureCollection).toHaveBeenCalledTimes(2); expect(requests[1].body).toContain("INTERSECTS"); expect(result.type).toBe("FeatureCollection"); }); - it("resolves travel_time_filter via the injected isochrone resolver", async () => { + it("resolves isoline_filter via the injected isochrone resolver", async () => { const { client, requests } = makeClient(); - const resolveTravelTime = vi.fn(async () => ( + const resolveIsoline = vi.fn(async () => ( { type: "Polygon" as const, coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] } )); const input: GpfGetFeaturesInput = { ...baseInput, - travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }, + isoline_filter: { lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" }, }; - const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveTravelTime }); + const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveIsoline }); - expect(resolveTravelTime).toHaveBeenCalledOnce(); + expect(resolveIsoline).toHaveBeenCalledOnce(); // The compiled main request carries an INTERSECTS predicate built from the isochrone. expect(requests[0].body).toContain("INTERSECTS"); expect(result.type).toBe("FeatureCollection"); @@ -262,7 +262,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { }, }; - const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }); + const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }); // Two upstream fetches: reference-by-id, then the main query. expect(client.fetchFeatureCollection).toHaveBeenCalledTimes(2); @@ -289,7 +289,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { }; await expect( - runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }), + runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }), ).rejects.toThrow(/catalogue embarqué est probablement désynchronisé/); }); @@ -308,7 +308,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => { // Must propagate as-is (same instance), so server.ts maps it to 502 — not be // swallowed by the desync branch nor rewritten. await expect( - runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }), + runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }), ).rejects.toBe(upstream); }); }); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index ca96dd8d..ced687d9 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -20,7 +20,7 @@ vi.mock("../../src/proxy/execute", () => ({ runGeometryIsolineQuery: (...args: unknown[]) => runGeometryIsolineQuery(...args), })); vi.mock("../../src/proxy/transport", () => ({ - getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveTravelTime: vi.fn() }), + getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveIsoline: vi.fn() }), getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }), getDefaultGeometryIsolineQueryDeps: () => ({ getGeometry: vi.fn() }), })); @@ -258,6 +258,19 @@ describe("proxy/server", () => { expect(runGeometryIsolineQuery).not.toHaveBeenCalled(); }); + it("400 on a query token whose isoline_filter exceeds the filter's lower time limit", async () => { + // 121 minutes is accepted by the isoline service but not by `isoline_filter`: + // the proxy must re-run the filter's own limit on the decoded payload. + const token = encodeToken({ + kind: PROXY_TOKEN_KIND.query, + typename: "BDTOPO_V3:batiment", + isoline_filter: { lon: 2.35, lat: 48.85, profile: "car", cost_type: "time", cost_value: 121 }, + }, KEY); + const res = await request(baseUrl).get(layerPath(token)); + expect(res.status).toBe(400); + expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); + }); + it("404 when the by-id feature is absent (FeatureNotFoundError)", async () => { runGeometryFeatureByIdQuery.mockRejectedValue( new FeatureNotFoundError("Le feature 'batiment.404' est introuvable dans 'BDTOPO_V3:batiment'."), diff --git a/test/proxy/token.test.ts b/test/proxy/token.test.ts index 71999491..0cf911d7 100644 --- a/test/proxy/token.test.ts +++ b/test/proxy/token.test.ts @@ -45,9 +45,9 @@ const PARAM_SHAPES: Record = { where: [{ property: "hauteur", operator: "gt", value: "20" }], limit: 100, }, - travel_time: { + isoline: { typename: "BDTOPO_V3:batiment", - travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }, + isoline_filter: { lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" }, limit: 100, }, }; diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index 2aef5981..a3a9c2ae 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -5,7 +5,7 @@ import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; // Mock ONLY the I/O boundaries, so the real proxy transport code runs: // - fetchJSONPostWithLimit (the bounded WFS fetch, parses to JSON) — but keep the real error classes; -// - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the travel_time leg +// - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the isoline leg // goes through the SAME PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES bounds as WFS. // The real NavigationIsolineClient runs (only its fetcher is mocked), so this covers // the previously-untested gap where the isoline leg used unbounded fetchJSONGet. @@ -38,7 +38,7 @@ vi.mock("../../src/helpers/RateLimiter", () => ({ import { getDefaultGeometryIsolineQueryDeps, getProxyWfsClient, - resolveProxyTravelTimeGeometry, + resolveProxyIsolineGeometry, } from "../../src/proxy/transport"; import { resetEnv } from "../../src/config/env"; import { geometryToEwkt } from "../../src/wfs/geometry"; @@ -109,24 +109,24 @@ describe("proxy/transport · buildProxyTransport (via getProxyWfsClient)", () => }); }); -describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { - const travelTimeInput: GpfGetFeaturesInput = { +describe("proxy/transport · resolveProxyIsolineGeometry", () => { + const isolineInput: GpfGetFeaturesInput = { typename: "BDTOPO_V3:batiment", limit: 100, spatial_extras: [], - travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }, + isoline_filter: { lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" }, }; it("resolves the isochrone through the BOUNDED fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES) and returns EWKT", async () => { // The real NavigationIsolineClient runs; only its fetcher is mocked. This is - // the regression guard: the travel_time leg must NOT use the unbounded + // the regression guard: the isoline leg must NOT use the unbounded // fetchJSONGet (HTTP_TIMEOUT only) — it must go through fetchJSONGetWithLimit - // with the SAME bounds as the WFS leg, so a 2-call travel_time stays capped. + // with the SAME bounds as the WFS leg, so a 2-call isoline stays capped. fetchJSONGetWithLimit.mockResolvedValue({ geometry: { type: "Polygon", coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] }, }); - const result = await resolveProxyTravelTimeGeometry(travelTimeInput); + const result = await resolveProxyIsolineGeometry(isolineInput); expect(fetchJSONGetWithLimit).toHaveBeenCalledOnce(); const [url, timeoutMs, maxBytes, label] = fetchJSONGetWithLimit.mock.calls[0]; @@ -144,13 +144,13 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { expect(geometryToEwkt(result)).toMatch(/^SRID=4326;POLYGON/); }); - it("throws defensively if called without a travel_time filter", async () => { + it("throws defensively if called without an isoline filter", async () => { const noFilter: GpfGetFeaturesInput = { typename: "BDTOPO_V3:batiment", limit: 100, spatial_extras: [], }; - await expect(resolveProxyTravelTimeGeometry(noFilter)).rejects.toThrow(/travel_time/); + await expect(resolveProxyIsolineGeometry(noFilter)).rejects.toThrow(/isoline/); expect(fetchJSONGetWithLimit).not.toHaveBeenCalled(); }); }); diff --git a/test/tools/wfs/countFeatures.test.ts b/test/tools/wfs/countFeatures.test.ts index 54e1dc6e..a777921a 100644 --- a/test/tools/wfs/countFeatures.test.ts +++ b/test/tools/wfs/countFeatures.test.ts @@ -143,7 +143,7 @@ describe("Test GpfCountFeaturesTool", () => { ).toBeNull(); }); - it("should apply travel_time_filter before returning the count", async () => { + it("should apply isoline_filter before returning the count", async () => { const tool = new GpfCountFeaturesTool(); mockFeatureTypes({ [COMMUNE_TYPENAME]: polygonFeatureType }); captureIsochroneRequests(); @@ -154,10 +154,11 @@ describe("Test GpfCountFeaturesTool", () => { name: "gpf_count_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - travel_time_filter: { + isoline_filter: { lon: 2.337306, lat: 48.849319, - minutes: 5, + cost_type: "time", + cost_value: 5, profile: "car", }, }, diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index 8e82767d..4caa7c0d 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -241,12 +241,14 @@ describe("Test GpfGetFeaturesTool", () => { distance_m: expect.objectContaining({ type: "number" }), }), }); - expect(tool.toolDefinition.inputSchema.properties?.travel_time_filter).toMatchObject({ + expect(tool.toolDefinition.inputSchema.properties?.isoline_filter).toMatchObject({ type: "object", properties: expect.objectContaining({ lon: expect.objectContaining({ type: "number" }), lat: expect.objectContaining({ type: "number" }), - minutes: expect.objectContaining({ type: "number", maximum: 120 }), + cost_type: expect.objectContaining({ enum: ["time", "distance"] }), + // The filter's lower time limit, not the isoline service's 600 minutes. + cost_value: expect.objectContaining({ type: "number", description: expect.stringContaining("maximum : 120)") }), profile: expect.objectContaining({ enum: ["car", "pedestrian"] }), }), }); @@ -271,7 +273,7 @@ describe("Test GpfGetFeaturesTool", () => { }); }); - it("should compile travel_time_filter into a WFS request using an isochrone geometry", async () => { + it("should compile isoline_filter into a WFS request using an isochrone geometry", async () => { const tool = new GpfGetFeaturesTool(); mockFeatureTypes({ [COMMUNE_TYPENAME]: polygonFeatureType }); const isochroneUrls = captureIsochroneRequests(); @@ -282,10 +284,11 @@ describe("Test GpfGetFeaturesTool", () => { name: "gpf_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - travel_time_filter: { + isoline_filter: { lon: 2.337306, lat: 48.849319, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "pedestrian", }, }, @@ -412,7 +415,7 @@ describe("Test GpfGetFeaturesTool", () => { { bbox_filter: { west: 2.1, south: 48.7, east: 2.5, north: 48.9 } }, { dwithin_point_filter: { lon: 2.3, lat: 48.8, distance_m: 500 } }, { intersects_feature_filter: { typename: "ADMINEXPRESS-COG.LATEST:departement", feature_id: "departement.1" } }, - { travel_time_filter: { lon: 2.3, lat: 48.8, minutes: 10, profile: "pedestrian" } }, + { isoline_filter: { lon: 2.3, lat: 48.8, cost_type: "distance", cost_value: 10, profile: "pedestrian" } }, ]) { expect(() => gpfGetFeaturesInputSchema.parse({ typename: COMMUNE_TYPENAME, diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts index eda7587d..fef956bb 100644 --- a/test/wfs/queryPreparation.test.ts +++ b/test/wfs/queryPreparation.test.ts @@ -154,13 +154,14 @@ describe("gpfGetFeatures/queryPreparation", () => { expect(compiled.resolvedGeometryRef).toBe(resolvedGeometryRef); }); - it("should compile travel_time with resolved isochrone geometry", () => { + it("should compile isoline with resolved isochrone geometry", () => { const compiled = compileQueryParts({ ...baseInput, - travel_time_filter: { + isoline_filter: { lon: 2.3522, lat: 48.8566, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "pedestrian", }, }, wrappedFeatureType, diff --git a/test/wfs/spatialFilter.test.ts b/test/wfs/spatialFilter.test.ts index bc720e19..61c0f5c0 100644 --- a/test/wfs/spatialFilter.test.ts +++ b/test/wfs/spatialFilter.test.ts @@ -35,22 +35,24 @@ describe("getSpatialFilter", () => { }); }); - it("should map a travel_time_filter to the compiler spatial filter", () => { + it("should map an isoline_filter to the compiler spatial filter", () => { const input: GpfGetFeaturesInput = { ...baseInput, - travel_time_filter: { + isoline_filter: { lon: 2.3522, lat: 48.8566, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "pedestrian", }, }; expect(getSpatialFilter(input)).toEqual({ - operator: "travel_time", + operator: "isoline", lon: 2.3522, lat: 48.8566, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "pedestrian", }); }); @@ -112,50 +114,66 @@ describe("gpfGetFeaturesInputSchema spatial filters", () => { })).toThrow("Un seul filtre spatial est autorisé"); }); - it("should validate travel-time filters", () => { + it("should validate isoline filters", () => { expect(gpfGetFeaturesInputSchema.parse({ ...baseInput, - travel_time_filter: { + isoline_filter: { lon: 2.3522, lat: 48.8566, - minutes: 120, + cost_type: "time", + cost_value: 120, profile: "car", }, - }).travel_time_filter).toEqual({ + }).isoline_filter).toEqual({ lon: 2.3522, lat: 48.8566, - minutes: 120, + cost_type: "time", + cost_value: 120, profile: "car", }); }); - it("should reject invalid travel-time filters", () => { + it("should reject invalid isoline filters", () => { expect(() => gpfGetFeaturesInputSchema.parse({ ...baseInput, - travel_time_filter: { + isoline_filter: { lon: 2.3522, lat: 48.8566, - minutes: 0, + cost_type: "time", + cost_value: 0, profile: "pedestrian", }, })).toThrow(); expect(() => gpfGetFeaturesInputSchema.parse({ ...baseInput, - travel_time_filter: { + isoline_filter: { lon: 2.3522, lat: 48.8566, - minutes: 121, + cost_type: "time", + cost_value: 121, // the limit is lower for the filter than for the isoline tool profile: "pedestrian", }, })).toThrow(); expect(() => gpfGetFeaturesInputSchema.parse({ ...baseInput, - travel_time_filter: { + isoline_filter: { lon: 2.3522, lat: 48.8566, - minutes: 15, + cost_type: "distance", + cost_value: 50001, + profile: "pedestrian", + }, + })).toThrow(); + + expect(() => gpfGetFeaturesInputSchema.parse({ + ...baseInput, + isoline_filter: { + lon: 2.3522, + lat: 48.8566, + cost_type: "time", + cost_value: 15, profile: "bicycle", }, })).toThrow();