Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ L'idée est ici de répondre à des questions précises en traitant côté serve

> Ex: Quelles assiettes de SUP sont présentes autour de la mairie de Vincennes ?

Les tools WFS orientés "objet" (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`) exposent un `feature_ref { typename, feature_id }` quand l'objet source est réutilisable tel quel dans un appel ultérieur à `gpf_wfs_get_feature_by_id` ou `gpf_wfs_get_features` (ex : `spatial_operator="intersects_feature"`).
Les tools WFS orientés "objet" (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`) exposent un `feature_ref { typename, feature_id }` quand l'objet source est réutilisable tel quel dans un appel ultérieur à `gpf_wfs_get_feature_by_id` ou `gpf_wfs_get_features` (ex : `spatial_filter={ type: "intersects_feature", feature_ref: ... }`).

### Explorer les données vecteurs

Expand Down Expand Up @@ -236,15 +236,15 @@ Le tool accepte un contrat structuré :
- `select` pour choisir les propriétés à renvoyer
- `where` pour filtrer les objets
- `order_by` pour trier les résultats
- `spatial_operator` et ses paramètres dédiés pour le spatial
- `spatial_filter` pour le spatial
- `result_type="request"` pour récupérer la requête compilée en `POST`, ainsi qu'une `get_url` dérivée quand elle reste raisonnablement portable en GET

Exemples :

- `where=[{ property: "code_insee", operator: "eq", value: "25000" }]`
- `spatial_operator="bbox"` avec `bbox_west`, `bbox_south`, `bbox_east`, `bbox_north`
- `spatial_operator="dwithin_point"` avec `dwithin_lon`, `dwithin_lat`, `dwithin_distance_m`
- `spatial_operator="intersects_feature"` avec `intersects_feature_typename` et `intersects_feature_id` issus d'une `feature_ref`
- `spatial_filter={ type: "bbox", bbox: { west, south, east, north } }`
- `spatial_filter={ type: "dwithin_point", point: { lon, lat }, distance_m }`
- `spatial_filter={ type: "intersects_feature", feature_ref: { typename, feature_id } }` issu d'une `feature_ref`

