diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 195e600..ea17cae 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -2,9 +2,9 @@ name: Node.js CI on: push: - branches: [ "main" ] + branches: [ "main", "release/**" ] pull_request: - branches: [ "main" ] + branches: [ "main", "release/**" ] jobs: build: diff --git a/.gitignore b/.gitignore index 2e8aeb9..fefa1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ /.vscode/ .npmrc .codex -.env.e2e \ No newline at end of file +.env.e2e +.continue/ diff --git a/README.md b/README.md index 90c6c44..5afa698 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | Récupérer le cadastre | `cadastre` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [PARCELLAIRE-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_PARCELLAIRE-EXPRESS-PCI) | Parcelle cadastrale | | Récupérer les documents d'urbanisme | `urbanisme` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | PLU, POS, CC | | Récupérer les servitudes | `assiette_sup` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | SUP autour d'un lieu | +| Trouver les points d'intérêt proches| `pointsdinteret` | [Géocodage Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/geocodage/) | Trouver une couche WFS | `gpf_wfs_search_types` | [gpf-schema-store](https://github.com/ignfab/gpf-schema-store) | Trouver la table des bâtiments | | Décrire une couche WFS | `gpf_wfs_describe_type` | [gpf-schema-store](https://github.com/ignfab/gpf-schema-store) | Lister les champs disponibles | | Interroger une couche WFS | `gpf_wfs_get_features` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Extraire ou compter des objets | diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 28e5a59..4f970bc 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -61,6 +61,7 @@ Tous les tools exposent les mêmes annotations MCP dans leur définition `tools/ - [`gpf_wfs_describe_type`](#gpf_wfs_describe_type) - [`gpf_wfs_get_feature_by_id`](#gpf_wfs_get_feature_by_id) - [`gpf_wfs_get_features`](#gpf_wfs_get_features) +- [`pointsdinteret`](#pointsdinteret) ## `geocode` @@ -1492,3 +1493,153 @@ Aucun `outputSchema` unique n'est exposé. La sortie dépend de `result_type` (` | Succès `result_type="http_post_request"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Succès `result_type="http_get_url"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. | + +## `pointsdinteret` + +Code Source : [src/tools/PointsDInteretTool.ts](../src/tools/PointsDInteretTool.ts) + +### Titre + +Points d'intérêt obtenus par géocodage inverse + +### Description du tool + +``` +Renvoie les points d'intérêt les plus proches des coordonnées en entrée. +Le champ `name` contient le nom du point d'intérêt et le champ `categories` liste ses classifications. +Chaque résultat peut aussi inclure les coordonnées du point d'intérêt (`centroid`), sa distance aux coordonnées de départ (`distance`), ainsi que des informations de localisation (`city`, `zipcode`). +Les réultats sont classés par distance, puis par importance : utilisez des coordonnées précises et montez la valeur de `maximumResponses` si l'information ne semble pas assez pertinente. +Pour obtenir un résultat plus détaillé sur un point d'intérêt trouvé, appelez ensuite `wfs_search_types` avec des éléments pertinents de `category`, puis `wfs_get_features` avec le `typename` obtenu et les coordonnées du centroïde. +(source : Géoplateforme (service de géocodage)). +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `lat` | number | oui | La latitude du point. | +| `lon` | number | oui | La longitude du point. | +| `maximumResponses` | integer | non | Le nombre maximum de résultats à retourner (entre 1 et 20). Défaut : 3. | + +
+Schéma d’entrée brut + +```json +{ + "type": "object", + "properties": { + "lon": { + "type": "number", + "description": "La longitude du point.", + "minimum": -180, + "maximum": 180 + }, + "lat": { + "type": "number", + "description": "La latitude du point.", + "minimum": -90, + "maximum": 90 + }, + "maximumResponses": { + "type": "integer", + "description": "Le nombre maximum de résultats à retourner (entre 1 et 20). Défaut : 3.", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "lon", + "lat" + ] +} +``` + +
+ +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `results` | array | oui | La liste des points d'intérêt à proximité, ordonnée par distance. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "results": { + "type": "array", + "description": "La liste des points d'intérêt à proximité, ordonnée par distance.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Le nom du point d'intérêt trouvé." + }, + "categories": { + "type": "array", + "description": "Les catégories du point d'intérêt.", + "items": { + "type": "string" + } + }, + "city": { + "type": "string", + "description": "Le nom de la ville où est le point d'intérêt." + }, + "zipcode": { + "type": "string", + "description": "Le code postal du point d'intérêt" + }, + "distance": { + "type": "number", + "description": "La distance en mètres entre le point demandé et le point d'intérêt retenu." + }, + "centroid": { + "type": "object", + "description": "Les coordonnées du centre du point d'intérêt.", + "properties": { + "lon": { + "type": "number", + "description": "La longitude du point.", + "minimum": -180, + "maximum": 180 + }, + "lat": { + "type": "number", + "description": "La latitude du point.", + "minimum": -90, + "maximum": 90 + } + }, + "required": [ + "lon", + "lat" + ] + } + }, + "required": [ + "name", + "categories", + "distance" + ] + } + } + }, + "required": [ + "results" + ] +} +``` + +
+ +### Réponse MCP + +| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` | +| --- | --- | --- | --- | +| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | +| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. | diff --git a/src/gpf/pointsdinteret.ts b/src/gpf/pointsdinteret.ts new file mode 100644 index 0000000..edbd475 --- /dev/null +++ b/src/gpf/pointsdinteret.ts @@ -0,0 +1,79 @@ +import { fetchJSONGet } from "../helpers/http.js"; +import logger from "../logger.js"; +import type { JsonFetcher } from "../helpers/http.js"; +import { RateLimiter } from "../helpers/RateLimiter.js"; +import { getEnv } from "../config/env.js"; + +export const POINTSDINTERET_SOURCE = "Géoplateforme (service de géocodage)"; + +type RawPointsDInteretFeature = { + properties: { + toponym: string; + category: string[]; + city?: string[]; + postcode?: string[]; + distance: number; + }; + geometry: { + type: string; + coordinates: number[]; + }; +} + +export type PointsDInteretResult = { + name: string; + categories: string[]; + city?: string; + zipcode?: string; + distance: number; + centroid?: { + lon: number, + lat: number + }; +}; + +type RawPointsDInteretResponse = { + features?: RawPointsDInteretFeature[]; +}; + +export class PointsDInteretClient { + constructor( + private rateLimiter: RateLimiter, + private fetcher: JsonFetcher = fetchJSONGet, + ) {} + + /** + * Get the nearest points of interest for given coordinates + * + * @see https://geoservices.ign.fr/documentation/services/services-geoplateforme/geocodage + */ + async pointsdinteret(lon: number, lat: number, maximumResponses = 3): Promise { + await this.rateLimiter.limit(); + logger.debug(`[gpf:pointsdinteret] pointsdinteret(${lon}, ${lat}, ${maximumResponses})...`); + + const url = 'https://data.geopf.fr/geocodage/reverse/?' + new URLSearchParams({ + lon: String(lon), + lat: String(lat), + index: "poi", // we could also include "parcel" but it is redundant with the cadastre tool + limit: String(maximumResponses), + }).toString(); + + const json: RawPointsDInteretResponse = await this.fetcher(url); + const results = Array.isArray(json?.features) ? json.features : []; + return results.map((item) => ({ + name: item.properties.toponym, + categories: item.properties.category, + city: Array.isArray(item.properties.city) ? item.properties.city[0] : undefined, + zipcode: Array.isArray(item.properties.postcode) ? item.properties.postcode[0] : undefined, + distance: item.properties.distance, + centroid: item.geometry.type == "Point" ? { + lon: item.geometry.coordinates[0], + lat: item.geometry.coordinates[1] + } : undefined, + })); + } +} + +export const pointsdinteretClient = new PointsDInteretClient( + new RateLimiter({ name: "GPF_POINTSDINTERET", maxCalls: getEnv().GPF_GEOCODE_RATE_LIMIT, period: 1 }), +); diff --git a/src/tools/PointsDInteretTool.ts b/src/tools/PointsDInteretTool.ts new file mode 100644 index 0000000..6df402d --- /dev/null +++ b/src/tools/PointsDInteretTool.ts @@ -0,0 +1,84 @@ +/** + * MCP tool exposing reverse geocoding for points of interest. + */ + +import BaseTool from "./BaseTool.js"; +import { z } from "zod"; + +import { pointsdinteretClient, POINTSDINTERET_SOURCE } from "../gpf/pointsdinteret.js"; +import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { lonSchema, latSchema } from "../helpers/schemas.js"; +import logger from "../logger.js"; + +// --- Schema --- + +const pointsdinteretInputSchema = z.object({ + lon: lonSchema, + lat: latSchema, + maximumResponses: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Le nombre maximum de résultats à retourner (entre 1 et 50). Défaut : 3."), +}).strict(); + +// --- Types --- + +type PointsDInteretInput = z.infer; + +const pointsdinteretResultSchema = z + .object({ + name: z.string().describe("Le nom du point d'intérêt trouvé"), + categories: z.array(z.string()).describe("Ses catégories"), + city: z.string().optional().describe("Sa ville"), + zipcode: z.string().optional().describe("Son code postal"), + distance: z.number().describe("La distance en mètres entre le point demandé et le point d'intérêt retenu"), + centroid: z.object({ + lon: lonSchema, + lat: latSchema + }).optional().describe("Les coordonnées du centre du point d'intérêt") +}) +.catchall(z.unknown()); + +const pointsdinteretOutputSchema = z.object({ + results: z.array(pointsdinteretResultSchema).describe("La liste des points d'intérêt à proximité, ordonnée par distance."), +}); + +// --- Tool --- + +class PointsDInteretTool extends BaseTool { + name = "pointsdinteret"; + title = "Points d'intérêt obtenus par géocodage inverse"; + annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; + description = [ + "Renvoie les points d'intérêt les plus proches des coordonnées en entrée.", + "Le champ `name` contient le nom du point d'intérêt et le champ `categories` liste ses classifications.", + "Chaque résultat peut aussi inclure les coordonnées du point d'intérêt (`centroid`), sa distance aux coordonnées de départ (`distance`), ainsi que des informations de localisation (`city`, `zipcode`).", + "Les réultats sont classés par distance, puis par importance : utilisez des coordonnées précises et montez la valeur de `maximumResponses` si l'information ne semble pas assez pertinente.", + "Pour obtenir un résultat plus détaillé sur un point d'intérêt trouvé, appelez ensuite `wfs_search_types` avec des éléments pertinents de `category`, puis `wfs_get_features` avec le `typename` obtenu et les coordonnées du centroïde.", + `(source : ${POINTSDINTERET_SOURCE}).` + ].join("\n"); + protected outputSchemaShape = pointsdinteretOutputSchema; + + schema = pointsdinteretInputSchema; + + /** + * Returns the points of interest relevant to the requested point. + * + * @param input Normalized tool input. + * @returns The relevant points of interest. + */ + async execute(input: PointsDInteretInput) { + logger.info(`[tool] execute ${this.name} ...`, { + input: input + }); + + return { + results: await pointsdinteretClient.pointsdinteret(input.lon, input.lat, input.maximumResponses), + }; + } +} + +export default PointsDInteretTool; diff --git a/test/gpf/pointsdinteret.test.ts b/test/gpf/pointsdinteret.test.ts new file mode 100644 index 0000000..f176bf7 --- /dev/null +++ b/test/gpf/pointsdinteret.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { PointsDInteretClient } from "../../src/gpf/pointsdinteret.js"; +import { RateLimiter } from "../../src/helpers/RateLimiter.js"; + +const rawPointsDInteretServiceResponse = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 45.10948, + -12.855996 + ] + }, + "properties": { + "name": [ + "Pengoua Bolé" + ], + "toponym": "Pengoua Bolé", + "category": [ + "sommet", + "élément topographique ou forestier", + "détail orographique" + ], + "classification": 8, + "importance": 0.3, + "extrafields": { + "cleabs": "PAIOROGR0000001600001372" + }, + "citycode": [ + "97616" + ], + "depcode": [ + "976" + ], + "city": [ + "Sada" + ], + "postcode": [ + "97640" + ], + "territory": "DOMTOM", + "distance": 30, + "score": 0.997, + "_type": "poi" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 45.110309, + -12.859327 + ] + }, + "properties": { + "name": [ + "Sada" + ], + "toponym": "Sada", + "category": [ + "administratif", + "commune" + ], + "postcode": [ + "97640" + ], + "citycode": [ + "97616" + ], + "depcode": [ + "976" + ], + "classification": 2, + "importance": 0.9, + "extrafields": { + "population": "11156", + "status": "", + "cleabs": "COMMUNE_0000001600043245" + }, + "territory": "DOMTOM", + "distance": 407, + "score": 0.9593, + "_type": "poi" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 45.101193, + -12.805296 + ] + }, + "properties": { + "name": [ + "CC du Centre-Ouest" + ], + "toponym": "CC du Centre-Ouest", + "category": [ + "administratif", + "epci" + ], + "classification": 2, + "importance": 0.9, + "extrafields": { + "codes_insee_des_communes_membres": [ + "97617", + "97616", + "97614", + "97605", + "97613" + ], + "cleabs": "EPCI____0000002150236238" + }, + "territory": "DOMTOM", + "distance": 5684, + "score": 0.4316, + "_type": "poi" + } + } + ] +}; + +describe("Test PointsDInteretClient.pointsdinteret",() => { + it("should identify 'Pengoua Bolé'", async () => { + const rateLimiter = new RateLimiter({ name: "test", maxCalls: 100, period: 1 }); + const client = new PointsDInteretClient(rateLimiter, async () => ({ + features: [rawPointsDInteretServiceResponse.features[0], rawPointsDInteretServiceResponse.features[1]], + })); + const results = await client.pointsdinteret(45.104692, -12.84725, 3); + expect(results.length).toBeGreaterThan(0); + const firstItem = results[0]; + + expect(firstItem.name).toEqual("Pengoua Bolé"); + expect(firstItem.centroid?.lon).toBeCloseTo(45.10948); + expect(firstItem.centroid?.lat).toBeCloseTo(-12.855996); + expect(firstItem.categories).toEqual(["sommet", "élément topographique ou forestier", "détail orographique"]); + expect(firstItem.city).toEqual('Sada'); + expect(firstItem.zipcode).toEqual('97640'); + expect(firstItem.distance).toBeCloseTo(30) + + }); + + it("should honor maximumResponses", async () => { + const rateLimiter = new RateLimiter({ name: "test", maxCalls: 100, period: 1 }); + const client = new PointsDInteretClient(rateLimiter, async () => ({ + features: [rawPointsDInteretServiceResponse.features[2]], + })); + const results = await client.pointsdinteret(45.101193, -12.805296, 1); + + expect(results).toHaveLength(1); + }); + + it("should return an empty array for unknown points", async () => { + const rateLimiter = new RateLimiter({ name: "test", maxCalls: 100, period: 1 }); + const client = new PointsDInteretClient(rateLimiter, async () => ({ features: [] })); + const results = await client.pointsdinteret(-135.645895, -39.143907); + + expect(results).toEqual([]); + }); + +}); diff --git a/test/integration/level1-protocol/pointsdinteret.test.ts b/test/integration/level1-protocol/pointsdinteret.test.ts new file mode 100644 index 0000000..110800a --- /dev/null +++ b/test/integration/level1-protocol/pointsdinteret.test.ts @@ -0,0 +1,58 @@ +/** + * Integration test: geocode tool with real API calls. + */ + +import { describe, it, expect } from "vitest"; +import { callTool } from "../helpers/mcp-client.js"; +import { withMcpServer } from "../helpers/level1-fixtures.js"; +import { expectNonEmptyResults, expectToolCallToThrow } from "../helpers/level1-assertions.js"; +import { INTEGRATION_CONFIG } from "../config/shared.js"; + +export type PointsDInteretResult = { + results: Array<{ + name: string; + categories: string[]; + city?: string; + zipcode?: string; + distance: number; + centroid?: { + lon: number, + lat: number + }; + }>; +}; + + +describe("Geocode Tool (integration)", () => { + const { getHandle } = withMcpServer(); + + it("should find a result for a point in a Saint-Pierre (Réunion)", async () => { + const result = await callTool(getHandle().client, "pointsdinteret", { + lon: 55.482554, + lat: -20.904138, + maximumResponses: 1, + }); + + expectNonEmptyResults(result); + + const first = result.results[0]; + expect(first.name).toBeDefined(); + expect(first.categories).toBeDefined(); + expect(first.distance).toBeDefined(); + }, INTEGRATION_CONFIG.timeout); + + it("should find the Refuge de la Femma from approximate coordinates", async () => { + const result = await callTool(getHandle().client, "pointsdinteret", { + lon: 6.929378, + lat: 45.362713, + maximumResponses: 5, + }); + + expectNonEmptyResults(result); + const text = JSON.stringify(result).toLowerCase(); + expect(text).toContain("refuge de la femma"); + expect(text).toContain("val-cenis"); + expect(text).toContain("savoie"); + expect(text).toContain("auvergne-rhône-alpes"); + }, INTEGRATION_CONFIG.timeout); +}); diff --git a/test/integration/level2-agent/level2-agent.test.ts b/test/integration/level2-agent/level2-agent.test.ts index 3a4f296..306e72c 100644 --- a/test/integration/level2-agent/level2-agent.test.ts +++ b/test/integration/level2-agent/level2-agent.test.ts @@ -65,6 +65,13 @@ const mcpScenarios = [ expect(containsNumberInRange(normalizedFinalMessage, 1000, 1100)).toBe(true); }, }, + { + testName: "should chain geocode and pointsofinterest tools to answer the question", + userInput: "Qu'est-ce qui se trouve exactement à mi-chemin entre la place de la Contrescarpe et le Centre Pompidou ?", + expectedResponseFragments: ["Trésor", "Notre-Dame"], + toolMode: "mcp", + requiredToolCalls: ["geocode", "pointsofinterest"], + }, { testName: "should answer the question about 14 lycées near the chateau de Vincennes", userInput: "Combien de lycées sont situés à 2km du chateau de vincennes?", diff --git a/test/integration/samples.ts b/test/integration/samples.ts index 5fda541..274180d 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -19,6 +19,7 @@ export const EXPECTED_TOOL_NAMES = [ "cadastre", "urbanisme", "assiette_sup", + "pointsdinteret", "gpf_wfs_search_types", "gpf_wfs_describe_type", "gpf_wfs_get_features", diff --git a/test/tools/strict-input.test.ts b/test/tools/strict-input.test.ts index c541a3a..3887ff4 100644 --- a/test/tools/strict-input.test.ts +++ b/test/tools/strict-input.test.ts @@ -9,6 +9,7 @@ import GpfWfsDescribeTypeTool from "../../src/tools/GpfWfsDescribeTypeTool"; import GpfWfsGetFeatureByIdTool from "../../src/tools/GpfWfsGetFeatureByIdTool"; import GpfWfsGetFeaturesTool from "../../src/tools/GpfWfsGetFeaturesTool"; import GpfWfsSearchTypesTool from "../../src/tools/GpfWfsSearchTypesTool"; +import PointsDInteretTool from "../../src/tools/PointsDInteretTool"; import UrbanismeTool from "../../src/tools/UrbanismeTool"; const strictInputCases = [ @@ -65,6 +66,11 @@ const strictInputCases = [ tool: new UrbanismeTool(), validArguments: { lon: 2.3522, lat: 48.8566 }, }, + { + label: "PointsDInteretTool", + tool: new PointsDInteretTool(), + validArguments: { lon: 2.3522, lat: 48.8566 }, + } ] as const; describe("Strict tool input schemas", () => {