diff --git a/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap b/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap index 439a7abd..492c94bc 100644 --- a/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap @@ -15,30 +15,6 @@ exports[`searchPatternFlyTool, callback should have a specific markdown format: Found 2 collections with 5 related resources. Use the attached resources to access and read full content.", "type": "text", }, - { - "description": "A resource collection series for Button", - "groupId": "123456", - "mimeType": "text/markdown", - "name": "Button (Collection)", - "type": "resource_link", - "uri": "patternfly://docs/123456", - }, - { - "description": "Design Guidelines", - "groupId": "123456", - "mimeType": "text/markdown", - "name": "Button - Design (v6)", - "type": "resource_link", - "uri": "patternfly://docs/34567b", - }, - { - "description": "Component JSON schema with property definitions for Button.", - "groupId": "123456", - "mimeType": "text/markdown", - "name": "Button - JSON Schema (v6)", - "type": "resource_link", - "uri": "patternfly://schemas/67b890", - }, { "description": "A resource collection series for Lorem Button", "groupId": "654321", @@ -71,6 +47,30 @@ Found 2 collections with 5 related resources. Use the attached resources to acce "type": "resource_link", "uri": "patternfly://schemas/678e90", }, + { + "description": "A resource collection series for Button", + "groupId": "123456", + "mimeType": "text/markdown", + "name": "Button (Collection)", + "type": "resource_link", + "uri": "patternfly://docs/123456", + }, + { + "description": "Design Guidelines", + "groupId": "123456", + "mimeType": "text/markdown", + "name": "Button - Design (v6)", + "type": "resource_link", + "uri": "patternfly://docs/34567b", + }, + { + "description": "Component JSON schema with property definitions for Button.", + "groupId": "123456", + "mimeType": "text/markdown", + "name": "Button - JSON Schema (v6)", + "type": "resource_link", + "uri": "patternfly://schemas/67b890", + }, ] `; diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index d45ca7d3..bdb2a15f 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -1,10 +1,133 @@ import { + calculateRelevance, dynamicFilterPatternFly, filterPatternFly, searchPatternFly, type FilterPatternFlyFilters } from '../patternFly.search'; +describe('calculateRelevance', () => { + it.each([ + { + description: 'exact match for name', + query: 'button', + result: { + name: 'button', + entries: [] + }, + expected: 0 + }, + { + description: 'exact match for name, casing', + query: 'Button', + result: { + name: 'button', + entries: [] + }, + expected: 0 + }, + { + description: 'exact match on displayName', + query: 'Action Button', + result: { + name: 'btn-component', + entries: [ + { displayName: 'Action Button' } + ] + }, + expected: 0 + }, + { + description: 'exact match on displayName, casing', + query: 'action button', + result: { + name: 'btn-component', + entries: [ + { displayName: 'Action Button' } + ] + }, + expected: 0 + }, + { + description: 'exact match on multiple displayNames', + query: 'Secondary Button', + result: { + name: 'button', + entries: [ + { displayName: 'Primary Button' }, + { displayName: 'Secondary Button' } + ] + }, + expected: 0 + }, + { + description: 'contains match in name', + query: 'modal', + result: { + name: 'modal-box', + entries: [] + }, + expected: 1 + }, + { + description: 'contains match in displayName', + query: 'group', + result: { + name: 'btn', + entries: [ + { displayName: 'Button Group' } + ] + }, + expected: 1 + }, + { + description: 'contains match with casing', + query: 'ALERT', + result: { + name: 'inline-alert-box', + entries: [] + }, + expected: 1 + }, + { + description: 'no match on name or displayNames', + query: 'dropdown', + result: { + name: 'button', + entries: [ + { displayName: 'Primary Button' } + ] + }, + expected: 2 + }, + { + description: 'undefined entries', + query: 'table', + result: { + name: 'card', + entries: undefined + }, + expected: 2 + }, + { + description: 'missing or empty displayName entries', + query: 'toolbar', + result: { + name: 'page', + entries: [ + { displayName: '' }, + { displayName: undefined } + ] + }, + expected: 2 + } + ])('should create a relevance score, $description', ({ query, result, expected }) => { + const relevance = calculateRelevance(result as any, query); + + expect(relevance).toBe(expected); + }); +}); + describe('filterPatternFly', () => { const mockResources = new Map([ ['button', { diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index 1ccf5089..17f4c03c 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -1,5 +1,6 @@ import { fuzzySearch, + normalizeString, type FuzzySearch, type FuzzySearchOptions, type FuzzySearchResult @@ -170,6 +171,47 @@ type FilterPatternFlyMemoArgs = [ settings?: FilterPatternFlySettings | undefined ]; +/** + * Rate how closely a search result matches a search query. + * + * @note We prioritize **exact name matches** because users/agents respond best when + * the returned item’s `name` (or any of its display names) exactly equals their typed + * search, even if other metadata might be a better match. + * + * @param {SearchPatternFlyResult} result - Result object containing name and display name props. + * @param query - Query string for comparison. + * @returns `result` relevance to a `normalizedQuery`: + * - `0`: Exact match + * - `1`: Contains match + * - `2`: Everything else + */ +const calculateRelevance = ( + result: SearchPatternFlyResult, + query: string +): number => { + const normalizedName = normalizeString.memo(result.name); + const normalizedQuery = normalizeString.memo(query); + + if (normalizedName === normalizedQuery) { + return 0; + } + + const displayNames = (result.entries || []) + .map(entry => (entry.displayName ? normalizeString.memo(entry.displayName) : '')) + .filter(Boolean); + + if (displayNames.some(name => name === normalizedQuery)) { + return 0; + } + + if (normalizedName.includes(normalizedQuery) || + displayNames.some(name => name.includes(normalizedQuery))) { + return 1; + } + + return 2; +}; + /** * Apply sequenced priority filters for predictable filtering, filter PatternFly data. * @@ -616,7 +658,14 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly return a.distance - b.distance; } - return a.name.localeCompare(b.name); + const relevantA = calculateRelevance(a, coercedSearchQuery); + const relevantB = calculateRelevance(b, coercedSearchQuery); + + if (relevantA !== relevantB) { + return relevantA - relevantB; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); }; const sortedExactMatches = exactMatches.sort(sortByDistanceByName); @@ -641,6 +690,7 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly searchPatternFly.memo = memo(searchPatternFly, DEFAULT_OPTIONS.toolMemoOptions.searchPatternFlyDocs); export { + calculateRelevance, dynamicFilterPatternFly, filterPatternFly, searchPatternFly, diff --git a/src/tool.searchPatternFly.ts b/src/tool.searchPatternFly.ts index aa6982d9..fdedb55d 100644 --- a/src/tool.searchPatternFly.ts +++ b/src/tool.searchPatternFly.ts @@ -89,10 +89,15 @@ const searchPatternFlyTool = (options = getOptions()): McpTool => { const results = new Map>(); const groupSortNames = new Map(); + const groupOrder = new Map(); let numberCollections = 0; let numberRecords = 0; - parseResults.forEach(result => { + parseResults.forEach((result, index) => { + if (!groupOrder.has(result.groupId)) { + groupOrder.set(result.groupId, index); + } + if (results.has(result.groupId)) { return; } @@ -164,8 +169,22 @@ const searchPatternFlyTool = (options = getOptions()): McpTool => { const gidA = a.groupId as string; const gidB = b.groupId as string; - // 1. Sort by Group (using the Collection's name) + // 1. Sort by Group (Relevance order from search results, or alphabetical for wildcard all) if (gidA !== gidB) { + if (isSearchWildCardAll) { + const nameA = groupSortNames.get(gidA) || ''; + const nameB = groupSortNames.get(gidB) || ''; + + return nameA.localeCompare(nameB); + } + + const orderA = groupOrder.get(gidA) ?? Number.MAX_SAFE_INTEGER; + const orderB = groupOrder.get(gidB) ?? Number.MAX_SAFE_INTEGER; + + if (orderA !== orderB) { + return orderA - orderB; + } + const nameA = groupSortNames.get(gidA) || ''; const nameB = groupSortNames.get(gidB) || '';