> - Quelles sont les 5 communes les plus peuplées du Doubs (25)?
> - Combien y a-t-il de bâtiments à moins de 5 km de la tour Eiffel?
Expand Down
263 changes: 167 additions & 96 deletions docs/mcp-tools.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/gpf/adminexpress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ const ADMINEXPRESS_TYPENAMES = ADMINEXPRESS_TYPES.map((type) => `ADMINEXPRESS-CO
export async function getAdminUnits(lon: number, lat: number): Promise<AdminUnit[]> {
logger.info(`[adminexpress] getAdminUnits(${lon},${lat})...`);

const spatialFilter: SpatialFilter = { operator: "intersects_point", lon, lat };
const spatialFilter: SpatialFilter = {
type: "intersects_point",
point: { lon, lat },
};

// Resolve and compile one spatial filter per typename to avoid relying on
// cross-layer geometry property homogeneity.
Expand Down
5 changes: 2 additions & 3 deletions src/gpf/parcellaire-express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ export async function getParcellaireExpress(lon: number, lat: number): Promise<P
logger.info(`getParcellaireExpress(${lon},${lat}) ...`);

const spatialFilter: SpatialFilter = {
operator: "dwithin_point",
lon,
lat,
type: "dwithin_point",
point: { lon, lat },
distance_m: 10,
};

Expand Down
10 changes: 4 additions & 6 deletions src/gpf/urbanisme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,8 @@ export async function getUrbanisme(lon: number, lat: number): Promise<Record<str
logger.info(`getUrbanisme(${lon},${lat})...`);

const spatialFilter: SpatialFilter = {
operator: "dwithin_point",
lon,
lat,
type: "dwithin_point",
point: { lon, lat },
distance_m: 30,
};

Expand Down Expand Up @@ -118,9 +117,8 @@ export async function getAssiettesServitudes(lon: number, lat: number): Promise<
logger.info(`getAssiettesServitudes(${lon},${lat})...`);

const spatialFilter: SpatialFilter = {
operator: "dwithin_point",
lon,
lat,
type: "dwithin_point",
point: { lon, lat },
distance_m: 30,
};

Expand Down
23 changes: 12 additions & 11 deletions src/helpers/wfs_engine/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,15 @@ type GeometryLike = {
export function ensureIntersectsFeatureTargetsOtherTypename(
input: GpfWfsGetFeaturesInput,
) {
const spatialFilter = getSpatialFilter(input);

if (
input.spatial_operator === "intersects_feature" &&
input.intersects_feature_typename !== undefined &&
input.typename === input.intersects_feature_typename
spatialFilter?.type === "intersects_feature" &&
input.typename === spatialFilter.feature_ref.typename
) {
throw new Error(
"Le filtre `intersects_feature` sur le même `typename` retourne potentiellement plusieurs objets. " +
"Utiliser `gpf_wfs_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_id }` pour cibler exactement un objet.",
"Utiliser `gpf_wfs_get_feature_by_id` avec `{ typename, feature_id: spatial_filter.feature_ref.feature_id }` pour cibler exactement un objet.",
);
}
}
Expand Down Expand Up @@ -103,25 +104,25 @@ export async function resolveIntersectsFeatureGeometry(
input: GpfWfsGetFeaturesInput,
): Promise<ResolvedFeatureGeometryRef | undefined> {
const spatialFilter = getSpatialFilter(input);
if (!spatialFilter || spatialFilter.operator !== "intersects_feature") {
if (!spatialFilter || spatialFilter.type !== "intersects_feature") {
return undefined;
}

const referenceFeatureType = await getFeatureType(spatialFilter.typename);
const referenceFeatureType = await getFeatureType(spatialFilter.feature_ref.typename);
const referenceGeometryProperty = getGeometryProperty(referenceFeatureType);
const featureCollection = await fetchFeatureById({
typename: spatialFilter.typename,
feature_id: spatialFilter.feature_id,
typename: spatialFilter.feature_ref.typename,
feature_id: spatialFilter.feature_ref.feature_id,
propertyName: referenceGeometryProperty.name,
});
const referenceFeature = requireSingleFeatureById(featureCollection, {
typename: spatialFilter.typename,
feature_id: spatialFilter.feature_id,
typename: spatialFilter.feature_ref.typename,
feature_id: spatialFilter.feature_ref.feature_id,
});

if (!isGeometryLike(referenceFeature?.geometry)) {
throw new Error(
`Le feature de référence '${spatialFilter.feature_id}' n'a pas de géométrie exploitable.`,
`Le feature de référence '${spatialFilter.feature_ref.feature_id}' n'a pas de géométrie exploitable.`,
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/helpers/wfs_engine/queryPreparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function compileWhereClause(featureType: Collection, geometryProperty: Collectio
featureType,
geometryProperty,
clause.property,
"La propriété '{property}' est géométrique. Utiliser `spatial_operator` et ses paramètres dédiés."
"La propriété '{property}' est géométrique. Utiliser `spatial_filter`."
);
const normalized = normalizeWhereClause(property, clause);

