diff --git a/docs/config.md b/docs/config.md index e16eba78..ccd06692 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_isoline_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_isoline_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_isoline_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 1baf6e89..f9a2c6f8 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. | +| `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 `isoline_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/isodistance 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 : []. | | `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", @@ -1382,7 +1391,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu ] }, "default": [], - "description": "É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.\n`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.\n`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`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`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`.\n`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.\n`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\nLes `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.\nSi 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.\nSinon, 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." + "description": "É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.\n`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.\n`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`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`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 `isoline_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`.\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance 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.\n`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\nLes `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.\nSi 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.\nSinon, 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." } }, "required": [ @@ -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/itinerary.ts b/src/gpf/itinerary.ts index 8ad26a4e..8b704a1a 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -7,8 +7,7 @@ import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE } 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. +// Same engine as the `isoline_filter`, so that both report the same travel times. export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE; export const ITINERARY_PROFILES = NAVIGATION_PROFILES; export const ITINERARY_METRICS = NAVIGATION_METRICS; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index d95b50a8..b0b6b499 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -15,8 +15,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 NavigationMetric = 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 49b8b015..31058106 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 NavigationMetric, - 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,43 +109,63 @@ const isolinePointSchema = z.object({ lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), }).strict(); -const travelTimeFilterSchema = isolinePointSchema.extend({ - 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 .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: NavigationMetric; 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: NavigationMetric; 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({ @@ -181,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 = @@ -286,8 +298,8 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({ .describe(buildSpatialExtrasDescription( "chaque objet", GPF_SPATIAL_EXTRAS_DOCNAMES, - "`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`.\n"+ - "`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.\n"+ + "`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 `isoline_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`.\n"+ + "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance 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.\n"+ "`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\n"+ "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." )), @@ -482,14 +494,11 @@ export const gpfGetFeatureByIdLayerPublishedInputSchema = generatePublishedInput // --- `gpf_isoline_layer` (proxy) --- -export const gpfIsolineLayerInputObjectSchema = isolinePointSchema.extend({ - profile: navigationProfileSchema, - cost_type: navigationMetricsSchema, - cost_value: isolineCostValueSchema, -}).strict(); +export const gpfIsolineLayerInputObjectSchema = isolinePointSchema + .merge(buildIsolineCostSchema(ISOLINE_COST_LIMITS)); 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 8c3bfb0b..9c1852d6 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -5,7 +5,7 @@ import { RateLimiter } from "../../src/helpers/RateLimiter.js"; import { ServiceResponseError } from "../../src/helpers/http.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 7a2c0290..bfc69de7 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 41b19b03..3a127bc2 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() }), })); @@ -259,6 +259,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();