From 17c5d64d503cd6a03bdfc300728cb528b4cbb33d Mon Sep 17 00:00:00 2001 From: esgn <5435148+esgn@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:11:12 +0200 Subject: [PATCH] feat: enhance WFS search functionality to include relevance scores in results --- README.md | 2 +- package-lock.json | 34 +++++++++++------- package.json | 4 +-- src/gpf/wfs.ts | 5 +-- src/tools/GpfWfsSearchTypesTool.ts | 14 ++++---- test/gpf/wfs.test.ts | 42 +++++++++++++--------- test/tools/wfs.test.ts | 56 ++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3a4f18c..6b8ea6d 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ L'idée est ici de répondre à des précises en traitant côté serveur les app #### Explorer les tables * [gpf_wfs_list_types()](src/tools/GpfWfsListTypesTool.ts) pour **lister de façon exhaustive les types WFS connus du catalogue de schémas embarqué**. Cet outil est surtout utile pour un inventaire complet ou une exploration globale du catalogue ; pour trouver rapidement un type pertinent, préférer `gpf_wfs_search_types`. -* [gpf_wfs_search_types(keywords,max_results=10)](src/tools/GpfWfsSearchTypesTool.ts) pour **rechercher un type WFS pertinent à partir de mots-clés et obtenir un `typename` valide**. La recherche est textuelle et configurable via `GPF_WFS_MINISEARCH_OPTIONS`. +* [gpf_wfs_search_types(keywords,max_results=10)](src/tools/GpfWfsSearchTypesTool.ts) pour **rechercher un type WFS pertinent à partir de mots-clés et obtenir un `typename` valide**. La recherche est textuelle, configurable via `GPF_WFS_MINISEARCH_OPTIONS`, et renvoie aussi un score de pertinence quand il est disponible. > - Quels sont les millésimes ADMINEXPRESS disponibles sur la Géoplateforme? > - Quelle est la table de la BDTOPO correspondant aux bâtiments? diff --git a/package-lock.json b/package-lock.json index dc57d1c..e6524c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@ignfab/geocontext", - "version": "0.9.4", + "version": "0.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ignfab/geocontext", - "version": "0.9.4", + "version": "0.9.5", "license": "MIT", "dependencies": { "@camptocamp/ogc-client": "^1.3.0", - "@ignfab/gpf-schema-store": "^0.1.2", + "@ignfab/gpf-schema-store": "^0.1.3", "@rgrove/parse-xml": "^4.2.0", "https-proxy-agent": "^7.0.6", "jsts": "^2.12.1", @@ -663,21 +663,31 @@ } }, "node_modules/@ignfab/gpf-schema-store": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@ignfab/gpf-schema-store/-/gpf-schema-store-0.1.2.tgz", - "integrity": "sha512-kbtgvKJ8EurpXkTxZ6EnZqCmAAdvc2edhAr7LCgWMn7kFI7wqovN39X3HPKMoDVWBGvOTvW/vr2OrmOsrowffg==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@ignfab/gpf-schema-store/-/gpf-schema-store-0.1.3.tgz", + "integrity": "sha512-j/+4CJOicPzv7FyQS4LWA34mlluCZ7S0Y/Q6nKWuCVCd1n9h/+81E9LreilpRCxzvwM+8CWdUMkoCzCHMEjLkw==", "license": "MIT", "dependencies": { "@camptocamp/ogc-client": "^1.3.0", "@fast-csv/format": "^5.0.5", - "commander": "^13.1.0", + "commander": "^14.0.3", "js-yaml": "^4.1.1", - "minisearch": "^7.2.0" + "minisearch": "^7.2.0", + "zod": "^4.3.6" }, "bin": { "gpf-schema-store": "dist/cli.js" } }, + "node_modules/@ignfab/gpf-schema-store/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2554,12 +2564,12 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/component-emitter": { diff --git a/package.json b/package.json index 8073586..0d602c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ignfab/geocontext", - "version": "0.9.4", + "version": "0.9.5", "description": "An experimental MCP server providing access to the services and data of the french Geoplateform", "type": "module", "bin": { @@ -39,7 +39,7 @@ }, "dependencies": { "@camptocamp/ogc-client": "^1.3.0", - "@ignfab/gpf-schema-store": "^0.1.2", + "@ignfab/gpf-schema-store": "^0.1.3", "@rgrove/parse-xml": "^4.2.0", "https-proxy-agent": "^7.0.6", "jsts": "^2.12.1", diff --git a/src/gpf/wfs.ts b/src/gpf/wfs.ts index d588304..ec5c377 100644 --- a/src/gpf/wfs.ts +++ b/src/gpf/wfs.ts @@ -2,6 +2,7 @@ import { Collection, + CollectionSearchResult, getCollectionCatalog, MiniSearchCollectionSearchEngine, MiniSearchCollectionSearchOptions, @@ -184,8 +185,8 @@ export class WfsClient { return this.catalog.list(); } - async searchFeatureTypes(query: string, maxResults: number = 20): Promise { - return this.catalog.search(query, { + async searchFeatureTypesWithScores(query: string, maxResults: number = 20): Promise { + return this.catalog.searchWithScores(query, { limit: maxResults, }); } diff --git a/src/tools/GpfWfsSearchTypesTool.ts b/src/tools/GpfWfsSearchTypesTool.ts index 779ea84..2138017 100644 --- a/src/tools/GpfWfsSearchTypesTool.ts +++ b/src/tools/GpfWfsSearchTypesTool.ts @@ -25,6 +25,7 @@ const gpfWfsSearchTypeResultSchema = z.object({ id: z.string().describe("L'identifiant complet du type WFS."), title: z.string().describe("Le titre lisible du type WFS."), description: z.string().describe("La description du type WFS."), + score: z.number().describe("Le score de pertinence de la recherche.").optional(), }); const gpfWfsSearchTypesOutputSchema = z.object({ @@ -38,7 +39,7 @@ class GpfWfsSearchTypesTool extends MCPTool { description = [ "Recherche des types WFS de la Géoplateforme (GPF) à partir de mots-clés afin de trouver un identifiant de type (`typename`) valide.", "Utiliser ce tool avant `gpf_wfs_describe_type` ou `gpf_wfs_get_features` lorsque le nom exact du type n'est pas connu.", - "La recherche est textuelle (mini-search) et retourne une liste ordonnée de candidats avec leur identifiant, leur titre et leur description.", + "La recherche est textuelle (mini-search) et retourne une liste ordonnée de candidats avec leur identifiant, leur titre, leur description et un score de pertinence éventuel.", "Le paramètre `max_results` permet d'élargir le nombre de candidats retournés (10 par défaut).", ].join("\r\n"); protected outputSchemaShape = gpfWfsSearchTypesOutputSchema; @@ -47,12 +48,13 @@ class GpfWfsSearchTypesTool extends MCPTool { async execute(input: GpfWfsSearchTypesInput) { const maxResults = input.max_results || 10; - const featureTypes = await wfsClient.searchFeatureTypes(input.query, maxResults); + const featureTypes = await wfsClient.searchFeatureTypesWithScores(input.query, maxResults); return { - results: featureTypes.map((featureType) => ({ - id: featureType.id, - title: featureType.title, - description: featureType.description, + results: featureTypes.map(({ collection, score }) => ({ + id: collection.id, + title: collection.title, + description: collection.description, + ...(score !== undefined ? { score } : {}), })), }; } diff --git a/test/gpf/wfs.test.ts b/test/gpf/wfs.test.ts index f301ea8..d91634e 100644 --- a/test/gpf/wfs.test.ts +++ b/test/gpf/wfs.test.ts @@ -92,20 +92,20 @@ describe("Test WfsClient",() => { }); }); - describe("searchFeatureTypes",() => { + describe("searchFeatureTypesWithScores",() => { it("should find BDTOPO_V3:batiment for 'bâtiments bdtopo'", async () => { - const featureTypes = await wfsClient.searchFeatureTypes("bâtiments bdtopo"); - expect(featureTypes).toBeDefined(); - expect(featureTypes.length).toBeGreaterThan(0); - const featureTypeNames= featureTypes.map((featureType)=>featureType.id); - expect(featureTypeNames).toContain("BDTOPO_V3:batiment"); - }); - it("should find BDTOPO_V3:departement and ADMINEXPRESS-COG.LATEST:departement for 'départements'", async () => { - const featureTypes = await wfsClient.searchFeatureTypes("départements"); - expect(featureTypes).toBeDefined(); - expect(featureTypes.length).toBeGreaterThan(0); - const featureTypeNames= featureTypes.map((featureType)=>featureType.id); - expect(featureTypeNames).toContain("BDTOPO_V3:departement"); + const featureTypes = await wfsClient.searchFeatureTypesWithScores("bâtiments bdtopo"); + expect(featureTypes).toBeDefined(); + expect(featureTypes.length).toBeGreaterThan(0); + const featureTypeNames= featureTypes.map((featureType)=>featureType.collection.id); + expect(featureTypeNames).toContain("BDTOPO_V3:batiment"); + }); + it("should find BDTOPO_V3:departement and ADMINEXPRESS-COG.LATEST:departement for 'départements'", async () => { + const featureTypes = await wfsClient.searchFeatureTypesWithScores("départements"); + expect(featureTypes).toBeDefined(); + expect(featureTypes.length).toBeGreaterThan(0); + const featureTypeNames= featureTypes.map((featureType)=>featureType.collection.id); + expect(featureTypeNames).toContain("BDTOPO_V3:departement"); expect(featureTypeNames).toContain("ADMINEXPRESS-COG.LATEST:departement"); }); @@ -118,16 +118,24 @@ describe("Test WfsClient",() => { boost: { title: 4.0 }, } }); - const featureTypes = await tuned.searchFeatureTypes("bâtiments bdtopo"); + const featureTypes = await tuned.searchFeatureTypesWithScores("bâtiments bdtopo"); expect(featureTypes).toBeDefined(); expect(featureTypes.length).toBeGreaterThan(0); - const featureTypeNames= featureTypes.map((featureType)=>featureType.id); + const featureTypeNames= featureTypes.map((featureType)=>featureType.collection.id); expect(featureTypeNames).toContain("BDTOPO_V3:batiment"); }); + it("should return scored results for 'bâtiments bdtopo'", async () => { + const featureTypes = await wfsClient.searchFeatureTypesWithScores("bâtiments bdtopo"); + expect(featureTypes).toBeDefined(); + expect(featureTypes.length).toBeGreaterThan(0); + const batimentResult = featureTypes.find((featureType) => featureType.collection.id === "BDTOPO_V3:batiment"); + expect(batimentResult).toBeDefined(); + expect(batimentResult?.score).toEqual(expect.any(Number)); + }); }); - - describe("getFeatureType",() => { + + describe("getFeatureType",() => { it("should return the feature type with BDTOPO_V3:batiment", async () => { const featureType = await wfsClient.getFeatureType("BDTOPO_V3:batiment"); expect(featureType).toBeDefined(); diff --git a/test/tools/wfs.test.ts b/test/tools/wfs.test.ts index 5ea98ad..0233583 100644 --- a/test/tools/wfs.test.ts +++ b/test/tools/wfs.test.ts @@ -13,6 +13,7 @@ describe("Test GpfWfsSearchTypesTool",() => { id: "BDTOPO_V3:batiment", title: "Batiment", description: "Description de test", + score: 12.5, }, ], }; @@ -58,6 +59,7 @@ describe("Test GpfWfsSearchTypesTool",() => { results: [ { id: "BDTOPO_V3:batiment", + score: 12.5, }, ], }); @@ -66,11 +68,65 @@ describe("Test GpfWfsSearchTypesTool",() => { results: [ { id: "BDTOPO_V3:batiment", + score: 12.5, }, ], }); }); + it("should omit score when it is undefined", async () => { + class TestableGpfWfsSearchTypesToolWithoutScore extends GpfWfsSearchTypesTool { + async execute() { + return { + results: [ + { + id: "BDTOPO_V3:batiment", + title: "Batiment", + description: "Description de test", + }, + ], + }; + } + } + + const tool = new TestableGpfWfsSearchTypesToolWithoutScore(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_search_types", + arguments: { + query: "batiment", + max_results: 1, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + + const parsedTextContent = JSON.parse(textContent.text); + expect(parsedTextContent.results[0]).toMatchObject({ + id: "BDTOPO_V3:batiment", + }); + expect(parsedTextContent.results[0]).not.toHaveProperty("score"); + + expect(response.structuredContent).toBeDefined(); + expect(response.structuredContent).toMatchObject({ + results: [ + { + id: "BDTOPO_V3:batiment", + }, + ], + }); + const structuredContent = response.structuredContent as { results: Array> }; + expect(structuredContent.results[0]).not.toHaveProperty("score"); + }); + it("should return isError=true for invalid input", async () => { const tool = new GpfWfsSearchTypesTool(); const response = await tool.toolCall({