From 3fd376486f5b57e6f4e3b92acea22c7e4b4f822b Mon Sep 17 00:00:00 2001 From: esgn <5435148+esgn@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:12:48 +0200 Subject: [PATCH 1/4] fix(spatial_extras): null when not computable, 0 only for an empty overlap - intersection_area: null when the feature or the filter is not areal, or when the filter geometry could not be prepared; 0 only when both are areal and do not overlap - every extra is null on a missing or empty geometry - length sums the linear parts of a GeometryCollection; area of an empty Polygon is null - measures sum the parts of a GeometryCollection, overlaps included (JTS) - state the contract in deriveFromGeometry and in the LLM-facing descriptions - regenerate docs/mcp-tools.md --- docs/mcp-tools.md | 8 +- src/wfs/schema.ts | 8 +- src/wfs/spatialExtras.ts | 89 +++++++++++++---- test/tools/wfs/getFeatureById.test.ts | 6 +- test/wfs/response.test.ts | 134 ++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 28 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 1955974d..5ab202de 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1117,7 +1117,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | `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` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).
`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire d'intersection (en m²) entre la géométrie de l'objet renvoyé, qui doit être surfacique, et le filtre spatial.
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.
Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse. Valeur par défaut : []. | +| `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` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
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`. | @@ -1391,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` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).\n`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire d'intersection (en m²) entre la géométrie de l'objet renvoyé, qui doit être surfacique, et le filtre spatial.\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.\nSi une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse." + "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\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": [ @@ -2060,7 +2060,7 @@ Utiliser `spatial_extras` pour renvoyer une information géométrique dérivée | --- | --- | --- | --- | | `feature_id` | string | oui | Identifiant GPF exact de l'objet à récupérer, par exemple `commune.8952`. | | `select` | array | non | Liste des propriétés non géométriques à renvoyer. 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 l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).
`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).
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.
Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse. Valeur par défaut : []. | +| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`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).
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, par exemple `ADMINEXPRESS-COG.LATEST:commune`. |
@@ -2101,7 +2101,7 @@ Utiliser `spatial_extras` pour renvoyer une information géométrique dérivée ] }, "default": [], - "description": "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).\n`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).\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.\nSi une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse." + "description": "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\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).\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": [ diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index d6427400..0f5b8b45 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -151,8 +151,8 @@ export const GPF_SPATIAL_FILTER_DOCNAMES = GPF_GET_FEATURES_SPATIAL_FILTER_KEYS const SPATIAL_EXTRAS_BASE_DESCRIPTION_LINES = [ "`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.", "`bbox` est la boîte englobante de la géométrie.", - "`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).", - "`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).", + "`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).", ] as const; function buildSpatialExtrasDescription( @@ -167,7 +167,7 @@ function buildSpatialExtrasDescription( `${SPATIAL_EXTRAS_BASE_DESCRIPTION_LINES.join("\n")}\n`+ optionalFilterLine+ "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.\n"+ - "Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse."; + "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."; } function assertSpatialExtraSpatialFilterConsistency(input : Record, ctx : z.RefinementCtx) { @@ -192,7 +192,7 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({ "chaque objet", GPF_SPATIAL_EXTRAS_DOCNAMES, "`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n"+ - "`intersection_area` est l'aire d'intersection (en m²) entre la géométrie de l'objet renvoyé, qui doit être surfacique, et le filtre spatial." + "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre." )), }); diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts index 76829db9..fb8f7e99 100644 --- a/src/wfs/spatialExtras.ts +++ b/src/wfs/spatialExtras.ts @@ -5,7 +5,7 @@ import { area } from "@turf/area"; import { intersect } from "@turf/intersect"; import { circle } from "@turf/circle"; import { bboxPolygon } from "@turf/bbox-polygon"; -import type { Geometry, MultiPolygon, Point, Polygon, Position } from "geojson"; +import type { Geometry, LineString, MultiLineString, MultiPolygon, Point, Polygon, Position } from "geojson"; import distance from "../helpers/distance.js"; import { feature, featureCollection } from "@turf/helpers"; import { getSpatialFilter } from "./spatialFilter.js"; @@ -66,6 +66,9 @@ function spatialFilterToCentroid(spatialFilter: SpatialFilter, resolvedGeometryR /** Accepts any geometry and returns it as a Polygon or MultiPolygon, filtering * all 0D (point) and 1D (line) sub-geometries out. + * + * The polygons of a GeometryCollection are concatenated, not unioned: measures + * sum their parts, overlaps included, like JTS `GeometryCollection.getArea()`. */ function geometryToPolygons(geom: Geometry) : Polygon | MultiPolygon | null { switch(geom.type) { @@ -96,6 +99,39 @@ function geometryToPolygons(geom: Geometry) : Polygon | MultiPolygon | null { } } +/** Accepts any geometry and returns its linear (1D) parts as a LineString or + * MultiLineString, dropping empty lines, points and polygons. Returns null when + * nothing linear remains. + */ +function geometryToLines(geom: Geometry) : LineString | MultiLineString | null { + switch (geom.type) { + case "LineString": + return geom.coordinates.length >= 2 ? geom : null; + case "MultiLineString": { + const coordinates = geom.coordinates.filter((line) => line.length >= 2); + return coordinates.length == 0 ? null : { type: "MultiLineString", coordinates }; + } + case "GeometryCollection": { + const coordinates = geom.geometries + .map(geometryToLines) + .filter((lines) => lines !== null) + .flatMap((lines) => lines.type == "LineString" ? [lines.coordinates] : lines.coordinates); + return coordinates.length == 0 ? null : { type: "MultiLineString", coordinates }; + } + default: + return null; + } +} + +/** True when `geometry` is a GeoJSON geometry holding at least one finite position. */ +function isComputableGeometry(geometry: unknown) : geometry is Geometry { + try { + return bbox(geometry as Geometry).every(Number.isFinite); + } catch { + return false; + } +} + type SpatialContext = { filterCentroid: Point | null, filterPolygons: Polygon | MultiPolygon | null @@ -153,34 +189,37 @@ export function dropEmptyRings(geom: Geometry) : Polygon | MultiPolygon | null { return null; } -/** Return the areal (2D) intersection between a geometry and a spatial filter. - * null if the intersection has no area. +/** Return the area (m²) of the part of a geometry lying inside a spatial filter. + * + * null when it cannot be computed: the geometry or the filter has no areal part, + * or the filter geometry could not be prepared. 0 only when both are areal and + * do not overlap. */ -function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: SpatialFilter, filterPolygons: Polygon | MultiPolygon | null) : Polygon | MultiPolygon | null { +function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: SpatialFilter, filterPolygons: Polygon | MultiPolygon | null) : number | null { const geo = geometryToPolygons(geom); if (!geo) { - return null; + return null; // non-areal feature } switch (spatialFilter.operator) { case "intersects_point": - return null; // non-2D filter + return null; // non-areal filter (already rejected by the input schema) // Note: `dwithin_point` matches a feature as soon as any part of it // lies within `distance_m`, so the intersection is needed even for it. case "dwithin_point": case "intersects_feature": case "travel_time": { - if (!filterPolygons) return null; // non-2D filter + if (!filterPolygons) return null; // non-areal filter, or filter preparation failed // Whatever lies outside the feature's bbox cannot intersect it, so clipping the // reference first leaves the result unchanged while polyclip only ever processes // the neighbouring vertices. const clippedFilter = dropEmptyRings(bboxClip(filterPolygons, bbox(geo)).geometry); - if (!clippedFilter) return null; // no areal overlap + if (!clippedFilter) return 0; // no overlap const inter = intersect(featureCollection([feature(clippedFilter), feature(geo)])); - return inter == null ? null : inter.geometry; + return inter == null ? 0 : area(inter.geometry); } case "bbox": { - const clipped = bboxClip(geo, [spatialFilter.west, spatialFilter.south, spatialFilter.east, spatialFilter.north]); - return dropEmptyRings(clipped.geometry); + const clipped = dropEmptyRings(bboxClip(geo, [spatialFilter.west, spatialFilter.south, spatialFilter.east, spatialFilter.north]).geometry); + return clipped == null ? 0 : area(clipped); } default: // Make a compile-time error if a filter is missing from the switch const noFilter: never = spatialFilter; @@ -188,6 +227,16 @@ function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: Spatia } } +/** Compute the requested `spatial_extras` of one returned feature. + * + * Contract shared by every extra: + * 1. A mismatch that is knowable before the main WFS query (e.g. `area` on a layer + * whose geometry format is linear) is rejected upstream with an error, never here. + * 2. A value that cannot be computed for this feature (missing or empty geometry, + * no part of the required dimension, non-areal filter, internal failure) is `null`. + * 3. Otherwise the value is the computed one, `0` included: `0` always means the + * computation ran (e.g. no overlap with the filter), never "not computable". + */ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPostProcessInput, context: SpatialContext) { const spatial_extras = input.spatial_extras; @@ -197,8 +246,14 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo return ret; } - // Assume that geometry is a Geometry. Otherwise, all the required spatial_extra will be set to null. - const geo = geometry as Geometry; + if (!isComputableGeometry(geometry)) { + // Contract case 2: nothing can be computed on a missing or empty geometry. + for (const extra of spatial_extras) { + ret[extra] = null; + } + return ret; + } + const geo = geometry; if (spatial_extras.includes("centroid")) { try { @@ -223,7 +278,8 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo if (spatial_extras.includes("length")) { try { - ret.length = (geo.type == "LineString" || geo.type == "MultiLineString") ? turfLength(feature(geo), { units: "meters" }) : null; + const geoAsLines = geometryToLines(geo); + ret.length = geoAsLines ? turfLength(feature(geoAsLines), { units: "meters" }) : null; } catch { ret.length = null; } @@ -232,7 +288,7 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo if (spatial_extras.includes("area")) { try { const geoAsPolygons = geometryToPolygons(geo); - ret.area = geoAsPolygons ? area(geo) : null; + ret.area = geoAsPolygons ? area(geoAsPolygons) : null; } catch { ret.area = null; } @@ -261,8 +317,7 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo if (requires_intersection_area) { try { - const intersection = intersectionAreaWithSpatialFilter(geo, spatialFilter, context.filterPolygons); - ret.intersection_area = intersection ? area(intersection) : 0; + ret.intersection_area = intersectionAreaWithSpatialFilter(geo, spatialFilter, context.filterPolygons); } catch { ret.intersection_area = null; } diff --git a/test/tools/wfs/getFeatureById.test.ts b/test/tools/wfs/getFeatureById.test.ts index f3f6530d..c8cc7ff3 100644 --- a/test/tools/wfs/getFeatureById.test.ts +++ b/test/tools/wfs/getFeatureById.test.ts @@ -96,10 +96,10 @@ describe("Test GpfGetFeatureByIdTool", () => { description: "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n"+ "`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n"+ "`bbox` est la boîte englobante de la géométrie.\n"+ - "`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).\n"+ - "`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).\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"+ "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.\n"+ - "Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse.", + "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.", }, select: { type: "array", diff --git a/test/wfs/response.test.ts b/test/wfs/response.test.ts index 8d5aa6f5..f8132f42 100644 --- a/test/wfs/response.test.ts +++ b/test/wfs/response.test.ts @@ -8,6 +8,7 @@ import { postProcessFeatureCollection, } from "../../src/wfs/response"; import { type SpatialExtraOptions } from "../../src/wfs/schema"; +import type { Geometry } from "geojson"; describe("wfs_engine/response", () => { function getFeatures( @@ -377,6 +378,139 @@ describe("wfs_engine/response", () => { ); expect(intersectionArea([[outsideRing]])).toEqual(0); }); + + // Contract shared by every extra: `null` when the value cannot be computed for + // this feature, a number (`0` included) when the computation ran. + describe("spatial_extras contract", () => { + const square = [[2, 48], [2.1, 48], [2.1, 48.1], [2, 48.1], [2, 48]]; + const bbox_filter = { west: 1.9, south: 47.9, east: 2.2, north: 48.2 }; + const intersects_feature_filter = { typename: "REF:type", feature_id: "ref.1" }; + + function derive( + geometry: unknown, + spatial_extras: string[], + filters: Record = {}, + resolvedGeometryRef?: Geometry, + ) { + const result = transformFeatureCollectionResponse({ + type: "FeatureCollection", + features: [{ id: "f.1", geometry, properties: {} }], + }, { + typename: "TEST:type", + spatial_extras, + ...filters, + } as Parameters[1], resolvedGeometryRef); + + return getFeatures(result)[0]; + } + + it.each([ + ["an absent geometry", undefined], + ["a null geometry", null], + ["an empty Point", { type: "Point", coordinates: [] }], + ["an empty Polygon", { type: "Polygon", coordinates: [] }], + ["an empty GeometryCollection", { type: "GeometryCollection", geometries: [] }], + ])("should return null for every extra on %s", (_label, geometry) => { + const feature = derive( + geometry, + ["centroid", "bbox", "length", "area", "distance_to_filter", "intersection_area"], + { bbox_filter }, + ); + + expect(feature).toMatchObject({ + centroid: null, + bbox: null, + length: null, + area: null, + distance_to_filter: null, + intersection_area: null, + }); + }); + + it("should return null for intersection_area on a non-areal feature, like area", () => { + const feature = derive( + { type: "LineString", coordinates: [[2, 48], [2.1, 48.1]] }, + ["area", "intersection_area"], + { bbox_filter }, + ); + + expect(feature.area).toBeNull(); + expect(feature.intersection_area).toBeNull(); + }); + + it("should return null for intersection_area when the intersects_feature reference is not areal", () => { + const feature = derive( + { type: "Polygon", coordinates: [square] }, + ["intersection_area"], + { intersects_feature_filter }, + { type: "LineString", coordinates: [[1.9, 47.9], [2.2, 48.2]] }, + ); + + expect(feature.intersection_area).toBeNull(); + }); + + it("should return null for filter-dependent extras when the reference geometry could not be prepared", () => { + const feature = derive( + { type: "Polygon", coordinates: [square] }, + ["distance_to_filter", "intersection_area"], + { intersects_feature_filter }, + ); + + expect(feature.distance_to_filter).toBeNull(); + expect(feature.intersection_area).toBeNull(); + }); + + it("should return 0 for intersection_area when both geometries are areal and do not overlap", () => { + const farSquare = square.map(([lon, lat]) => [lon + 5, lat]); + const feature = derive( + { type: "Polygon", coordinates: [farSquare] }, + ["intersection_area"], + { intersects_feature_filter }, + { type: "Polygon", coordinates: [square] }, + ); + + expect(feature.intersection_area).toEqual(0); + }); + + it("should sum the linear parts of a GeometryCollection for length and ignore the other parts", () => { + const line = { type: "LineString", coordinates: [[2.3, 48.8], [2.31, 48.81]] }; + const lineLength = derive(line, ["length"]).length as number; + + const feature = derive({ + type: "GeometryCollection", + geometries: [line, line, { type: "Polygon", coordinates: [square] }], + }, ["length"]); + + expect(feature.length as number).toBeCloseTo(2 * lineLength, 6); + }); + + it("should sum the polygons of a GeometryCollection for area, overlaps included, like JTS", () => { + const shifted = square.map(([lon, lat]) => [lon + 0.05, lat]); // overlaps half of `square` + const squareArea = derive({ type: "Polygon", coordinates: [square] }, ["area"]).area as number; + + const feature = derive({ + type: "GeometryCollection", + geometries: [ + { type: "Polygon", coordinates: [square] }, + { type: "Polygon", coordinates: [shifted] }, + ], + }, ["area"]); + + expect(feature.area as number).toBeCloseTo(2 * squareArea, 0); + }); + + it("should return null for length when a GeometryCollection has no usable linear part", () => { + const feature = derive({ + type: "GeometryCollection", + geometries: [ + { type: "Polygon", coordinates: [square] }, + { type: "LineString", coordinates: [] }, + ], + }, ["length"]); + + expect(feature.length).toBeNull(); + }); + }); }); // --- postProcessFeatureCollection --- From 9e67f18cb89d3c3b6ab7e94ff24279cf27988974 Mon Sep 17 00:00:00 2001 From: esgn <5435148+esgn@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:31:28 +0200 Subject: [PATCH 2/4] fix(spatial_extras): validate filter-dependent extras in the input schema - move "requires a spatial filter" from compileQueryParts to the zod refinement: invalid-tool-params instead of execution-error, raised before any catalog or network call - reject distance_to_filter with intersects_point_filter (always 0) - list only the compatible filters in the error message - state the rule in the spatial_extras description, regenerate docs/mcp-tools.md - move the related tests from compileQueryParts to the tool --- docs/mcp-tools.md | 4 +- src/wfs/queryPreparation.ts | 7 --- src/wfs/schema.ts | 54 ++++++++++++++++++++---- src/wfs/spatialExtras.ts | 6 ++- test/tools/wfs/getFeatures.test.ts | 68 ++++++++++++++++++++++++++++++ test/wfs/queryPreparation.test.ts | 12 +----- 6 files changed, 120 insertions(+), 31 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 5ab202de..76a06dbd 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1117,7 +1117,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | `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` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
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 : []. | +| `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` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.
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`. | @@ -1391,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` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\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` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.\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": [ diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts index 575ce398..d3cc9a45 100644 --- a/src/wfs/queryPreparation.ts +++ b/src/wfs/queryPreparation.ts @@ -25,7 +25,6 @@ import type { import { GPF_SPATIAL_FILTER_DOCNAMES, queryIsGetFeaturesInput, - spatialExtraRequiresFilter, } from "./schema.js" import { @@ -188,7 +187,6 @@ export function compileQueryParts( let geometryName: string | undefined; const isGetFeatures = queryIsGetFeaturesInput(input); const spatialFilter = getSpatialFilter(input); - const spatialExtras = isGetFeatures ? input.spatial_extras : []; const fragments: string[] = []; // Keep the spatial predicate first: the GeoPlateforme GeoServer is sensitive @@ -221,11 +219,6 @@ export function compileQueryParts( const noFilter: never = spatialFilter; throw new Error(`Unhandled filter case: ${noFilter}`); } - } else if (spatialExtras.length > 0) { - const faultyExtra = spatialExtras.filter(spatialExtraRequiresFilter); - if (faultyExtra.length > 0) { - throw new Error(`Impossible de demander ${faultyExtra} sans spécifier de filtre géométrique (à choisir parmi ${GPF_SPATIAL_FILTER_DOCNAMES}).`); - } } for (const clause of input.where ?? []) { diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 0f5b8b45..7465d711 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -170,16 +170,51 @@ function buildSpatialExtrasDescription( "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."; } -function assertSpatialExtraSpatialFilterConsistency(input : Record, ctx : z.RefinementCtx) { +/** For each filter-dependent extra, the spatial filter that makes it meaningless, and why. */ +const FILTER_DEPENDENT_EXTRA_RULES: Record = { + distance_to_filter: { + incompatibleFilter: "intersects_point_filter", + incompatibleReason: "vaut toujours 0 avec `intersects_point_filter`, puisque chaque objet renvoyé contient le point : pour classer des objets selon leur distance à un point, utilisez plutôt `dwithin_point_filter`", + }, + intersection_area: { + incompatibleFilter: "intersects_point_filter", + incompatibleReason: "ne peut pas être calculé avec `intersects_point_filter`, car un point n'a pas de surface : utilisez plutôt un filtre surfacique", + }, +}; + +/** + * Rejects the filter-dependent extras (`distance_to_filter`, `intersection_area`) + * when no spatial filter is given, or when the given filter makes them meaningless. + * Both only depend on the input, hence invalid tool parameters rather than + * execution errors. + */ +function assertSpatialExtraSpatialFilterConsistency(input: z.infer, ctx: z.RefinementCtx) { const usedSpatialFilters = GPF_GET_FEATURES_SPATIAL_FILTER_KEYS.filter((key) => input[key] !== undefined); - const usedSpatiaExtras = input.spatial_extras as SpatialExtraOptions[]; - if (usedSpatialFilters.includes("intersects_point_filter") && usedSpatiaExtras.includes("intersection_area")) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["spatial_extras"], - message: `intersection_area ne peut être calculé que sur une géométrie surfacique, or le filtre spatial utilisé est ponctuel. Retirez intersection_area de spatial_extras.`, - }); + for (const extra of input.spatial_extras.filter(spatialExtraRequiresFilter)) { + const { incompatibleFilter, incompatibleReason } = FILTER_DEPENDENT_EXTRA_RULES[extra]; + + if (usedSpatialFilters.length === 0) { + const compatibleFilters = GPF_GET_FEATURES_SPATIAL_FILTER_KEYS + .filter((key) => key !== incompatibleFilter) + .map((name) => `\`${name}\``) + .join(", ") + .replace(/, ([^,]*)$/, ' ou $1'); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["spatial_extras"], + message: `\`${extra}\` exige un filtre spatial (${compatibleFilters}) : ajoutez un de ces filtres, ou retirez \`${extra}\` de \`spatial_extras\`.`, + }); + } else if (usedSpatialFilters.includes(incompatibleFilter)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["spatial_extras"], + message: `\`${extra}\` ${incompatibleReason}, ou retirez \`${extra}\` de \`spatial_extras\`.`, + }); + } } } @@ -192,7 +227,8 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({ "chaque objet", GPF_SPATIAL_EXTRAS_DOCNAMES, "`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n"+ - "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre." + "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n"+ + "`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`." )), }); diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts index fb8f7e99..3c130190 100644 --- a/src/wfs/spatialExtras.ts +++ b/src/wfs/spatialExtras.ts @@ -151,7 +151,8 @@ export function prepareSpatialContext(input: FeatureCollectionPostProcessInput, return context; } - // The existence of a spatial filter has already been validated, hence the final "!". + // The input schema guarantees a spatial filter when a filter-dependent extra is + // requested (`assertSpatialExtraSpatialFilterConsistency`), hence the final "!". // In case of internal error, the associated spatial_extra will be set to null. const spatialFilter = getSpatialFilter(input)!; @@ -302,7 +303,8 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo return ret; } - // The existence of a spatial filter has already been validated, hence the final "!". + // The input schema guarantees a spatial filter when a filter-dependent extra is + // requested (`assertSpatialExtraSpatialFilterConsistency`), hence the final "!". // In case of internal error, the associated spatial_extra will be set to null. const spatialFilter = getSpatialFilter(input)!; diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index 655cf725..6924ae37 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -380,6 +380,74 @@ describe("Test GpfGetFeaturesTool", () => { expect(mockFetchJSONPost).not.toHaveBeenCalled(); }); + describe("filter-dependent spatial_extras", () => { + const FILTER_DEPENDENT_EXTRAS = ["distance_to_filter", "intersection_area"] as const; + + async function callWith(args: Record) { + const tool = new GpfGetFeaturesTool(); + return tool.toolCall({ + params: { + name: "gpf_get_features", + arguments: { typename: COMMUNE_TYPENAME, ...args }, + }, + }); + } + + it.each(FILTER_DEPENDENT_EXTRAS)("should reject %s without a spatial filter as invalid tool parameters", async (extra) => { + const response = await callWith({ spatial_extras: [extra] }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: [ + expect.objectContaining({ + code: "custom", + name: "spatial_extras", + detail: expect.stringContaining(`\`${extra}\` exige un filtre spatial`), + }), + ], + }); + expect(mockGetFeatureType).not.toHaveBeenCalled(); + expect(mockFetchJSONPost).not.toHaveBeenCalled(); + }); + + it.each(FILTER_DEPENDENT_EXTRAS)("should reject %s with intersects_point_filter as invalid tool parameters", async (extra) => { + const response = await callWith({ + intersects_point_filter: { lon: 2.3, lat: 48.8 }, + spatial_extras: [extra], + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: [ + expect.objectContaining({ + code: "custom", + name: "spatial_extras", + detail: expect.stringContaining("intersects_point_filter"), + }), + ], + }); + expect(mockGetFeatureType).not.toHaveBeenCalled(); + expect(mockFetchJSONPost).not.toHaveBeenCalled(); + }); + + it("should accept both extras with any other spatial filter", () => { + for (const filter of [ + { 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" } }, + ]) { + expect(() => gpfGetFeaturesInputSchema.parse({ + typename: COMMUNE_TYPENAME, + spatial_extras: [...FILTER_DEPENDENT_EXTRAS], + ...filter, + })).not.toThrow(); + } + }); + }); + it("should reject legacy inputs removed from the public schema", async () => { const tool = new GpfGetFeaturesTool(); const response = await tool.toolCall({ diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts index de7f6594..35e43b86 100644 --- a/test/wfs/queryPreparation.test.ts +++ b/test/wfs/queryPreparation.test.ts @@ -5,10 +5,7 @@ import type { GpfFeatureType } from "../../src/wfs/catalog"; import { compileQueryParts } from "../../src/wfs/queryPreparation"; import type { GpfGetFeaturesInput, GpfCountFeaturesInput } from "../../src/wfs/schema"; import { geometryToEwkt } from "../../src/wfs/geometry"; -import { - GPF_SPATIAL_EXTRAS_REQUIRING_FILTER, - queryIsGetFeaturesInput, -} from "../../src/wfs/schema"; +import { queryIsGetFeaturesInput } from "../../src/wfs/schema"; describe("gpfGetFeatures/queryPreparation", () => { const featureType: OgcCollectionSchema = { @@ -209,13 +206,6 @@ describe("gpfGetFeatures/queryPreparation", () => { ); }); - it.each(GPF_SPATIAL_EXTRAS_REQUIRING_FILTER)("should reject %s without any spatial filter", (spatialExtra) => { - expect(() => compileQueryParts({ - ...baseInput, - spatial_extras: [spatialExtra], - }, wrappedFeatureType)).toThrow(`Impossible de demander ${spatialExtra} sans spécifier de filtre géométrique`); - }); - it("should build sortBy from structured order_by", () => { const compiled = compileQueryParts({ ...baseInput, From 8aa7c32fbca56a50c462ea42cde788e355434047 Mon Sep 17 00:00:00 2001 From: esgn <5435148+esgn@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:34:48 +0200 Subject: [PATCH 3/4] fix(properties): skip spatial_extras validation when none is requested buildPropertyNameWithGeometry always passes an empty spatial_extras list, and the `!spatial_extras` guard let it through: the by-id layer tool and the proxy by-id resolve then read the geometry format and threw on an unlisted or missing one. Return early on an empty list, so already minted by-id layer URLs do not depend on the catalog format values. --- src/wfs/properties.ts | 4 +++- test/wfs/properties.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts index 32bc22b2..ed583e2e 100644 --- a/src/wfs/properties.ts +++ b/src/wfs/properties.ts @@ -132,7 +132,9 @@ export function validateSelectProperty(featureType: GpfFeatureType, propertyName // --- Spatial Extras Validation --- function validateSpatialExtras(featureType: GpfFeatureType, geometryName: string, spatial_extras?: SpatialExtraOptions[]) { - if (!spatial_extras) { + // Nothing to validate without extras: do not read the geometry format, which + // cartographic callers (always `[]`) never need. + if (!spatial_extras?.length) { return; } const geometryType = getGeometryType(featureType, geometryName); diff --git a/test/wfs/properties.test.ts b/test/wfs/properties.test.ts index 1028c398..c213152b 100644 --- a/test/wfs/properties.test.ts +++ b/test/wfs/properties.test.ts @@ -6,6 +6,7 @@ import { resolveNonGeometryProperty, validateSelectProperty, buildPropertyName, + buildPropertyNameWithGeometry, } from "../../src/wfs/properties"; // --- Test fixtures --- @@ -278,3 +279,26 @@ describe("buildPropertyName", () => { .toEqual("name,geometry"); }); }); + +describe("buildPropertyNameWithGeometry", () => { + // Cartographic callers (by-id layer tool, proxy by-id resolve) request no + // spatial_extras, so the geometry format must not be read: an unlisted or + // missing format must not break already minted by-id layer URLs. + it.each([ + ["an unlisted format", { format: "geometry-multisurface", "x-ogc-role": "primary-geometry" }], + ["no format", { "x-ogc-role": "primary-geometry" }], + ])("should not read the geometry format when the geometry has %s", (_label, geometry) => { + const collection: OgcCollectionSchema = { + ...singleGeometryCollection, + properties: { + ...singleGeometryCollection.properties, + geometry: geometry as unknown as OgcCollectionProperty, + }, + }; + + expect(buildPropertyNameWithGeometry(asFeatureType("SINGLE:GEO", collection))) + .toEqual("geometry,name,population"); + expect(buildPropertyNameWithGeometry(asFeatureType("SINGLE:GEO", collection), ["name"])) + .toEqual("name,geometry"); + }); +}); From 66bb1f0e99beebc3db6d4891d2bf50213c192bcb Mon Sep 17 00:00:00 2001 From: esgn <5435148+esgn@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:41:14 +0200 Subject: [PATCH 4/4] fix(gpf_get_features): tell the LLM that spatial_extras only cover returned features - tool and spatial_extras descriptions: extras are computed after the query, on the returned features only, and cannot be used in where/order_by; a ranking or a sum needs numberReturned == numberMatched - "property does not exist" error: explain when the name is a spatial extra (message only, a real catalog column with that name is still accepted) --- docs/mcp-tools.md | 5 +++-- src/tools/GpfGetFeaturesTool.ts | 2 ++ src/wfs/properties.ts | 8 ++++++-- src/wfs/schema.ts | 3 ++- test/wfs/properties.test.ts | 10 ++++++++++ test/wfs/queryPreparation.test.ts | 9 +++++++++ 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 76a06dbd..193f9af6 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1095,6 +1095,7 @@ 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 `spatial_extras` pour obtenir des mesures calculées sur la géométrie (`centroid`, `bbox`, `length`, `area`, `distance_to_filter` 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 }`. @@ -1117,7 +1118,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | `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` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.
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 : []. | +| `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` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
`distance_to_filter` 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, le plus proche) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit`.
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`. | @@ -1391,7 +1392,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` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.\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` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\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` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n`distance_to_filter` 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, le plus proche) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit`.\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": [ diff --git a/src/tools/GpfGetFeaturesTool.ts b/src/tools/GpfGetFeaturesTool.ts index 719e3e07..f1a83e0c 100644 --- a/src/tools/GpfGetFeaturesTool.ts +++ b/src/tools/GpfGetFeaturesTool.ts @@ -17,6 +17,7 @@ import { type GpfGetFeaturesInput, gpfGetFeaturesPublishedInputSchema, GPF_SPATIAL_FILTER_DOCNAMES, + GPF_SPATIAL_EXTRAS_DOCNAMES, } from "../wfs/schema.js"; import logger from "../logger.js"; @@ -25,6 +26,7 @@ import logger from "../logger.js"; const GPF_GET_FEATURES_TOOL_DESCRIPTION = [ "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é (${GPF_SPATIAL_FILTER_DOCNAMES}) pour le spatial.`, + `Utiliser \`spatial_extras\` pour obtenir des mesures calculées sur la géométrie (${GPF_SPATIAL_EXTRAS_DOCNAMES}). 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 }`.", diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts index ed583e2e..18090fd0 100644 --- a/src/wfs/properties.ts +++ b/src/wfs/properties.ts @@ -10,7 +10,7 @@ import type { GpfFeatureType } from "./catalog.js"; import type { OgcCollectionProperty } from "@ignfab/gpf-schema-store"; import type { Geometry } from "geojson"; -import type { SpatialExtraOptions } from "./schema.js"; +import { GPF_GET_FEATURES_SPATIAL_EXTRAS, type SpatialExtraOptions } from "./schema.js"; // --- Geometry Resolution --- @@ -99,8 +99,12 @@ export function resolveNonGeometryProperty(featureType: GpfFeatureType, property const nonGeometryProperties = (Object.entries(featureType.schema.properties)) .filter(([_propertyName, property]) => Boolean((property as OgcCollectionProperty).type)) .map(([propertyName]) => propertyName); + // A spatial extra name is a likely mix-up (e.g. `order_by: area`): say what it is. + const spatialExtraHint = (GPF_GET_FEATURES_SPATIAL_EXTRAS as readonly string[]).includes(propertyName) + ? ` \`${propertyName}\` désigne un élément calculé par \`spatial_extras\` dans \`gpf_get_features\`, pas une propriété du type : il n'est utilisable ni dans \`select\`, ni dans \`where\`, ni dans \`order_by\`.` + : ""; throw new Error( - `La propriété '${propertyName}' n'existe pas pour '${featureType.typename}'. ` + + `La propriété '${propertyName}' n'existe pas pour '${featureType.typename}'.${spatialExtraHint} ` + `Propriétés non géométriques disponibles : ${nonGeometryProperties.join(", ")}. ` + `Appelle \`gpf_describe_type\` pour obtenir la signification de ces propriétés.`, ); diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 7465d711..6502bfd6 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -228,7 +228,8 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({ GPF_SPATIAL_EXTRAS_DOCNAMES, "`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n"+ "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n"+ - "`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`." + "`distance_to_filter` 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, le plus proche) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit`." )), }); diff --git a/test/wfs/properties.test.ts b/test/wfs/properties.test.ts index c213152b..af0b00cc 100644 --- a/test/wfs/properties.test.ts +++ b/test/wfs/properties.test.ts @@ -156,6 +156,16 @@ describe("getGeometryName", () => { }); describe("resolveNonGeometryProperty", () => { + it("should accept a real catalog property named like a spatial extra", () => { + const collection: OgcCollectionSchema = { + ...singleGeometryCollection, + properties: { ...singleGeometryCollection.properties, area: populationProperty }, + }; + + expect(resolveNonGeometryProperty(asFeatureType("SINGLE:GEO", collection), "area", "Error message")) + .toEqual(populationProperty); + }); + it("should return the property when it is non-geometric", () => { const result = resolveNonGeometryProperty( asFeatureType("SINGLE:GEO", singleGeometryCollection), diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts index 35e43b86..4459a0a0 100644 --- a/test/wfs/queryPreparation.test.ts +++ b/test/wfs/queryPreparation.test.ts @@ -48,6 +48,15 @@ describe("gpfGetFeatures/queryPreparation", () => { spatial_extras: [] }; + it.each<[string, Partial]>([ + ["select", { select: ["area"] }], + ["where", { where: [{ property: "area", operator: "gt", value: "1000" }] }], + ["order_by", { order_by: [{ property: "area", direction: "desc" }] }], + ])("should explain that a spatial extra is not a property when used in %s", (_clause, clause) => { + expect(() => compileQueryParts({ ...baseInput, ...clause }, wrappedFeatureType)) + .toThrow("`area` désigne un élément calculé par `spatial_extras` dans `gpf_get_features`, pas une propriété du type"); + }); + it("should compile where clauses", () => { const compiled = compileQueryParts({ ...baseInput,