Skip to content
Merged
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
48 changes: 24 additions & 24 deletions src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
]
`;

Expand Down
123 changes: 123 additions & 0 deletions src/__tests__/patternFly.search.test.ts
Original file line number Diff line number Diff line change
@@ -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', {
Expand Down
52 changes: 51 additions & 1 deletion src/patternFly.search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
fuzzySearch,
normalizeString,
type FuzzySearch,
type FuzzySearchOptions,
type FuzzySearchResult
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand All @@ -641,6 +690,7 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly
searchPatternFly.memo = memo(searchPatternFly, DEFAULT_OPTIONS.toolMemoOptions.searchPatternFlyDocs);

export {
calculateRelevance,
dynamicFilterPatternFly,
filterPatternFly,
searchPatternFly,
Expand Down
23 changes: 21 additions & 2 deletions src/tool.searchPatternFly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,15 @@ const searchPatternFlyTool = (options = getOptions()): McpTool => {

const results = new Map<string, Record<string, unknown>>();
const groupSortNames = new Map<string, string>();
const groupOrder = new Map<string, number>();
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;
}
Expand Down Expand Up @@ -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) || '';

Expand Down
Loading