Expand Down Expand Up @@ -194,7 +194,7 @@ export function compileQueryParts(
// Keep the spatial predicate first: the GeoPlateforme GeoServer is sensitive
// to filter ordering and may reject equivalent filters when attributes come first.
if (spatialFilter) {
switch (spatialFilter.operator) {
switch (spatialFilter.type) {
case "bbox":
fragments.push(compileBboxSpatialFilter(geometryProperty, spatialFilter));
break;
Expand Down
84 changes: 64 additions & 20 deletions src/helpers/wfs_engine/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const DEFAULT_LIMIT = 100;
export const MAX_LIMIT = 5000;
export const REQUEST_GET_URL_MAX_LENGTH = 6000;
export const WHERE_OPERATORS = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "is_null"] as const;
export const SPATIAL_OPERATORS = ["bbox", "intersects_point", "dwithin_point", "intersects_feature"] as const;
export const SPATIAL_FILTER_TYPES = ["bbox", "intersects_point", "dwithin_point", "intersects_feature"] as const;
export const ORDER_DIRECTIONS = ["asc", "desc"] as const;

// --- Shared Clauses ---
Expand All @@ -35,13 +35,67 @@ const orderBySchema = z.object({
direction: z.enum(ORDER_DIRECTIONS).default("asc").describe("Direction de tri : `asc` ou `desc`."),
}).strict().describe("Critère de tri structuré. Exemple : `{ property: \"population\", direction: \"desc\" }`.");

const spatialPointSchema = z.object({
lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`."),
lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`."),
}).strict().describe("Point en WGS84 `lon/lat`.");

const spatialBboxSchema = z.object({
west: lonSchema.describe("Longitude ouest en WGS84 `lon/lat`."),
south: latSchema.describe("Latitude sud en WGS84 `lon/lat`."),
east: lonSchema.describe("Longitude est en WGS84 `lon/lat`."),
north: latSchema.describe("Latitude nord en WGS84 `lon/lat`."),
}).strict().describe("Boite englobante en WGS84 `lon/lat`.");

const spatialFeatureRefSchema = z.object({
typename: z.string().trim().min(1).describe("Type WFS du feature de référence."),
feature_id: z.string().trim().min(1).describe("Identifiant du feature de référence."),
}).strict().describe("Référence légère vers un feature WFS réutilisable.");

export const spatialFilterSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("bbox"),
bbox: spatialBboxSchema,
}).strict().describe("Filtre spatial par boite englobante."),
z.object({
type: z.literal("intersects_point"),
point: spatialPointSchema,
}).strict().describe("Filtre spatial par intersection avec un point."),
z.object({
type: z.literal("dwithin_point"),
point: spatialPointSchema,
distance_m: z.number().finite().positive().describe("Distance en metres."),
}).strict().describe("Filtre spatial par distance autour d'un point."),
z.object({
type: z.literal("intersects_feature"),
feature_ref: spatialFeatureRefSchema,
}).strict().describe("Filtre spatial par intersection avec un feature de reference."),
]);

// --- Shared Types ---

export type SpatialPoint = {
lon: number;
lat: number;
};

export type SpatialBbox = {
west: number;
south: number;
east: number;
north: number;
};

export type SpatialFeatureRef = {
typename: string;
feature_id: string;
};

export type SpatialFilter =
| { operator: "bbox"; west: number; south: number; east: number; north: number }
| { operator: "intersects_point"; lon: number; lat: number }
| { operator: "dwithin_point"; lon: number; lat: number; distance_m: number }
| { operator: "intersects_feature"; typename: string; feature_id: string };
| { type: "bbox"; bbox: SpatialBbox }
| { type: "intersects_point"; point: SpatialPoint }
| { type: "dwithin_point"; point: SpatialPoint; distance_m: number }
| { type: "intersects_feature"; feature_ref: SpatialFeatureRef };

// --- Shared Compact Outputs ---

Expand Down Expand Up @@ -91,26 +145,16 @@ export const gpfWfsGetFeaturesInputSchema = z.object({
.min(1)
.optional()
.describe("Clauses de filtre attributaire, combinées avec `AND`."),
spatial_operator: z
.enum(SPATIAL_OPERATORS)
spatial_filter: spatialFilterSchema
.optional()
.describe("Type optionnel de filtre spatial."),
bbox_west: lonSchema.describe("Longitude ouest en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(),
bbox_south: latSchema.describe("Latitude sud en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(),
bbox_east: lonSchema.describe("Longitude est en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(),
bbox_north: latSchema.describe("Latitude nord en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(),
intersects_lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"intersects_point\"`.").optional(),
intersects_lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"intersects_point\"`.").optional(),
dwithin_lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"dwithin_point\"`.").optional(),
dwithin_lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"dwithin_point\"`.").optional(),
dwithin_distance_m: z.number().finite().positive().describe("Distance en mètres, utilisée avec `spatial_operator = \"dwithin_point\"`.").optional(),
intersects_feature_typename: z.string().trim().min(1).optional().describe("Type WFS du feature de référence, utilisé avec `spatial_operator = \"intersects_feature\"`."),
intersects_feature_id: z.string().trim().min(1).optional().describe("Identifiant du feature de référence, utilisé avec `spatial_operator = \"intersects_feature\"`."),
.describe("Filtre spatial optionnel. Variantes supportees : `{ type: \"bbox\", bbox: { west, south, east, north } }`, `{ type: \"intersects_point\", point: { lon, lat } }`, `{ type: \"dwithin_point\", point: { lon, lat }, distance_m }`, `{ type: \"intersects_feature\", feature_ref: { typename, feature_id } }`."),
}).strict();

// --- `gpf_wfs_get_features` Types ---

export type GpfWfsGetFeaturesInput = z.infer<typeof gpfWfsGetFeaturesInputSchema>;
export type GpfWfsGetFeaturesInput = Omit<z.infer<typeof gpfWfsGetFeaturesInputSchema>, "spatial_filter"> & {
spatial_filter?: SpatialFilter;
};
export type WhereClause = NonNullable<GpfWfsGetFeaturesInput["where"]>[number];
export type OrderByClause = NonNullable<GpfWfsGetFeaturesInput["order_by"]>[number];

Expand Down
20 changes: 10 additions & 10 deletions src/helpers/wfs_engine/spatialCql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import type { SpatialFilter } from "./schema.js";
* @param spatialFilter Normalized bbox filter.
* @returns A CQL bbox predicate.
*/
export function compileBboxSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract<SpatialFilter, { operator: "bbox" }>) {
if (spatialFilter.west >= spatialFilter.east) {
throw new Error("Le bbox est invalide : `bbox_west` doit être strictement inférieur à `bbox_east`.");
export function compileBboxSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract<SpatialFilter, { type: "bbox" }>) {
if (spatialFilter.bbox.west >= spatialFilter.bbox.east) {
throw new Error("Le bbox est invalide : `spatial_filter.bbox.west` doit être strictement inférieur à `spatial_filter.bbox.east`.");
}
if (spatialFilter.south >= spatialFilter.north) {
throw new Error("Le bbox est invalide : `bbox_south` doit être strictement inférieur à `bbox_north`.");
if (spatialFilter.bbox.south >= spatialFilter.bbox.north) {
throw new Error("Le bbox est invalide : `spatial_filter.bbox.south` doit être strictement inférieur à `spatial_filter.bbox.north`.");
}
return `BBOX(${geometryProperty.name},${spatialFilter.west},${spatialFilter.south},${spatialFilter.east},${spatialFilter.north},'EPSG:4326')`;
return `BBOX(${geometryProperty.name},${spatialFilter.bbox.west},${spatialFilter.bbox.south},${spatialFilter.bbox.east},${spatialFilter.bbox.north},'EPSG:4326')`;
}

/**
Expand All @@ -35,8 +35,8 @@ export function compileBboxSpatialFilter(geometryProperty: CollectionProperty, s
* @param spatialFilter Normalized point intersection filter.
* @returns A CQL intersects predicate.
*/
export function compileIntersectsPointSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract<SpatialFilter, { operator: "intersects_point" }>) {
return `INTERSECTS(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.lon} ${spatialFilter.lat}))`;
export function compileIntersectsPointSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract<SpatialFilter, { type: "intersects_point" }>) {
return `INTERSECTS(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.point.lon} ${spatialFilter.point.lat}))`;
}

/**
Expand All @@ -46,8 +46,8 @@ export function compileIntersectsPointSpatialFilter(geometryProperty: Collection
* @param spatialFilter Normalized distance filter.
* @returns A CQL dwithin predicate.
*/
export function compileDwithinSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract<SpatialFilter, { operator: "dwithin_point" }>) {
return `DWITHIN(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.lon} ${spatialFilter.lat}),${spatialFilter.distance_m},meters)`;
export function compileDwithinSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract<SpatialFilter, { type: "dwithin_point" }>) {
return `DWITHIN(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.point.lon} ${spatialFilter.point.lat}),${spatialFilter.distance_m},meters)`;
}

/**
Expand Down
Loading
Loading